| import React, { useRef, useEffect } from 'react'; |
| import type { Observation } from '../types'; |
|
|
| interface Map2DProps { |
| observation: Observation | null; |
| agentMoveFlash: number; |
| } |
|
|
| |
| const WALL = 1; |
| const DOOR_OPEN = 2; |
| const DOOR_CLOSED = 3; |
| const EXIT = 4; |
| const OBSTACLE = 5; |
|
|
| |
| 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], |
| }; |
|
|
| |
| 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)' }, |
| }; |
|
|
| |
| 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; |
| } |
| } |
|
|
|
|
| |
| 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); |
| }; |
|
|
| |
| px(2, 0, 6, 1, '#5c4a3d'); |
| |
| px(2, 1, 6, 5, '#f5d5a0'); |
| |
| px(3, 3, 1, 1, '#3d2b1a'); |
| px(6, 3, 1, 1, '#3d2b1a'); |
| px(4, 5, 2, 1, '#c8937a'); |
| |
| px(2, 1, 6, 1, '#7a5c3e'); |
|
|
| |
| px(3, 6, 4, 4, theme.body); |
| px(3, 6, 4, 1, theme.dark); |
|
|
| |
| px(1, 6, 2, 4, theme.arm); |
| px(7, 6, 2, 4, theme.arm); |
|
|
| |
| px(3, 10, 2, 4, '#1e40af'); |
| px(5, 10, 2, 4, '#1e3a8a'); |
|
|
| |
| px(3, 14, 2, 2, '#3a2e26'); |
| px(5, 14, 2, 2, '#2e2420'); |
| } |
|
|
| |
| 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}`)); |
|
|
| |
| ctx.fillStyle = '#c8b890'; |
| ctx.fillRect(0, 0, canvas.width, canvas.height); |
|
|
| |
| 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: { |
| |
| const nearFire = fire_grid[idx(x, y)] > 0.05 |
| ? fire_grid[idx(x, y)] |
| : ( |
| (x > 0 && fire_grid[idx(x-1, y)] > 0.05 ? fire_grid[idx(x-1, y)] : 0) + |
| (x < W-1 && fire_grid[idx(x+1, y)] > 0.05 ? fire_grid[idx(x+1, y)] : 0) + |
| (y > 0 && fire_grid[idx(x, y-1)] > 0.05 ? fire_grid[idx(x, y-1)] : 0) + |
| (y < H-1 && fire_grid[idx(x, y+1)] > 0.05 ? fire_grid[idx(x, y+1)] : 0) |
| ) * 0.28; |
|
|
| const heatShift = Math.min(1, nearFire * 2.2); |
| const wallFlicker = 0.88 + 0.12 * Math.sin(t * 7.3 + x * 2.1 + y * 3.7); |
|
|
| |
| const br = Math.round(94 + heatShift * 100 * wallFlicker); |
| const bg = Math.round(88 - heatShift * 52); |
| const bb = Math.round(80 - heatShift * 70); |
| ctx.fillStyle = `rgb(${br},${bg},${bb})`; |
| ctx.fillRect(px, py, cs, cs); |
|
|
| |
| const brickH = cs / 2; |
| for (let row = 0; row < 2; row++) { |
| const by = py + row * brickH; |
| |
| ctx.fillStyle = `rgba(0,0,0,${0.28 + heatShift * 0.12})`; |
| ctx.fillRect(px, by + brickH - 1, cs, 1); |
| |
| const mortarX = px + ((x + row) % 2 === 0 ? cs / 2 : cs / 4); |
| ctx.fillStyle = `rgba(0,0,0,${0.22 + heatShift * 0.10})`; |
| ctx.fillRect(mortarX, by, 1, brickH - 1); |
| } |
|
|
| |
| ctx.fillStyle = `rgba(255,${200 - Math.round(heatShift * 80)},${160 - Math.round(heatShift * 140)},${0.32 + heatShift * 0.15})`; |
| ctx.fillRect(px, py, cs, 2); |
| ctx.fillRect(px, py + 2, 2, cs - 2); |
|
|
| |
| ctx.fillStyle = `rgba(0,0,0,${0.50 + heatShift * 0.20})`; |
| ctx.fillRect(px, py + cs - 2, cs, 2); |
| ctx.fillRect(px + cs - 2, py, 2, cs - 2); |
|
|
| |
| if (heatShift > 0.05) { |
| const glowA = heatShift * 0.35 * wallFlicker; |
| ctx.fillStyle = `rgba(255,${Math.round(80 - heatShift * 60)},0,${glowA})`; |
| ctx.fillRect(px + 2, py + 2, cs - 4, cs - 4); |
|
|
| |
| ctx.strokeStyle = `rgba(255,${Math.round(160 - heatShift * 120)},0,${heatShift * 0.6 * wallFlicker})`; |
| ctx.lineWidth = 1; |
| ctx.beginPath(); |
| ctx.moveTo(px + cs * 0.3, py + cs * 0.2); |
| ctx.lineTo(px + cs * 0.5, py + cs * 0.55); |
| ctx.lineTo(px + cs * 0.7, py + cs * 0.4); |
| ctx.stroke(); |
| if (heatShift > 0.4) { |
| ctx.beginPath(); |
| ctx.moveTo(px + cs * 0.2, py + cs * 0.7); |
| ctx.lineTo(px + cs * 0.45, py + cs * 0.85); |
| ctx.stroke(); |
| } |
| } |
| break; |
| } |
| case OBSTACLE: { |
| |
| const obsNearFire = ( |
| (x > 0 && fire_grid[idx(x-1, y)] > 0.05 ? fire_grid[idx(x-1, y)] : 0) + |
| (x < W-1 && fire_grid[idx(x+1, y)] > 0.05 ? fire_grid[idx(x+1, y)] : 0) + |
| (y > 0 && fire_grid[idx(x, y-1)] > 0.05 ? fire_grid[idx(x, y-1)] : 0) + |
| (y < H-1 && fire_grid[idx(x, y+1)] > 0.05 ? fire_grid[idx(x, y+1)] : 0) |
| ) * 0.4 + fire_grid[idx(x, y)] * 0.8; |
| const obsHeat = Math.min(1, obsNearFire); |
| const obsFlicker = 0.82 + 0.18 * Math.sin(t * 9.1 + x * 1.9 + y * 2.5); |
|
|
| ctx.fillStyle = '#2a2520'; |
| ctx.fillRect(px, py, cs, cs); |
|
|
| |
| ctx.fillStyle = 'rgba(60,50,40,0.7)'; |
| ctx.fillRect(px + 3, py + 3, cs * 0.4, cs * 0.35); |
| ctx.fillRect(px + cs * 0.55, py + cs * 0.5, cs * 0.35, cs * 0.4); |
| ctx.fillStyle = 'rgba(80,65,50,0.5)'; |
| ctx.fillRect(px + cs * 0.25, py + cs * 0.6, cs * 0.45, cs * 0.3); |
|
|
| |
| if (obsHeat > 0.05) { |
| const eg = Math.round(40 + obsHeat * 90 * obsFlicker); |
| ctx.fillStyle = `rgba(255,${eg},0,${obsHeat * 0.55 * obsFlicker})`; |
| ctx.fillRect(px + 2, py + 2, cs - 4, cs - 4); |
|
|
| |
| ctx.strokeStyle = `rgba(255,${Math.round(120 * obsHeat * obsFlicker)},0,${obsHeat * 0.8})`; |
| ctx.lineWidth = 1.5; |
| ctx.beginPath(); |
| ctx.moveTo(px + cs * 0.1, py + cs * 0.5); |
| ctx.lineTo(px + cs * 0.4, py + cs * 0.3); |
| ctx.lineTo(px + cs * 0.6, py + cs * 0.7); |
| ctx.lineTo(px + cs * 0.9, py + cs * 0.4); |
| ctx.stroke(); |
| } |
|
|
| |
| ctx.strokeStyle = `rgba(255,${Math.round(80 + obsHeat * 60)},0,${0.55 + obsHeat * 0.35})`; |
| ctx.lineWidth = 2; |
| ctx.strokeRect(px + 1, py + 1, cs - 2, cs - 2); |
|
|
| |
| ctx.fillStyle = `rgba(255,${Math.round(100 + obsHeat * 80)},0,${0.7 + obsHeat * 0.3})`; |
| [[4,4],[cs-6,4],[4,cs-6],[cs-6,cs-6]].forEach(([bx, by]) => { |
| ctx.beginPath(); ctx.arc(px+bx, py+by, 2, 0, Math.PI*2); ctx.fill(); |
| }); |
| break; |
| } |
| default: { |
| |
| const floorFire = Math.min(1, |
| fire_grid[idx(x, y)] * 1.5 + |
| (x > 0 ? fire_grid[idx(x-1, y)] : 0) * 0.3 + |
| (x < W-1 ? fire_grid[idx(x+1, y)] : 0) * 0.3 + |
| (y > 0 ? fire_grid[idx(x, y-1)] : 0) * 0.3 + |
| (y < H-1 ? fire_grid[idx(x, y+1)] : 0) * 0.3 |
| ); |
| const base = (x + y) % 2 === 0; |
| const fr = Math.round((base ? 232 : 208) + floorFire * 23); |
| const fg = Math.round((base ? 216 : 190) - floorFire * 40); |
| const fb = Math.round((base ? 184 : 152) - floorFire * 80); |
| ctx.fillStyle = `rgb(${fr},${fg},${fb})`; |
| ctx.fillRect(px, py, cs, cs); |
| |
| 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); |
| } |
| } |
| } |
| } |
|
|
| |
| 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(); |
|
|
| |
| 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(); |
| } |
| } |
|
|
| |
| 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); |
| |
| 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); |
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| 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 isVisible = visible.has(`${x},${y}`); |
|
|
| const windDx = wv[0] * cs * 0.25 * eff; |
| const windDy = wv[1] * cs * 0.25 * eff - cs * 0.06; |
|
|
| |
| if (!isVisible) { |
| const beaconPulse = 0.6 + 0.4 * Math.sin(t * 6.0 + x * 1.7 + y * 2.3); |
| const beaconR = cs * (1.4 + beaconPulse * 0.6); |
| const beaconGr = ctx.createRadialGradient(px, py, 0, px, py, beaconR); |
| beaconGr.addColorStop(0, `rgba(255,100,0,${eff * beaconPulse * 0.85})`); |
| beaconGr.addColorStop(0.3, `rgba(255,60,0,${eff * beaconPulse * 0.50})`); |
| beaconGr.addColorStop(1, 'rgba(220,30,0,0)'); |
| ctx.fillStyle = beaconGr; |
| ctx.beginPath(); ctx.arc(px, py, beaconR, 0, Math.PI * 2); ctx.fill(); |
| } |
|
|
| |
| { |
| 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(); |
| } |
|
|
| |
| { |
| 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.92})`); |
| gr.addColorStop(0.45,`rgba(255,60,0,${eff * 0.62})`); |
| gr.addColorStop(1, 'rgba(220,20,0,0)'); |
| ctx.fillStyle = gr; |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); |
| } |
|
|
| |
| { |
| const r = cs * 0.28 * 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,235,90,${eff * 0.97})`); |
| gr.addColorStop(0.35,`rgba(255,175,25,${eff * 0.78})`); |
| gr.addColorStop(1, 'rgba(255,80,0,0)'); |
| ctx.fillStyle = gr; |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); |
| } |
|
|
| |
| if (eff > 0.55) { |
| const r = cs * 0.14 * 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,230,${eff * 0.95})`); |
| gr.addColorStop(1, 'rgba(255,220,60,0)'); |
| ctx.fillStyle = gr; |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); |
| } |
|
|
| |
| if (fire > 0.35) { |
| const r = cs * 0.30 * 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,165,10,${eff * 0.68})`); |
| gr.addColorStop(1, 'rgba(255,60,0,0)'); |
| ctx.fillStyle = gr; |
| ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill(); |
| } |
|
|
| |
| { |
| const bloomR = cs * (0.85 + eff * 0.35); |
| const bloomGr = ctx.createRadialGradient(px, py, cs * 0.2, px, py, bloomR); |
| bloomGr.addColorStop(0, 'rgba(255,120,0,0)'); |
| bloomGr.addColorStop(0.6, `rgba(255,80,0,${eff * 0.22})`); |
| bloomGr.addColorStop(1, 'rgba(200,30,0,0)'); |
| ctx.fillStyle = bloomGr; |
| ctx.beginPath(); ctx.arc(px, py, bloomR, 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])); |
| } |
| } |
| } |
|
|
| |
| 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); |
|
|
| |
| 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(); |
| }); |
|
|
| |
| 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; |
|
|
| |
| 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(); |
|
|
| |
| 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(); |
|
|
| |
| drawMinecraftAgent(ctx, apx, apy, cs, theme); |
|
|
| |
| const hRatio = Math.max(0, Math.min(1, agent_health / 100)); |
| |
| 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(); |
| |
| 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(); |
| |
| 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(); |
| |
| 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(); |
|
|
| |
| 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(); |
| } |
|
|
| |
| 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); }; |
| |
| }, [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; |