feat(frontend): Stardew Valley-inspired 2D pixel art redesign
This commit is contained in:
parent
d1cc325eeb
commit
97816d9e27
10 changed files with 1082 additions and 551 deletions
|
|
@ -1,22 +0,0 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: '.',
|
||||
base: '/',
|
||||
server: {
|
||||
port: 18790,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,30 +1,52 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { Text, Html } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// ── Isometric grid helper: convert grid coords → world position ──
|
||||
// Isometric: x = (col - row) * tileW/2, z = (col + row) * tileH/2
|
||||
const TILE_W = 1.0;
|
||||
const TILE_H = 0.5;
|
||||
// ── Grid → pixel position helper ─────────────────────────────────
|
||||
// Simple top-down mapping: each tile is 32px × 32px
|
||||
export const TILE_SIZE = 32;
|
||||
|
||||
export function gridToWorld(row, col) {
|
||||
return [
|
||||
(col - row) * (TILE_W / 2),
|
||||
0,
|
||||
(col + row) * (TILE_H / 2),
|
||||
];
|
||||
export function gridToPixel(row, col) {
|
||||
return {
|
||||
left: col * TILE_SIZE + TILE_SIZE / 2,
|
||||
top: row * TILE_SIZE + TILE_SIZE / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// ── World pos to grid (inverse) ──────────────────────────────────
|
||||
export function worldToGrid(x, z) {
|
||||
const col = (x / (TILE_W / 2) + z / (TILE_H / 2)) / 2;
|
||||
const row = (z / (TILE_H / 2) - x / (TILE_W / 2)) / 2;
|
||||
return { row: Math.round(row), col: Math.round(col) };
|
||||
}
|
||||
// ── Thought bubble content map ───────────────────────────────────
|
||||
const THOUGHT_BUBBLES = {
|
||||
thinking: '...',
|
||||
blocked: '!',
|
||||
};
|
||||
|
||||
// ── Activity → glow color ────────────────────────────────────────
|
||||
const ACTIVITY_COLORS = {
|
||||
// ── Department accessory emoji ───────────────────────────────────
|
||||
const DEPARTMENT_ACCESSORIES = {
|
||||
'cos': '📋',
|
||||
'engineering': '⚙️',
|
||||
'senior-eng': '🔮',
|
||||
'trust': '🛡️',
|
||||
'market': '🔍',
|
||||
'health': '🌿',
|
||||
'content': '📝',
|
||||
'presentations': '✨',
|
||||
'vault': '🗝️',
|
||||
};
|
||||
|
||||
export default function AgentSprite({ agent, tileSize = TILE_SIZE }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
if (!agent || !agent.position) return null;
|
||||
|
||||
const { row, col } = agent.targetPosition || agent.position;
|
||||
const { left, top } = gridToPixel(row, col);
|
||||
|
||||
const deptKey = agent.department || agent.id;
|
||||
const departmentClass = `agent--${deptKey}`;
|
||||
const activity = agent.activity || 'idle';
|
||||
const task = agent.currentTask || null;
|
||||
const thought = THOUGHT_BUBBLES[activity] || null;
|
||||
const accessory = DEPARTMENT_ACCESSORIES[deptKey] || '';
|
||||
|
||||
// Status glow colors
|
||||
const glowColors = {
|
||||
idle: '#44cc44',
|
||||
thinking: '#ffcc00',
|
||||
working: '#4488ff',
|
||||
|
|
@ -32,159 +54,73 @@ const ACTIVITY_COLORS = {
|
|||
offline: '#666666',
|
||||
};
|
||||
|
||||
const LERP_SPEED = 0.08;
|
||||
|
||||
export default function AgentSprite({ agent, hovered, onHover }) {
|
||||
const meshRef = useRef();
|
||||
const glowRef = useRef();
|
||||
const [targetPos, setTargetPos] = useState(null);
|
||||
|
||||
// Initialize position
|
||||
if (!targetPos && agent.position) {
|
||||
setTargetPos(gridToWorld(agent.position.row, agent.position.col));
|
||||
} else if (agent.targetPosition) {
|
||||
setTargetPos(gridToWorld(agent.targetPosition.row, agent.targetPosition.col));
|
||||
}
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (!meshRef.current || !agent) return;
|
||||
|
||||
// Calculate target world position
|
||||
const tgt = agent.targetPosition || agent.position;
|
||||
if (!tgt) return;
|
||||
|
||||
const [tx, , tz] = gridToWorld(tgt.row, tgt.col);
|
||||
|
||||
// Smooth lerp toward target
|
||||
meshRef.current.position.x += (tx - meshRef.current.position.x) * LERP_SPEED;
|
||||
meshRef.current.position.z += (tz - meshRef.current.position.z) * LERP_SPEED;
|
||||
|
||||
// If close enough, snap to target
|
||||
if (
|
||||
Math.abs(tx - meshRef.current.position.x) < 0.01 &&
|
||||
Math.abs(tz - meshRef.current.position.z) < 0.01
|
||||
) {
|
||||
meshRef.current.position.x = tx;
|
||||
meshRef.current.position.z = tz;
|
||||
}
|
||||
|
||||
// Floating bob
|
||||
const bob = Math.sin(state.clock.elapsedTime * 2 + agent.position?.row || 0) * 0.05;
|
||||
meshRef.current.position.y = 0.15 + bob;
|
||||
|
||||
// Glow pulse based on activity
|
||||
if (glowRef.current) {
|
||||
const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.5 + 0.5;
|
||||
glowRef.current.material.opacity = agent.activity === 'working' ? 0.3 + pulse * 0.4 :
|
||||
agent.activity === 'thinking' ? 0.2 + pulse * 0.3 :
|
||||
agent.activity === 'offline' ? 0 :
|
||||
0.15;
|
||||
}
|
||||
});
|
||||
|
||||
if (!agent) return null;
|
||||
|
||||
const color = agent.activity === 'offline' ? '#666666' : agent.color;
|
||||
const glowColor = ACTIVITY_COLORS[agent.activity] || '#44cc44';
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Agent body — isometric cube */}
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={targetPos || [0, 0.15, 0]}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation();
|
||||
onHover(agent.id, true);
|
||||
<div
|
||||
className={`agent-sprite ${departmentClass}`}
|
||||
data-activity={activity}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transition: 'left 0.8s ease-in-out, top 0.8s ease-in-out',
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation();
|
||||
onHover(agent.id, false);
|
||||
}}
|
||||
castShadow
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<boxGeometry args={[0.4, 0.5, 0.3]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={agent.activity === 'offline' ? 0 : 0.3}
|
||||
roughness={0.6}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Thought bubble */}
|
||||
{thought && (
|
||||
<div className="agent-thought">{thought}</div>
|
||||
)}
|
||||
|
||||
{/* Glow ring below agent */}
|
||||
<mesh
|
||||
ref={glowRef}
|
||||
position={
|
||||
targetPos
|
||||
? [targetPos[0], 0.02, targetPos[2]]
|
||||
: [0, 0.02, 0]
|
||||
}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry args={[0.25, 0.35, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={glowColor}
|
||||
transparent
|
||||
opacity={0.2}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Agent name label */}
|
||||
<Text
|
||||
position={
|
||||
targetPos
|
||||
? [targetPos[0], 0.7, targetPos[2]]
|
||||
: [0, 0.7, 0]
|
||||
}
|
||||
fontSize={0.15}
|
||||
color={agent.activity === 'offline' ? '#888888' : '#ffffff'}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.01}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{/* Name label */}
|
||||
<div className="agent-name" style={{ top: -22 }}>
|
||||
{agent.name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Sparkle particles for working */}
|
||||
<div className="agent-sparkles">
|
||||
<div className="sparkle" />
|
||||
<div className="sparkle" />
|
||||
<div className="sparkle" />
|
||||
</div>
|
||||
|
||||
{/* Accessory icon above head */}
|
||||
{accessory && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: -18,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: '8px',
|
||||
lineHeight: 1,
|
||||
imageRendering: 'pixelated',
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(1px 1px 0 rgba(0,0,0,0.5))',
|
||||
}}>
|
||||
{accessory}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pixel-art body */}
|
||||
<div className="agent-body" />
|
||||
|
||||
{/* Status glow ring */}
|
||||
<div
|
||||
className="agent-glow"
|
||||
style={{
|
||||
background: glowColors[activity] || '#4c4',
|
||||
boxShadow: `0 0 4px ${glowColors[activity] || '#4c4'}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{hovered && (
|
||||
<Html
|
||||
position={
|
||||
targetPos
|
||||
? [targetPos[0], 0.9, targetPos[2]]
|
||||
: [0, 0.9, 0]
|
||||
}
|
||||
center
|
||||
distanceFactor={15}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'rgba(20,20,40,0.95)',
|
||||
color: '#eee',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'nowrap',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', color: agent.color, marginBottom: 4 }}>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8 }}>
|
||||
Status: <span style={{ color: glowColor }}>{agent.activity}</span>
|
||||
</div>
|
||||
{agent.currentTask && (
|
||||
<div style={{ opacity: 0.7, marginTop: 2 }}>
|
||||
{agent.currentTask}
|
||||
<div className="agent-tooltip">
|
||||
<strong>{agent.name}</strong>
|
||||
<div>Status: {activity}</div>
|
||||
{task && <div className="task">{task}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,58 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import useStore from './store.js';
|
||||
import wsClient from './WebSocketClient.js';
|
||||
import OfficeScene from './OfficeScene.jsx';
|
||||
import FarmScene from './OfficeScene.jsx';
|
||||
import './src/farm.css';
|
||||
|
||||
// ── Camera setup for true isometric ─────────────────────────────
|
||||
// Orthographic with rotation: 45° around Y, then ~35.264° tilt down (atan(1/√2))
|
||||
// But the React-idiomatic way: position the camera and use lookAt
|
||||
// Simpler: use orbit controls locked to isometric-ish angle
|
||||
function CameraSetup() {
|
||||
// We use OrbitControls with limited rotation for a fixed-ish isometric feel
|
||||
return null; // Controls are in the Canvas
|
||||
}
|
||||
|
||||
// ── UI Overlay ──────────────────────────────────────────────────
|
||||
function UIOverlay() {
|
||||
const agents = useStore(s => s.agents);
|
||||
// ── Connection indicator (top-right) ─────────────────────────────
|
||||
function ConnectionIndicator() {
|
||||
const connected = useStore(s => s.wsConnected);
|
||||
const reconnecting = useStore(s => s.wsReconnecting);
|
||||
|
||||
const status = connected ? 'Connected' : reconnecting ? 'Reconnecting' : 'Offline';
|
||||
const dotColor = connected ? '#44cc44' : reconnecting ? '#ffcc00' : '#ff4444';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
padding: '0 16px',
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
borderTop: '1px solid #333',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<span>Spatial Office</span>
|
||||
<span>|</span>
|
||||
<span>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: connected ? '#44cc44' : '#ff4444',
|
||||
marginRight: 4,
|
||||
verticalAlign: 'middle',
|
||||
}} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
<span>|</span>
|
||||
<span>{Object.keys(agents).length} agents</span>
|
||||
<div className="connection-indicator">
|
||||
<div
|
||||
className="connection-indicator_dot"
|
||||
style={{ background: dotColor, boxShadow: `0 0 6px ${dotColor}` }}
|
||||
/>
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App ─────────────────────────────────────────────────────────
|
||||
// ── Bottom bar ────────────────────────────────────────────────────
|
||||
function FarmBar() {
|
||||
const agents = useStore(s => s.agents);
|
||||
const connected = useStore(s => s.wsConnected);
|
||||
|
||||
return (
|
||||
<div className="farm-bar">
|
||||
<span>🏡 Spatial Office</span>
|
||||
<span className="farm-bar_separator">|</span>
|
||||
<span className="farm-bar_status">
|
||||
<span
|
||||
className="farm-bar_dot"
|
||||
style={{
|
||||
background: connected ? '#44cc44' : '#ff4444',
|
||||
boxShadow: `0 0 4px ${connected ? '#44cc44' : '#ff4444'}`,
|
||||
}}
|
||||
/>
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
<span className="farm-bar_separator">|</span>
|
||||
<span>{Object.keys(agents).length} farmhands</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App ───────────────────────────────────────────────────────────
|
||||
export default function App() {
|
||||
const hasConnected = useRef(false);
|
||||
const agents = useStore(s => s.agents);
|
||||
const connected = useStore(s => s.wsConnected);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConnected.current) return;
|
||||
|
|
@ -85,92 +77,17 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Canvas
|
||||
shadows
|
||||
gl={{
|
||||
antialias: true,
|
||||
toneMapping: THREE.ACESFilmicToneMapping,
|
||||
toneMappingExposure: 1.2,
|
||||
}}
|
||||
camera={{
|
||||
position: [18, 20, 18],
|
||||
near: 0.1,
|
||||
far: 200,
|
||||
}}
|
||||
style={{ background: '#1a1a2e' }}
|
||||
>
|
||||
{/* Orthographic camera for true isometric */}
|
||||
<OrthoCamera />
|
||||
<div className="farm-container">
|
||||
{/* Connection indicator */}
|
||||
<ConnectionIndicator />
|
||||
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight
|
||||
position={[10, 20, 5]}
|
||||
intensity={0.8}
|
||||
castShadow
|
||||
shadow-mapSize-width={1024}
|
||||
shadow-mapSize-height={1024}
|
||||
/>
|
||||
<hemisphereLight
|
||||
args={['#8ec8f2', '#332211', 0.3]}
|
||||
/>
|
||||
{/* Scrollable farm viewport */}
|
||||
<div className="farm-viewport">
|
||||
<FarmScene />
|
||||
</div>
|
||||
|
||||
{/* Limited orbit controls — zoom + pan, but locked rotation for isometric feel */}
|
||||
<OrbitControls
|
||||
enableRotate={true}
|
||||
enableZoom={true}
|
||||
enablePan={true}
|
||||
minZoom={5}
|
||||
maxZoom={40}
|
||||
maxPolarAngle={Math.PI / 2.2}
|
||||
dampingFactor={0.1}
|
||||
target={[0, 0, 6]}
|
||||
/>
|
||||
|
||||
{/* Office scene */}
|
||||
<OfficeScene />
|
||||
</Canvas>
|
||||
|
||||
<UIOverlay />
|
||||
{/* Bottom status bar */}
|
||||
<FarmBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ortho camera wrapper ────────────────────────────────────────
|
||||
function OrthoCamera() {
|
||||
const ref = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
// On mount, set orthographic projection
|
||||
const camera = ref.current;
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
const frustumSize = 18;
|
||||
|
||||
camera.left = -frustumSize * aspect / 2;
|
||||
camera.right = frustumSize * aspect / 2;
|
||||
camera.top = frustumSize / 2;
|
||||
camera.bottom = -frustumSize / 2;
|
||||
camera.near = 0.1;
|
||||
camera.far = 200;
|
||||
camera.position.set(14, 18, 14);
|
||||
camera.lookAt(0, 0, 5);
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
// Handle resize
|
||||
const onResize = () => {
|
||||
const a = window.innerWidth / window.innerHeight;
|
||||
camera.left = -frustumSize * a / 2;
|
||||
camera.right = frustumSize * a / 2;
|
||||
camera.top = frustumSize / 2;
|
||||
camera.bottom = -frustumSize / 2;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
return <orthographicCamera ref={ref} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,195 +1,170 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { Text, Html } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import useStore, { ZONES } from './store.js';
|
||||
import AgentSprite, { gridToWorld } from './AgentSprite.jsx';
|
||||
import React from 'react';
|
||||
import useStore from './store.js';
|
||||
import AgentSprite, { TILE_SIZE } from './AgentSprite.jsx';
|
||||
|
||||
// ── Tile rendering constants ─────────────────────────────────────
|
||||
const TILE_W = 1.0;
|
||||
const TILE_H = 0.5;
|
||||
const GRID_ROWS = 20;
|
||||
const GRID_COLS = 28;
|
||||
// ── Zone → building mapping with pixel positions ──────────────────
|
||||
// Grid is 28 cols × 20 rows at 32px each → 896×640 px game board
|
||||
const BUILDINGS = [
|
||||
{
|
||||
id: 'workshop',
|
||||
zoneId: 'pod-a',
|
||||
label: 'Workshop',
|
||||
cssClass: 'building--workshop',
|
||||
left: 272, // col 8 → 272px
|
||||
top: 64, // row 2 → 64px
|
||||
width: 160, // 5 cols × 32
|
||||
height: 160, // 5 rows × 32
|
||||
},
|
||||
{
|
||||
id: 'studio',
|
||||
zoneId: 'pod-b',
|
||||
label: 'Studio',
|
||||
cssClass: 'building--studio',
|
||||
left: 464, // col 14 → 464px
|
||||
top: 64, // row 2
|
||||
width: 160,
|
||||
height: 160,
|
||||
},
|
||||
{
|
||||
id: 'cabins',
|
||||
zoneId: 'solo-1',
|
||||
label: 'Cabins',
|
||||
cssClass: 'building--cabin',
|
||||
left: 16, // col 0 → 16px (with padding)
|
||||
top: 80, // row 2
|
||||
width: 192, // 6 cols × 32
|
||||
height: 320, // 10 rows × 32
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
zoneId: 'solo-2',
|
||||
label: 'Library',
|
||||
cssClass: 'building--library',
|
||||
left: 720, // col 22 → 720px
|
||||
top: 80, // row 2
|
||||
width: 192, // 6 cols
|
||||
height: 320, // 10 rows
|
||||
},
|
||||
{
|
||||
id: 'shrine',
|
||||
zoneId: 'coaching',
|
||||
label: 'Shrine',
|
||||
cssClass: 'building--shrine',
|
||||
left: 272, // col 8
|
||||
top: 416, // row 13
|
||||
width: 384, // 12 cols
|
||||
height: 128, // 4 rows
|
||||
},
|
||||
{
|
||||
id: 'campfire',
|
||||
zoneId: 'coffee',
|
||||
label: 'Campfire',
|
||||
cssClass: 'building--campfire',
|
||||
left: 336, // col 10
|
||||
top: 16, // row 0
|
||||
width: 256, // 8 cols
|
||||
height: 64, // 2 rows
|
||||
},
|
||||
];
|
||||
|
||||
// ── Zone colors ──────────────────────────────────────────────────
|
||||
const ZONE_COLORS = {
|
||||
'pod-a': '#C68B3C',
|
||||
'pod-b': '#C68B3C',
|
||||
'solo-1': '#3A7CA5',
|
||||
'solo-2': '#3A7CA5',
|
||||
'coaching': '#5C8A5C',
|
||||
'coffee': '#8B6914',
|
||||
};
|
||||
const FLOOR_COLOR = '#1a1a2e';
|
||||
const GRID_LINE_COLOR = '#2a2a4a';
|
||||
// ── Dirt paths (connecting roads between buildings) ───────────────
|
||||
// These are positioned absolutely as thin rectangles
|
||||
const PATHS = [
|
||||
// Horizontal main path (top)
|
||||
{ left: 0, top: 48, width: 896, height: 16 },
|
||||
// Horizontal middle path
|
||||
{ left: 0, top: 224, width: 896, height: 16 },
|
||||
// Vertical left path
|
||||
{ left: 208, top: 0, width: 16, height: 640 },
|
||||
// Vertical center path
|
||||
{ left: 432, top: 0, width: 16, height: 640 },
|
||||
// Vertical right path
|
||||
{ left: 704, top: 0, width: 16, height: 640 },
|
||||
// Bottom horizontal
|
||||
{ left: 0, top: 544, width: 896, height: 16 },
|
||||
];
|
||||
|
||||
// ── Isometric floor tile ─────────────────────────────────────────
|
||||
function FloorTile({ row, col, zoneId }) {
|
||||
const [wx, _, wz] = gridToWorld(row, col);
|
||||
const color = zoneId ? ZONE_COLORS[zoneId] || FLOOR_COLOR : FLOOR_COLOR;
|
||||
// ── Decorative trees/rocks/flowers ────────────────────────────────
|
||||
const DECORATIONS = [
|
||||
// Trees along edges
|
||||
...Array.from({ length: 6 }, (_, i) => ({ type: 'tree', left: i * 24 + 12, top: 596 })),
|
||||
...Array.from({ length: 6 }, (_, i) => ({ type: 'tree', left: i * 24 + 12, top: 44 })),
|
||||
...Array.from({ length: 4 }, (_, i) => ({ type: 'tree', left: 840, top: i * 48 + 80 })),
|
||||
// Rocks
|
||||
{ type: 'rock', left: 250, top: 560 },
|
||||
{ type: 'rock', left: 480, top: 570 },
|
||||
{ type: 'rock', left: 420, top: 550 },
|
||||
{ type: 'rock', left: 720, top: 560 },
|
||||
// Flowers
|
||||
{ type: 'flower', left: 240, top: 580, color: '#FF6347' },
|
||||
{ type: 'flower', left: 500, top: 590, color: '#FFD700' },
|
||||
{ type: 'flower', left: 350, top: 585, color: '#87CEEB' },
|
||||
{ type: 'flower', left: 650, top: 595, color: '#DDA0DD' },
|
||||
// Fence posts along top path
|
||||
...Array.from({ length: 10 }, (_, i) => ({ type: 'fence', left: i * 64 + 120, top: 56 })),
|
||||
];
|
||||
|
||||
// Create isometric diamond shape
|
||||
const shape = useMemo(() => {
|
||||
const s = new THREE.Shape();
|
||||
const hw = TILE_W / 2;
|
||||
const hh = TILE_H / 2;
|
||||
s.moveTo(0, hh);
|
||||
s.lineTo(hw, 0);
|
||||
s.lineTo(0, -hh);
|
||||
s.lineTo(-hw, 0);
|
||||
s.closePath();
|
||||
return s;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Floor diamond */}
|
||||
<mesh
|
||||
position={[wx, 0, wz]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<shapeGeometry args={[shape]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
roughness={0.9}
|
||||
metalness={0}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Grid line border */}
|
||||
<lineLoop position={[wx, 0.001, wz]}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
count={5}
|
||||
array={new Float32Array([
|
||||
-TILE_W / 2, 0, 0,
|
||||
0, 0, -TILE_H / 2,
|
||||
TILE_W / 2, 0, 0,
|
||||
0, 0, TILE_H / 2,
|
||||
-TILE_W / 2, 0, 0,
|
||||
])}
|
||||
itemSize={3}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial color={GRID_LINE_COLOR} transparent opacity={0.3} />
|
||||
</lineLoop>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zone label overlay ──────────────────────────────────────────
|
||||
function ZoneLabel({ zone }) {
|
||||
const centerRow = zone.bounds.row + zone.bounds.rows / 2;
|
||||
const centerCol = zone.bounds.col + zone.bounds.cols / 2;
|
||||
const [wx, , wz] = gridToWorld(centerRow, centerCol);
|
||||
|
||||
return (
|
||||
<Html position={[wx, 0.5, wz]} center distanceFactor={30} style={{ pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: '#ffffffcc',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
border: `1px solid ${zone.color}44`,
|
||||
}}>
|
||||
{zone.name}
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Connection indicator ────────────────────────────────────────
|
||||
function ConnectionIndicator() {
|
||||
const connected = useStore(s => s.wsConnected);
|
||||
const reconnecting = useStore(s => s.wsReconnecting);
|
||||
|
||||
return (
|
||||
<Html fullscreen style={{ pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 20,
|
||||
fontSize: 12,
|
||||
color: '#ccc',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: connected ? '#44cc44' : reconnecting ? '#ffcc00' : '#ff4444',
|
||||
boxShadow: `0 0 6px ${connected ? '#44cc44' : reconnecting ? '#ffcc00' : '#ff4444'}`,
|
||||
}} />
|
||||
{connected ? 'Connected' : reconnecting ? 'Reconnecting...' : 'Disconnected'}
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main scene ──────────────────────────────────────────────────
|
||||
export default function OfficeScene() {
|
||||
export default function FarmScene() {
|
||||
const agents = useStore(s => s.agents);
|
||||
const grid = useStore(s => s.grid);
|
||||
|
||||
// Hover state: { [agentId]: boolean }
|
||||
const [hoveredAgents, setHoveredAgents] = useState({});
|
||||
|
||||
const tiles = useMemo(() => {
|
||||
const result = [];
|
||||
for (let row = 0; row < GRID_ROWS; row++) {
|
||||
for (let col = 0; col < GRID_COLS; col++) {
|
||||
const tile = grid.tiles?.[row]?.[col];
|
||||
result.push({ row, col, zoneId: tile?.zoneId || null });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [grid]);
|
||||
|
||||
const agentList = useMemo(() => Object.values(agents), [agents]);
|
||||
const agentList = React.useMemo(() => Object.values(agents), [agents]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Base ground plane */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
|
||||
<planeGeometry args={[30, 20]} />
|
||||
<meshStandardMaterial color="#111122" roughness={1} />
|
||||
</mesh>
|
||||
|
||||
{/* Floor tiles */}
|
||||
{tiles.map(({ row, col, zoneId }) => (
|
||||
<FloorTile key={`${row}-${col}`} row={row} col={col} zoneId={zoneId} />
|
||||
))}
|
||||
|
||||
{/* Zone labels */}
|
||||
{Object.values(ZONES).map(zone => (
|
||||
<ZoneLabel key={zone.id} zone={zone} />
|
||||
))}
|
||||
|
||||
{/* Agent sprites */}
|
||||
{agentList.map(agent => (
|
||||
<AgentSprite
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
hovered={!!hoveredAgents[agent.id]}
|
||||
onHover={(id, isHovered) =>
|
||||
setHoveredAgents(prev => ({ ...prev, [id]: isHovered }))
|
||||
}
|
||||
<div className="farm-grid" style={{ width: 896, height: 640 }}>
|
||||
{/* ── Dirt paths ──────────────────────────────────────── */}
|
||||
{PATHS.map((p, i) => (
|
||||
<div
|
||||
key={`path-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: p.left,
|
||||
top: p.top,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
background: '#8B6914',
|
||||
opacity: 0.5,
|
||||
boxShadow: 'inset 0 0 4px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Connection status */}
|
||||
<ConnectionIndicator />
|
||||
</group>
|
||||
{/* ── Buildings ───────────────────────────────────────── */}
|
||||
{BUILDINGS.map(b => (
|
||||
<div key={b.id}>
|
||||
<div
|
||||
className={`building ${b.cssClass}`}
|
||||
style={{
|
||||
left: b.left,
|
||||
top: b.top,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="building-label"
|
||||
style={{
|
||||
left: b.left + 4,
|
||||
top: b.top - 30,
|
||||
color: '#FFD700',
|
||||
}}
|
||||
>
|
||||
{b.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Decorations ─────────────────────────────────────── */}
|
||||
{DECORATIONS.map((d, i) => {
|
||||
if (d.type === 'tree') return <div key={`deco-${i}`} className="pixel-tree" style={{ left: d.left, top: d.top }} />;
|
||||
if (d.type === 'rock') return <div key={`deco-${i}`} className="pixel-rock" style={{ left: d.left, top: d.top }} />;
|
||||
if (d.type === 'flower') return <div key={`deco-${i}`} className="pixel-flower" style={{ left: d.left, top: d.top, background: d.color }} />;
|
||||
if (d.type === 'fence') return <div key={`deco-${i}`} className="fence-post" style={{ left: d.left, top: d.top }} />;
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* ── Agent sprites ──────────────────────────────────── */}
|
||||
{agentList.map(agent => (
|
||||
<AgentSprite key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,19 @@ class WebSocketClient {
|
|||
this.reconnectTimer = null;
|
||||
this.reconnectDelay = 1000;
|
||||
this.maxReconnectDelay = 30000;
|
||||
this.url = 'ws://localhost:18790';
|
||||
// Auto-detect WS URL: use relative path when behind nginx, direct when dev
|
||||
this.url = (() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
// When accessed via /office/, connect through same host + path
|
||||
if (window.location.pathname.startsWith('/office/')) {
|
||||
return `${proto}//${host}/office/`;
|
||||
}
|
||||
return `ws://${host}:18790`;
|
||||
}
|
||||
return 'ws://localhost:18790';
|
||||
})();
|
||||
this.mockInterval = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,38 +3,59 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spatial Office — Scobleclaw</title>
|
||||
<title>Spatial Office — Scobleclaw Farm</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
background: #2D1B0E;
|
||||
color: #e0d5c0;
|
||||
}
|
||||
#root { position: relative; }
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1a2e;
|
||||
gap: 12px;
|
||||
background: #2D1B0E;
|
||||
z-index: 100;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.loading-overlay span {
|
||||
color: #DAA520;
|
||||
font-size: 16px;
|
||||
animation: loadFade 1.5s ease-in-out infinite;
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #333;
|
||||
border-top-color: #4ECDC4;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #6B4226;
|
||||
border-top-color: #DAA520;
|
||||
image-rendering: pixelated;
|
||||
animation: spin 0.8s steps(4) infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes loadFade {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Loading farm...</span>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -9,17 +9,13 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.117.0",
|
||||
"@react-three/fiber": "^8.17.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"three": "^0.170.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/three": "^0.170.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
|
|
|
|||
696
src/frontend/src/farm.css
Normal file
696
src/frontend/src/farm.css
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
SPATIAL OFFICE — STARDEW VALLEY PIXEL ART THEME
|
||||
Palette: warm earthy farm tones
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── CSS Custom Properties ─────────────────────────────────── */
|
||||
:root {
|
||||
/* Earthy browns */
|
||||
--cl-earth-dark: #6B4226;
|
||||
--cl-earth-mid: #8B6914;
|
||||
--cl-earth-warm: #A0522D;
|
||||
|
||||
/* Farm greens */
|
||||
--cl-green-dark: #2D5A27;
|
||||
--cl-green-mid: #4A7C3F;
|
||||
--cl-green-light: #6B8E23;
|
||||
|
||||
/* Sky/waters */
|
||||
--cl-sky: #87CEEB;
|
||||
--cl-water-mid: #5B8FA8;
|
||||
--cl-water-deep: #2F5F7A;
|
||||
|
||||
/* Warm golds */
|
||||
--cl-gold: #DAA520;
|
||||
--cl-gold-bright: #F0C040;
|
||||
--cl-gold-muted: #C68B3C;
|
||||
|
||||
/* Cabin woods */
|
||||
--cl-wood-dark: #8B4513;
|
||||
--cl-wood-light: #DEB887;
|
||||
--cl-wood-mid: #D2B48C;
|
||||
|
||||
/* Cozy fires */
|
||||
--cl-fire: #FF6347;
|
||||
--cl-fire-mid: #FF8C42;
|
||||
--cl-fire-light: #FFD700;
|
||||
|
||||
/* Background */
|
||||
--cl-bg-light: #F5E6D3;
|
||||
--cl-bg-dark: #2D1B0E;
|
||||
|
||||
/* Agent colors */
|
||||
--agent-cos: #FF6B35;
|
||||
--agent-eng: #4ECDC4;
|
||||
--agent-senior: #7B2D8E;
|
||||
--agent-trust: #45B7D1;
|
||||
--agent-market: #96CEB4;
|
||||
--agent-health: #FFEAA7;
|
||||
--agent-content: #DDA0DD;
|
||||
--agent-pres: #FF8A80;
|
||||
--agent-vault: #A5D6A7;
|
||||
|
||||
/* Sizes */
|
||||
--tile-size: 32px;
|
||||
--agent-size: 28px;
|
||||
}
|
||||
|
||||
/* ── Base reset ────────────────────────────────────────────── */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
background: var(--cl-bg-dark);
|
||||
color: #e0d5c0;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
/* ── Farm container — full viewport ────────────────────────── */
|
||||
.farm-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--cl-bg-dark);
|
||||
}
|
||||
|
||||
/* ── Farm viewport — scrollable game board ─────────────────── */
|
||||
.farm-viewport {
|
||||
width: 100%;
|
||||
height: calc(100% - 44px); /* room for bottom bar */
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cl-earth-dark) transparent;
|
||||
}
|
||||
|
||||
/* ── Farm grid — the actual game board ─────────────────────── */
|
||||
.farm-grid {
|
||||
position: relative;
|
||||
min-width: max(100%, 896px); /* 28 cols × 32px */
|
||||
min-height: max(100%, 640px); /* 20 rows × 32px */
|
||||
background-color: var(--cl-green-dark);
|
||||
/* Subtle grass texture via repeating pattern */
|
||||
background-image:
|
||||
radial-gradient(circle at 6px 6px, rgba(74,124,63,0.4) 1px, transparent 1px),
|
||||
radial-gradient(circle at 22px 14px, rgba(107,142,35,0.3) 1px, transparent 1px),
|
||||
radial-gradient(circle at 14px 26px, rgba(74,124,63,0.3) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
/* ── Dirt path grid overlay ────────────────────────────────── */
|
||||
.farm-grid::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 31px,
|
||||
rgba(107,66,38,0.15) 31px,
|
||||
rgba(107,66,38,0.15) 32px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 31px,
|
||||
rgba(107,66,38,0.15) 31px,
|
||||
rgba(107,66,38,0.15) 32px
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
BUILDINGS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.building {
|
||||
position: absolute;
|
||||
border: 2px solid var(--cl-earth-dark);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Workshop (formerly Pod A) */
|
||||
.building--workshop {
|
||||
background:
|
||||
/* Brick pattern */
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
var(--cl-earth-warm),
|
||||
var(--cl-earth-warm) 15px,
|
||||
var(--cl-earth-dark) 15px,
|
||||
var(--cl-earth-dark) 16px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--cl-earth-warm),
|
||||
var(--cl-earth-warm) 47px,
|
||||
rgba(0,0,0,0.15) 47px,
|
||||
rgba(0,0,0,0.15) 48px
|
||||
);
|
||||
border-color: var(--cl-earth-dark);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(0,0,0,0.2),
|
||||
4px 4px 0 rgba(0,0,0,0.3);
|
||||
}
|
||||
.building--workshop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
background:
|
||||
linear-gradient(135deg, var(--cl-earth-dark) 45%, transparent 45%) 0 0 / 16px 24px repeat-x;
|
||||
}
|
||||
|
||||
/* Studio (formerly Pod B) */
|
||||
.building--studio {
|
||||
background:
|
||||
linear-gradient(var(--cl-wood-light), var(--cl-wood-mid));
|
||||
border-color: var(--cl-wood-dark);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(0,0,0,0.15),
|
||||
4px 4px 0 rgba(0,0,0,0.3);
|
||||
}
|
||||
.building--studio::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, var(--cl-fire-mid), var(--cl-earth-dark));
|
||||
/* Roof tiles */
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 14px,
|
||||
rgba(0,0,0,0.2) 14px,
|
||||
rgba(0,0,0,0.2) 16px
|
||||
);
|
||||
}
|
||||
|
||||
/* Cabin (solo work) */
|
||||
.building--cabin {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
var(--cl-wood-dark) 0px,
|
||||
var(--cl-wood-mid) 1px,
|
||||
var(--cl-wood-mid) 7px,
|
||||
var(--cl-wood-dark) 8px
|
||||
);
|
||||
border-color: var(--cl-wood-dark);
|
||||
box-shadow:
|
||||
3px 3px 0 rgba(0,0,0,0.3);
|
||||
}
|
||||
.building--cabin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -16px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
height: 16px;
|
||||
background:
|
||||
conic-gradient(from 45deg, var(--cl-earth-mid) 90deg, var(--cl-earth-dark) 90deg 180deg, var(--cl-earth-mid) 180deg 270deg, var(--cl-earth-dark) 270deg);
|
||||
background-size: 16px 16px;
|
||||
border: 2px solid var(--cl-earth-dark);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Library (solo east) */
|
||||
.building--library {
|
||||
background:
|
||||
linear-gradient(180deg, var(--cl-wood-dark) 0%, var(--cl-wood-light) 100%);
|
||||
border-color: var(--cl-wood-dark);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(0,0,0,0.1),
|
||||
4px 4px 0 rgba(0,0,0,0.3);
|
||||
}
|
||||
.building--library::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
height: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, var(--cl-water-deep) 0%, var(--cl-water-mid) 60%, var(--cl-earth-dark) 100%);
|
||||
border: 2px solid var(--cl-earth-dark);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Shrine (coaching) */
|
||||
.building--shrine {
|
||||
background:
|
||||
radial-gradient(ellipse at center, var(--cl-gold-bright) 0%, var(--cl-gold) 40%, var(--cl-gold-muted) 100%);
|
||||
border-color: var(--cl-gold-muted);
|
||||
box-shadow:
|
||||
inset 0 0 12px rgba(218,165,32,0.3),
|
||||
3px 3px 0 rgba(0,0,0,0.3);
|
||||
}
|
||||
.building--shrine::before {
|
||||
content: '✦';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: var(--cl-gold-bright);
|
||||
text-shadow: 0 0 8px var(--cl-gold-bright);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Campfire (coffee) */
|
||||
.building--campfire {
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 30%, var(--cl-bg-light) 0%, rgba(139,105,20,0.3) 100%);
|
||||
border-color: var(--cl-earth-dark);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.building--campfire::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 12px;
|
||||
height: 16px;
|
||||
background: var(--cl-fire);
|
||||
border-radius: 50% 50% 20% 20%;
|
||||
box-shadow:
|
||||
0 0 6px var(--cl-fire-light),
|
||||
0 0 12px var(--cl-fire-mid);
|
||||
animation: campfireFlicker 0.3s steps(3) infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes campfireFlicker {
|
||||
0% { height: 14px; background: var(--cl-fire); }
|
||||
33% { height: 18px; background: var(--cl-fire-light); }
|
||||
66% { height: 12px; background: var(--cl-fire-mid); }
|
||||
100% { height: 16px; background: var(--cl-fire); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
BUILDING LABELS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.building-label {
|
||||
position: absolute;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
pointer-events: none;
|
||||
text-shadow: 1px 1px 0 rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
AGENT SPRITES
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.agent-sprite {
|
||||
position: absolute;
|
||||
width: var(--agent-size);
|
||||
height: var(--agent-size);
|
||||
image-rendering: pixelated;
|
||||
transition: left 0.8s ease-in-out, top 0.8s ease-in-out;
|
||||
z-index: 10;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Agent body — pixel-art character using box-shadow technique */
|
||||
.agent-body {
|
||||
width: 18px;
|
||||
height: 20px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
/* Base body shape */
|
||||
background-color: var(--agent-color, #ccc);
|
||||
box-shadow:
|
||||
/* Head */
|
||||
3px -10px 0 2px var(--agent-color, #ccc),
|
||||
-3px -10px 0 2px var(--agent-color, #ccc),
|
||||
/* Eyes */
|
||||
1px -8px 0 1px #222,
|
||||
-1px -8px 0 1px #222,
|
||||
/* Body */
|
||||
0px 0px 0 0px var(--agent-color, #ccc);
|
||||
border-radius: 2px 2px 0 0;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Agent accessory — department-unique */
|
||||
.agent-accessory {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 8px;
|
||||
height: 4px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
/* Agent colors per department */
|
||||
.agent--cos { --agent-color: var(--agent-cos); }
|
||||
.agent--eng { --agent-color: var(--agent-eng); }
|
||||
.agent--senior { --agent-color: var(--agent-senior); }
|
||||
.agent--trust { --agent-color: var(--agent-trust); }
|
||||
.agent--market { --agent-color: var(--agent-market); }
|
||||
.agent--health { --agent-color: var(--agent-health); }
|
||||
.agent--content { --agent-color: var(--agent-content); }
|
||||
.agent--pres { --agent-color: var(--agent-pres); }
|
||||
.agent--vault { --agent-color: var(--agent-vault); }
|
||||
|
||||
/* ── Agent name label ──────────────────────────────────────── */
|
||||
.agent-name {
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000, -1px -1px 0 #000;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Idle bob animation ────────────────────────────────────── */
|
||||
.agent-sprite[data-activity="idle"] .agent-body {
|
||||
animation: agentBob 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes agentBob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
/* ── Working animation ─────────────────────────────────────── */
|
||||
.agent-sprite[data-activity="working"] .agent-body {
|
||||
animation: agentWork 0.4s steps(2) infinite;
|
||||
}
|
||||
|
||||
@keyframes agentWork {
|
||||
0% { transform: translateY(0) scale(1); }
|
||||
100% { transform: translateY(-2px) scale(1.05); }
|
||||
}
|
||||
|
||||
/* ── Thinking animation ────────────────────────────────────── */
|
||||
.agent-sprite[data-activity="thinking"] .agent-body {
|
||||
animation: agentThink 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes agentThink {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-1px) rotate(-2deg); }
|
||||
75% { transform: translateY(-1px) rotate(2deg); }
|
||||
}
|
||||
|
||||
/* ── Offline state ─────────────────────────────────────────── */
|
||||
.agent-sprite[data-activity="offline"] {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(80%);
|
||||
}
|
||||
.agent-sprite[data-activity="offline"] .agent-body {
|
||||
animation: agentSleep 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes agentSleep {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(1px) scaleY(0.9); }
|
||||
}
|
||||
|
||||
/* ── Blocked state ─────────────────────────────────────────── */
|
||||
.agent-sprite[data-activity="blocked"] {
|
||||
animation: agentBlocked 0.5s steps(2) infinite;
|
||||
}
|
||||
|
||||
@keyframes agentBlocked {
|
||||
0% { transform: translateX(-1px); }
|
||||
100% { transform: translateX(1px); }
|
||||
}
|
||||
|
||||
/* ── Thought bubble ────────────────────────────────────────── */
|
||||
.agent-thought {
|
||||
position: absolute;
|
||||
top: -36px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 7px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.agent-sprite[data-activity="thinking"] .agent-thought {
|
||||
opacity: 1;
|
||||
animation: thoughtFloat 2s ease-in-out infinite;
|
||||
}
|
||||
.agent-sprite[data-activity="blocked"] .agent-thought {
|
||||
opacity: 1;
|
||||
background: #ff4444;
|
||||
color: #fff;
|
||||
border-color: #ff2222;
|
||||
animation: thoughtFloat 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes thoughtFloat {
|
||||
0%, 100% { transform: translateX(-50%) translateY(0); }
|
||||
50% { transform: translateX(-50%) translateY(-3px); }
|
||||
}
|
||||
|
||||
/* ── Sparkle particles for working ─────────────────────────── */
|
||||
.agent-sparkles {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: none;
|
||||
}
|
||||
.agent-sprite[data-activity="working"] .agent-sparkles {
|
||||
display: block;
|
||||
}
|
||||
.sparkle {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
background: var(--cl-gold-bright);
|
||||
animation: sparkleTwinkle 0.5s steps(1) infinite;
|
||||
}
|
||||
.sparkle:nth-child(1) { left: -4px; animation-delay: 0s; }
|
||||
.sparkle:nth-child(2) { left: 2px; animation-delay: 0.1s; }
|
||||
.sparkle:nth-child(3) { top: -4px; left: -1px; animation-delay: 0.2s; }
|
||||
|
||||
@keyframes sparkleTwinkle {
|
||||
0% { opacity: 1; background: var(--cl-gold-bright); }
|
||||
50% { opacity: 0; background: var(--cl-fire-light); }
|
||||
100% { opacity: 1; background: var(--cl-gold-bright); }
|
||||
}
|
||||
|
||||
/* ── Status glow ring ──────────────────────────────────────── */
|
||||
.agent-glow {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 22px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.agent-sprite[data-activity="idle"] .agent-glow { background: #4c4; box-shadow: 0 0 4px #4c4; }
|
||||
.agent-sprite[data-activity="working"] .agent-glow { background: #48f; box-shadow: 0 0 6px #48f; animation: glowPulse 0.8s ease-in-out infinite; }
|
||||
.agent-sprite[data-activity="thinking"] .agent-glow { background: #fc0; box-shadow: 0 0 4px #fc0; animation: glowPulse 1.5s ease-in-out infinite; }
|
||||
.agent-sprite[data-activity="blocked"] .agent-glow { background: #f44; box-shadow: 0 0 6px #f44; animation: glowPulse 0.4s ease-in-out infinite; }
|
||||
.agent-sprite[data-activity="offline"] .agent-glow { background: #666; box-shadow: 0 0 2px #666; }
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.3; transform: translateX(-50%) scaleX(0.8); }
|
||||
50% { opacity: 0.7; transform: translateX(-50%) scaleX(1.2); }
|
||||
}
|
||||
|
||||
/* ── Agent tooltip ─────────────────────────────────────────── */
|
||||
.agent-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(45,27,14,0.95);
|
||||
color: #e0d5c0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--cl-earth-mid);
|
||||
box-shadow: 2px 2px 0 rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.agent-tooltip strong {
|
||||
color: var(--agent-color, var(--cl-gold-bright));
|
||||
}
|
||||
.agent-tooltip .task {
|
||||
color: #aaa;
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
BOTTOM BAR
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.farm-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
background: rgba(45,27,14,0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--cl-wood-mid);
|
||||
border-top: 2px solid var(--cl-earth-dark);
|
||||
z-index: 20;
|
||||
}
|
||||
.farm-bar_separator {
|
||||
color: var(--cl-earth-dark);
|
||||
}
|
||||
.farm-bar_status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.farm-bar_dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
CONNECTION INDICATOR (top-right)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.connection-indicator {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(45,27,14,0.85);
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--cl-earth-dark);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--cl-wood-mid);
|
||||
z-index: 30;
|
||||
}
|
||||
.connection-indicator_dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
MISC DECORATIVE
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Trees — decorative pixel-art trees scattered around */
|
||||
.pixel-tree {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pixel-tree::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0 auto;
|
||||
background: var(--cl-green-dark);
|
||||
box-shadow:
|
||||
-4px -4px 0 1px var(--cl-green-mid),
|
||||
4px -4px 0 1px var(--cl-green-mid),
|
||||
0px -14px 0 3px var(--cl-green-mid),
|
||||
-6px -8px 0 1px var(--cl-green-light),
|
||||
6px -8px 0 1px var(--cl-green-light);
|
||||
}
|
||||
.pixel-tree::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
margin: -2px auto 0;
|
||||
background: var(--cl-earth-dark);
|
||||
}
|
||||
|
||||
/* Rocks */
|
||||
.pixel-rock {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
width: 8px;
|
||||
height: 5px;
|
||||
background: #666;
|
||||
border-radius: 2px;
|
||||
box-shadow: 2px 2px 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Fence posts */
|
||||
.fence-post {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
background: var(--cl-wood-dark);
|
||||
}
|
||||
.fence-post::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background: var(--cl-wood-dark);
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
/* Flower decorations */
|
||||
.pixel-flower {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
|
@ -1,47 +1,47 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
// ── Zone definitions ───────────────────────────────────────────────
|
||||
// ── Stardew-inspired Zone definitions ───────────────────────────────
|
||||
const ZONES = {
|
||||
'pod-a': {
|
||||
id: 'pod-a',
|
||||
name: 'Collaboration Pod A',
|
||||
name: 'Workshop',
|
||||
type: 'collaboration',
|
||||
color: '#C68B3C',
|
||||
color: '#A0522D',
|
||||
bounds: { row: 2, col: 8, rows: 5, cols: 5 },
|
||||
},
|
||||
'pod-b': {
|
||||
id: 'pod-b',
|
||||
name: 'Collaboration Pod B',
|
||||
name: 'Studio',
|
||||
type: 'collaboration',
|
||||
color: '#C68B3C',
|
||||
color: '#DEB887',
|
||||
bounds: { row: 2, col: 14, rows: 5, cols: 5 },
|
||||
},
|
||||
'solo-1': {
|
||||
id: 'solo-1',
|
||||
name: 'Solo Work Area 1',
|
||||
name: 'Cabins',
|
||||
type: 'solo',
|
||||
color: '#3A7CA5',
|
||||
color: '#8B4513',
|
||||
bounds: { row: 2, col: 0, rows: 10, cols: 6 },
|
||||
},
|
||||
'solo-2': {
|
||||
id: 'solo-2',
|
||||
name: 'Solo Work Area 2',
|
||||
name: 'Library',
|
||||
type: 'solo',
|
||||
color: '#3A7CA5',
|
||||
color: '#5B8FA8',
|
||||
bounds: { row: 2, col: 22, rows: 10, cols: 6 },
|
||||
},
|
||||
'coaching': {
|
||||
id: 'coaching',
|
||||
name: 'Coaching Corner',
|
||||
name: 'Shrine',
|
||||
type: 'self-improvement',
|
||||
color: '#5C8A5C',
|
||||
color: '#DAA520',
|
||||
bounds: { row: 13, col: 8, rows: 4, cols: 12 },
|
||||
},
|
||||
'coffee': {
|
||||
id: 'coffee',
|
||||
name: 'Coffee Bar',
|
||||
name: 'Campfire',
|
||||
type: 'social',
|
||||
color: '#8B6914',
|
||||
color: '#FF8C42',
|
||||
bounds: { row: 0, col: 10, rows: 2, cols: 8 },
|
||||
},
|
||||
};
|
||||
|
|
@ -59,17 +59,17 @@ const AGENT_DEFS = {
|
|||
vault: { id: 'vault', name: 'Vault', department: 'vault', color: '#A5D6A7' },
|
||||
};
|
||||
|
||||
// ── Default agent positions (row, col on the isometric grid) ───────
|
||||
// ── Default agent positions (row, col on the pixel grid) ───────────
|
||||
const DEFAULT_POSITIONS = {
|
||||
cos: { row: 3, col: 2 },
|
||||
engineering: { row: 4, col: 10 },
|
||||
'senior-eng': { row: 5, col: 10 },
|
||||
trust: { row: 7, col: 2 },
|
||||
market: { row: 4, col: 16 },
|
||||
health: { row: 3, col: 24 },
|
||||
content: { row: 5, col: 16 },
|
||||
presentations: { row: 6, col: 16 },
|
||||
vault: { row: 7, col: 24 },
|
||||
cos: { row: 4, col: 2 },
|
||||
engineering: { row: 5, col: 10 },
|
||||
'senior-eng': { row: 6, col: 10 },
|
||||
trust: { row: 8, col: 2 },
|
||||
market: { row: 5, col: 16 },
|
||||
health: { row: 4, col: 24 },
|
||||
content: { row: 6, col: 16 },
|
||||
presentations: { row: 7, col: 16 },
|
||||
vault: { row: 8, col: 24 },
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
|
@ -142,7 +142,7 @@ const useStore = create((set, get) => ({
|
|||
|
||||
for (const diff of diffs) {
|
||||
if (diff.op === 'agent.move' || diff.op === 'agent.update') {
|
||||
const agentId = diff.path.split('.')[1]; // agents.engineering → engineering
|
||||
const agentId = diff.path.split('.')[1];
|
||||
if (updated[agentId]) {
|
||||
const delta = diff.value || {};
|
||||
if (delta.position) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import path from 'path';
|
|||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: '.',
|
||||
base: '/',
|
||||
base: '/office/',
|
||||
server: {
|
||||
port: 18790,
|
||||
host: '0.0.0.0',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue