Akshaykumarbm's picture
Upload folder using huggingface_hub
1123bef verified
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;