Spaces:
Sleeping
Sleeping
| import React, { useRef, useEffect } from 'react'; | |
| import type { Observation } from '../types'; | |
| interface Map2DProps { | |
| observation: Observation | null; | |
| agentMoveFlash: number; | |
| } | |
| /* ── Cell type constants ── */ | |
| const WALL = 1; | |
| const DOOR_OPEN = 2; | |
| const DOOR_CLOSED = 3; | |
| const EXIT = 4; | |
| const OBSTACLE = 5; | |
| /* ── Wind direction vectors ── */ | |
| const WIND_DIRS: Record<string, [number, number]> = { | |
| N: [0, -1], S: [0, 1], E: [1, 0], W: [-1, 0], | |
| NW: [-0.7, -0.7], NE: [0.7, -0.7], SW: [-0.7, 0.7], SE: [0.7, 0.7], | |
| CALM: [0, 0], | |
| }; | |
| /* ── Agent appearance per health tier ── */ | |
| const AGENT_THEMES = { | |
| healthy: { body: '#3b82f6', dark: '#1d4ed8', arm: '#2563eb', ring: '#fbbf24', ringGlow: 'rgba(251,191,36,0.5)' }, | |
| moderate: { body: '#f97316', dark: '#c2410c', arm: '#ea580c', ring: '#fb923c', ringGlow: 'rgba(251,146,60,0.5)' }, | |
| low: { body: '#dc2626', dark: '#991b1b', arm: '#b91c1c', ring: '#f87171', ringGlow: 'rgba(248,113,113,0.5)' }, | |
| critical: { body: '#7c3aed', dark: '#5b21b6', arm: '#6d28d9', ring: '#c4b5fd', ringGlow: 'rgba(196,181,253,0.5)' }, | |
| }; | |
| /* ── Ember particle ── */ | |
| class Ember { | |
| x: number; y: number; vx: number; vy: number; | |
| life: number; decay: number; size: number; | |
| type: 'ember' | 'spark'; | |
| constructor(x: number, y: number, windX: number) { | |
| const speed = 0.4 + Math.random() * 1.0; | |
| const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.6; | |
| this.x = x + (Math.random() - 0.5) * 3; | |
| this.y = y + (Math.random() - 0.5) * 3; | |
| this.vx = Math.cos(angle) * speed + windX * 0.7; | |
| this.vy = Math.sin(angle) * speed - 0.22; | |
| this.life = 1.0; | |
| this.decay = 0.012 + Math.random() * 0.015; | |
| this.size = 1.2 + Math.random() * 2.2; | |
| this.type = Math.random() > 0.4 ? 'ember' : 'spark'; | |
| } | |
| update() { | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| this.vy -= 0.012; | |
| this.vx *= 0.97; | |
| this.life -= this.decay; | |
| } | |
| } | |
| /* ── Minecraft pixel-art character ── */ | |
| function drawMinecraftAgent( | |
| ctx: CanvasRenderingContext2D, | |
| cx: number, cy: number, cs: number, | |
| theme: typeof AGENT_THEMES.healthy | |
| ) { | |
| const u = cs / 18; | |
| const left = cx - 5 * u; | |
| const top = cy - 8.5 * u; | |
| const px = (rx: number, ry: number, rw: number, rh: number, color: string) => { | |
| ctx.fillStyle = color; | |
| ctx.fillRect(left + rx * u, top + ry * u, rw * u, rh * u); | |
| }; | |
| /* Helmet */ | |
| px(2, 0, 6, 1, '#5c4a3d'); | |
| /* Head */ | |
| px(2, 1, 6, 5, '#f5d5a0'); | |
| /* Face features */ | |
| px(3, 3, 1, 1, '#3d2b1a'); /* left eye */ | |
| px(6, 3, 1, 1, '#3d2b1a'); /* right eye */ | |
| px(4, 5, 2, 1, '#c8937a'); /* mouth */ | |
| /* Hair accent */ | |
| px(2, 1, 6, 1, '#7a5c3e'); | |
| /* Body */ | |
| px(3, 6, 4, 4, theme.body); | |
| px(3, 6, 4, 1, theme.dark); | |
| /* Arms */ | |
| px(1, 6, 2, 4, theme.arm); | |
| px(7, 6, 2, 4, theme.arm); | |
| /* Legs */ | |
| px(3, 10, 2, 4, '#1e40af'); | |
| px(5, 10, 2, 4, '#1e3a8a'); | |
| /* Boots */ | |
| px(3, 14, 2, 2, '#3a2e26'); | |
| px(5, 14, 2, 2, '#2e2420'); | |
| } | |
| /* ── Main canvas component ── */ | |
| const Map2D: React.FC<Map2DProps> = ({ observation, agentMoveFlash }) => { | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const embersRef = useRef<Ember[]>([]); | |
| const trailRef = useRef<{ x: number; y: number; t: number }[]>([]); | |
| const timeRef = useRef(0); | |
| const rafRef = useRef(0); | |
| const CS = 40; | |
| const animate = () => { | |
| const canvas = canvasRef.current; | |
| if (!canvas || !observation) { rafRef.current = requestAnimationFrame(animate); return; } | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return; | |
| timeRef.current += 0.016; | |
| const t = timeRef.current; | |
| const { map_state, agent_health, wind_dir } = observation; | |
| const { grid_w: W, grid_h: H, cell_grid, fire_grid, smoke_grid, agent_x, agent_y } = map_state; | |
| const cs = CS; | |
| const wv = WIND_DIRS[wind_dir] ?? [0, 0]; | |
| const idx = (x: number, y: number) => y * W + x; | |
| const visible = new Set(map_state.visible_cells.map(([vx, vy]) => `${vx},${vy}`)); | |
| /* ── Canvas bg ── */ | |
| ctx.fillStyle = '#c8b890'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| /* ── Base layer ── */ | |
| for (let y = 0; y < H; y++) { | |
| for (let x = 0; x < W; x++) { | |
| const ct = cell_grid[idx(x, y)]; | |
| const px = x * cs, py = y * cs; | |
| switch (ct) { | |
| case WALL: { | |
| ctx.fillStyle = '#5e5850'; | |
| ctx.fillRect(px, py, cs, cs); | |
| ctx.fillStyle = 'rgba(255,255,255,0.28)'; | |
| ctx.fillRect(px, py, cs, 2); | |
| ctx.fillRect(px, py + 2, 2, cs - 2); | |
| ctx.fillStyle = 'rgba(0,0,0,0.45)'; | |
| ctx.fillRect(px, py + cs - 2, cs, 2); | |
| ctx.fillRect(px + cs - 2, py, 2, cs - 2); | |
| /* center mortar cross */ | |
| ctx.fillStyle = 'rgba(0,0,0,0.12)'; | |
| ctx.fillRect(px + cs / 2 - 1, py, 2, cs); | |
| ctx.fillRect(px, py + cs / 2 - 1, cs, 2); | |
| break; | |
| } | |
| case OBSTACLE: { | |
| ctx.fillStyle = '#3a3530'; | |
| ctx.fillRect(px, py, cs, cs); | |
| ctx.fillStyle = 'rgba(255,80,0,0.55)'; | |
| ctx.fillRect(px, py, cs, 3); | |
| ctx.fillRect(px, py, 3, cs); | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; | |
| ctx.fillRect(px, py + cs - 3, cs, 3); | |
| ctx.fillRect(px + cs - 3, py, 3, cs); | |
| break; | |
| } | |
| default: { | |
| /* Minecraft checkerboard floor */ | |
| ctx.fillStyle = (x + y) % 2 === 0 ? '#e8d8b8' : '#d0be98'; | |
| ctx.fillRect(px, py, cs, cs); | |
| /* tile bevel */ | |
| ctx.fillStyle = 'rgba(255,255,255,0.20)'; | |
| ctx.fillRect(px, py, cs, 2); | |
| ctx.fillRect(px, py + 2, 2, cs - 2); | |
| ctx.fillStyle = 'rgba(0,0,0,0.18)'; | |
| ctx.fillRect(px, py + cs - 2, cs, 2); | |
| ctx.fillRect(px + cs - 2, py, 2, cs - 2); | |
| } | |
| } | |
| } | |
| } | |
| /* ── Fire ambient: multiply scorches the floor tiles ── */ | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'multiply'; | |
| for (let y = 0; y < H; y++) { | |
| for (let x = 0; x < W; x++) { | |
| const fire = fire_grid[idx(x, y)]; | |
| if (fire < 0.1) continue; | |
| const px = x * cs + cs / 2, py = y * cs + cs / 2; | |
| const radius = cs * (1.2 + fire * 1.8); | |
| const a = Math.min(0.85, fire * 0.9); | |
| const gr = ctx.createRadialGradient(px, py, 0, px, py, radius); | |
| gr.addColorStop(0, `rgba(255,80,0,${a})`); | |
| gr.addColorStop(0.4, `rgba(220,40,0,${a * 0.5})`); | |
| gr.addColorStop(1, 'rgba(0,0,0,0)'); | |
| ctx.fillStyle = gr; | |
| ctx.fillRect(px - radius, py - radius, radius * 2, radius * 2); | |
| } | |
| } | |
| ctx.restore(); | |
| /* ── Fire volumetric: vivid source-over layers (real-looking flames) ── */ | |
| for (let y = 0; y < H; y++) { | |
| for (let x = 0; x < W; x++) { | |
| const fire = fire_grid[idx(x, y)]; | |
| if (fire < 0.05) continue; | |
| const px = x * cs + cs / 2, py = y * cs + cs / 2; | |
| const flicker = 0.80 + 0.20 * Math.sin(t * 11.0 + x * 3.1 + y * 2.7); | |
| const eff = Math.min(1, fire * flicker); | |
| const windDx = wv[0] * cs * 0.25 * eff; | |
| const windDy = wv[1] * cs * 0.25 * eff - cs * 0.06; | |
| /* Outer dark-red base: anchors fire to cell */ | |
| { | |
| const r = cs * 0.70 * (0.7 + eff * 0.3); | |
| const cx2 = px + windDx * 0.5, cy2 = py + windDy * 0.5; | |
| const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r); | |
| gr.addColorStop(0, `rgba(200,20,0,${eff * 0.65})`); | |
| gr.addColorStop(0.55,`rgba(170,10,0,${eff * 0.35})`); | |
| gr.addColorStop(1, 'rgba(80,0,0,0)'); | |
| ctx.fillStyle = gr; | |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| /* Mid vivid-orange body */ | |
| { | |
| const r = cs * 0.46 * (0.8 + eff * 0.2); | |
| const cx2 = px + windDx * 0.35, cy2 = py + windDy * 0.35 - cs * 0.04 * eff; | |
| const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r); | |
| gr.addColorStop(0, `rgba(255,110,0,${eff * 0.90})`); | |
| gr.addColorStop(0.45,`rgba(255,60,0,${eff * 0.60})`); | |
| gr.addColorStop(1, 'rgba(220,20,0,0)'); | |
| ctx.fillStyle = gr; | |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| /* Inner bright-yellow core */ | |
| { | |
| const r = cs * 0.26 * eff; | |
| const cx2 = px + windDx * 0.15, cy2 = py + windDy * 0.15 - cs * 0.10 * eff; | |
| const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r); | |
| gr.addColorStop(0, `rgba(255,230,80,${eff * 0.95})`); | |
| gr.addColorStop(0.35,`rgba(255,170,20,${eff * 0.75})`); | |
| gr.addColorStop(1, 'rgba(255,80,0,0)'); | |
| ctx.fillStyle = gr; | |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| /* White-hot tip (only for intense fire) */ | |
| if (eff > 0.55) { | |
| const r = cs * 0.13 * eff; | |
| const cx2 = px + windDx * 0.1, cy2 = py + windDy * 0.1 - cs * 0.18 * eff; | |
| const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r); | |
| gr.addColorStop(0, `rgba(255,255,220,${eff * 0.9})`); | |
| gr.addColorStop(1, 'rgba(255,220,60,0)'); | |
| ctx.fillStyle = gr; | |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| /* Wind-carried plume tip */ | |
| if (fire > 0.35) { | |
| const r = cs * 0.28 * eff; | |
| const cx2 = px + windDx, cy2 = py + windDy - cs * 0.22 * eff; | |
| const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r); | |
| gr.addColorStop(0, `rgba(255,160,0,${eff * 0.65})`); | |
| gr.addColorStop(1, 'rgba(255,60,0,0)'); | |
| ctx.fillStyle = gr; | |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| if (fire > 0.45 && Math.random() < 0.09 && embersRef.current.length < 120) { | |
| embersRef.current.push(new Ember(px, py, wv[0])); | |
| } | |
| } | |
| } | |
| /* ── Smoke (dark on light bg) ── */ | |
| for (let y = 0; y < H; y++) { | |
| for (let x = 0; x < W; x++) { | |
| const smoke = smoke_grid[idx(x, y)]; | |
| if (smoke < 0.1) continue; | |
| const px = x * cs + cs / 2, py = y * cs + cs / 2; | |
| const offX = Math.sin(t * 0.5 + x) * 2; | |
| const offY = Math.cos(t * 0.4 + y) * 2; | |
| const alpha = Math.min(0.68, smoke * 0.8); | |
| const gr = ctx.createRadialGradient(px + offX, py + offY, 0, px + offX, py + offY, cs * 0.82); | |
| gr.addColorStop(0, `rgba(72,82,96,${alpha})`); | |
| gr.addColorStop(1, 'rgba(72,82,96,0)'); | |
| ctx.fillStyle = gr; | |
| ctx.beginPath(); ctx.arc(px + offX, py + offY, cs * 0.82, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| } | |
| /* ── Exits & Doors ── */ | |
| for (let y = 0; y < H; y++) { | |
| for (let x = 0; x < W; x++) { | |
| const ct = cell_grid[idx(x, y)]; | |
| const px = x * cs, py = y * cs; | |
| const pulse = 0.7 + 0.3 * Math.sin(t * 3); | |
| if (ct === EXIT) { | |
| ctx.fillStyle = '#e6f4ec'; | |
| ctx.fillRect(px + 2, py + 2, cs - 4, cs - 4); | |
| ctx.strokeStyle = `rgba(22,163,74,${0.7 + 0.3 * pulse})`; | |
| ctx.lineWidth = 2 * pulse; | |
| ctx.strokeRect(px + 5, py + 5, cs - 10, cs - 10); | |
| /* EXIT symbol */ | |
| ctx.fillStyle = `rgba(22,163,74,${0.85 + 0.15 * pulse})`; | |
| ctx.font = `bold ${cs * 0.26}px var(--mono, monospace)`; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText('EXIT', px + cs / 2, py + cs / 2); | |
| } else if (ct === DOOR_CLOSED) { | |
| ctx.fillStyle = '#7c5c3c'; | |
| ctx.fillRect(px + 4, py + 2, cs - 8, cs - 4); | |
| ctx.fillStyle = '#4a3020'; | |
| ctx.fillRect(px + 2, py, cs - 4, 2); | |
| ctx.fillRect(px + 2, py + cs - 2, cs - 4, 2); | |
| /* handle */ | |
| ctx.fillStyle = '#f0b030'; | |
| ctx.beginPath(); | |
| ctx.arc(px + cs - 10, py + cs / 2, 2.5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } else if (ct === DOOR_OPEN) { | |
| ctx.fillStyle = '#4a3020'; | |
| ctx.fillRect(px + 2, py, 4, cs); | |
| ctx.fillRect(px + cs - 6, py, 4, cs); | |
| } | |
| } | |
| } | |
| /* ── Fog of War (dim only — all map items still readable) ── */ | |
| for (let y = 0; y < H; y++) { | |
| for (let x = 0; x < W; x++) { | |
| const key = `${x},${y}`; | |
| if (!visible.has(key)) { | |
| ctx.fillStyle = 'rgba(140,134,126,0.55)'; | |
| ctx.fillRect(x * cs, y * cs, cs, cs); | |
| } | |
| } | |
| } | |
| /* ── Vision lantern glow around agent ── */ | |
| const apx = agent_x * cs + cs / 2, apy = agent_y * cs + cs / 2; | |
| const lanternR = cs * 3.5; | |
| const lanternGr = ctx.createRadialGradient(apx, apy, 0, apx, apy, lanternR); | |
| lanternGr.addColorStop(0, 'rgba(255,240,190,0.18)'); | |
| lanternGr.addColorStop(0.5, 'rgba(255,220,140,0.08)'); | |
| lanternGr.addColorStop(1, 'rgba(0,0,0,0)'); | |
| ctx.fillStyle = lanternGr; | |
| ctx.fillRect(apx - lanternR, apy - lanternR, lanternR * 2, lanternR * 2); | |
| /* ── Agent trail ── */ | |
| const now = timeRef.current; | |
| if ( | |
| trailRef.current.length === 0 || | |
| Math.abs(trailRef.current[0].x - apx) > 1 || | |
| Math.abs(trailRef.current[0].y - apy) > 1 | |
| ) { | |
| trailRef.current.unshift({ x: apx, y: apy, t: now }); | |
| } | |
| if (trailRef.current.length > 20) trailRef.current.pop(); | |
| trailRef.current.forEach((p, i) => { | |
| const alpha = (1 - i / trailRef.current.length) * 0.70; | |
| ctx.fillStyle = `rgba(2,132,199,${alpha})`; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, cs * 0.12 * (1 - i / 22), 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| /* ── Agent rendering ── */ | |
| const theme = | |
| agent_health >= 60 ? AGENT_THEMES.healthy : | |
| agent_health >= 30 ? AGENT_THEMES.moderate : | |
| agent_health > 0 ? AGENT_THEMES.low : | |
| AGENT_THEMES.critical; | |
| const pulse = 0.85 + 0.15 * Math.sin(t * 4); | |
| const ringR = cs * 0.48; | |
| /* pulsing gold aura */ | |
| const auraR = ringR * (1.5 + 0.2 * pulse); | |
| const auraGr = ctx.createRadialGradient(apx, apy, ringR * 0.7, apx, apy, auraR); | |
| auraGr.addColorStop(0, `rgba(251,191,36,${0.28 * pulse})`); | |
| auraGr.addColorStop(1, 'rgba(251,191,36,0)'); | |
| ctx.fillStyle = auraGr; | |
| ctx.beginPath(); ctx.arc(apx, apy, auraR, 0, Math.PI * 2); ctx.fill(); | |
| /* ground shadow */ | |
| ctx.save(); | |
| ctx.globalAlpha = 0.22; | |
| const shadowGr = ctx.createRadialGradient(apx, apy + cs * 0.32, 0, apx, apy + cs * 0.32, cs * 0.38); | |
| shadowGr.addColorStop(0, 'rgba(0,0,0,0.6)'); | |
| shadowGr.addColorStop(1, 'rgba(0,0,0,0)'); | |
| ctx.fillStyle = shadowGr; | |
| ctx.beginPath(); ctx.ellipse(apx, apy + cs * 0.32, cs * 0.38, cs * 0.14, 0, 0, Math.PI * 2); ctx.fill(); | |
| ctx.restore(); | |
| /* Minecraft character */ | |
| drawMinecraftAgent(ctx, apx, apy, cs, theme); | |
| /* health arc ring — gold ring + colored fill */ | |
| const hRatio = Math.max(0, Math.min(1, agent_health / 100)); | |
| /* ring track */ | |
| ctx.beginPath(); | |
| ctx.arc(apx, apy, ringR, 0, Math.PI * 2); | |
| ctx.strokeStyle = 'rgba(0,0,0,0.12)'; | |
| ctx.lineWidth = 4; | |
| ctx.lineCap = 'round'; | |
| ctx.stroke(); | |
| /* gold base ring */ | |
| ctx.beginPath(); | |
| ctx.arc(apx, apy, ringR, 0, Math.PI * 2); | |
| ctx.strokeStyle = 'rgba(251,191,36,0.25)'; | |
| ctx.lineWidth = 3.5; | |
| ctx.stroke(); | |
| /* health fill */ | |
| ctx.beginPath(); | |
| ctx.arc(apx, apy, ringR, -Math.PI / 2, -Math.PI / 2 + hRatio * Math.PI * 2); | |
| ctx.strokeStyle = theme.ring; | |
| ctx.lineWidth = 3.5; | |
| ctx.lineCap = 'round'; | |
| ctx.stroke(); | |
| /* ring glow */ | |
| ctx.beginPath(); | |
| ctx.arc(apx, apy, ringR, -Math.PI / 2, -Math.PI / 2 + hRatio * Math.PI * 2); | |
| ctx.strokeStyle = theme.ringGlow; | |
| ctx.lineWidth = 6; | |
| ctx.stroke(); | |
| /* move flash */ | |
| if (agentMoveFlash > 0) { | |
| const fa = agentMoveFlash / 18; | |
| ctx.strokeStyle = `rgba(255,255,255,${fa * 0.8})`; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.arc(apx, apy, ringR * (1.8 + (1 - fa) * 0.6), 0, Math.PI * 2); | |
| ctx.stroke(); | |
| } | |
| /* ── Embers ── */ | |
| for (let i = embersRef.current.length - 1; i >= 0; i--) { | |
| const e = embersRef.current[i]; | |
| e.update(); | |
| if (e.life <= 0) { embersRef.current.splice(i, 1); continue; } | |
| ctx.fillStyle = `rgba(255,${Math.floor(80 + 175 * e.life)},0,${e.life})`; | |
| ctx.beginPath(); ctx.arc(e.x, e.y, e.size * e.life, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| rafRef.current = requestAnimationFrame(animate); | |
| }; | |
| useEffect(() => { | |
| rafRef.current = requestAnimationFrame(animate); | |
| return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [observation, agentMoveFlash]); | |
| const W = observation?.map_state.grid_w ?? 16; | |
| const H = observation?.map_state.grid_h ?? 16; | |
| return ( | |
| <canvas | |
| ref={canvasRef} | |
| id="map-canvas" | |
| width={W * CS} | |
| height={H * CS} | |
| /> | |
| ); | |
| }; | |
| export default Map2D; | |