gabraken's picture
Remove debug shortuts
c5be6f3
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { backendUrl } from '$lib/socket';
import { gameState, myPlayerId, selectedUnit, selectedBuilding, mapViewport, mapVisibleCells, mapExploredCells, mapCenterRequest, mapTextureUrl, isTutorial } from '$lib/stores/game';
import { BUILDING_SIZES } from '$lib/types';
import type { Unit, Building, UnitType, BuildingType, GameState } from '$lib/types';
// โ”€โ”€ Per-unit smooth interpolation state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const TICK_MS = 250; // must match server TICK_INTERVAL * 1000
type UnitRenderState = {
renderX: number;
renderY: number;
prevX: number;
prevY: number;
nextX: number;
nextY: number;
angle: number; // degrees โ€“ 0 = sprite faces up (north)
updateTime: number; // performance.now() when nextX/Y was received
};
let unitRenderStates: Record<string, UnitRenderState> = {};
let rafId = 0;
// โ”€โ”€ Attack beam animations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const BEAM_DURATION_MS = 220; // fade-out duration per flash
type BeamStyle = { color: string; width: number; dasharray?: string; glowStd?: number };
const UNIT_BEAM: Record<UnitType, BeamStyle> = {
scv: { color: '#ff9500', width: 0.10, glowStd: 0.12 },
marine: { color: '#00e5ff', width: 0.05, glowStd: 0.10 },
medic: { color: '#00e676', width: 0.05, dasharray: '0.25 0.12', glowStd: 0.08 },
goliath: { color: '#cddc39', width: 0.13, glowStd: 0.14 },
tank: { color: '#ff5722', width: 0.22, glowStd: 0.22 },
wraith: { color: '#e040fb', width: 0.07, glowStd: 0.12 },
};
// beamFlashTimes[unitId] = performance.now() of last tick where unit fired
let beamFlashTimes: Record<string, number> = {};
// beamOpacities[unitId] = current rendered opacity (0โ€“1), updated each animLoop frame
let beamOpacities: Record<string, number> = {};
// prevCooldowns[unitId] = attack_cooldown value from the previous game state tick
let prevCooldowns: Record<string, number> = {};
// Use progressive blob URL from preloader when available, fallback to direct URL
$: mapImageUrl = $mapTextureUrl ?? (typeof window !== 'undefined' ? `${backendUrl()}/static/MAP.png` : '');
const UNIT_SPRITE: Record<UnitType, string> = {
scv: 'scv.png', marine: 'marine.png', medic: 'medic.png',
goliath: 'goliath.png', tank: 'tank.png', wraith: 'wraith.png',
};
$: spriteBase = typeof window !== 'undefined' ? `${backendUrl()}/sprites` : '';
const MAP_W = 80;
const MAP_H = 80;
// Geographic landmarks loaded from map.json (same source as backend MAP_LANDMARKS)
type Landmark = { slug: string; name: string; x: number; y: number };
let MAP_LANDMARKS: Landmark[] = [];
onMount(() => {
fetch(`${backendUrl()}/static/map.json`)
.then((r) => r.json())
.then((data) => {
const locs: Array<{ name: string; x: number; y: number }> = data.locations ?? [];
MAP_LANDMARKS = locs
.filter((l) => l.name)
.map((l) => ({
slug: l.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''),
name: l.name,
x: Math.min(80, Math.max(0, Math.round(l.x * 80 / 100 * 10) / 10)),
y: Math.min(80, Math.max(0, Math.round(l.y * 80 / 100 * 10) / 10)),
}));
})
.catch(() => {});
});
// Vision radii for fog of war (in game cells)
const UNIT_VISION: Record<UnitType, number> = {
scv: 6, marine: 6, medic: 6, goliath: 8, tank: 8, wraith: 9,
};
const SIEGED_TANK_VISION = 12;
const BUILDING_VISION: Record<BuildingType, number> = {
command_center: 10, supply_depot: 7, barracks: 7, engineering_bay: 7,
refinery: 7, factory: 7, armory: 7, starport: 7,
};
function visionRadiusUnit(u: Unit): number {
if (u.unit_type === 'tank' && u.is_sieged) return SIEGED_TANK_VISION;
return UNIT_VISION[u.unit_type];
}
function visionRadiusBuilding(b: Building): number {
return BUILDING_VISION[b.building_type];
}
function cellsInRadius(cx: number, cy: number, r: number): Set<string> {
const out = new Set<string>();
const i0 = Math.max(0, Math.floor(cx - r));
const i1 = Math.min(MAP_W - 1, Math.ceil(cx + r));
const j0 = Math.max(0, Math.floor(cy - r));
const j1 = Math.min(MAP_H - 1, Math.ceil(cy + r));
for (let i = i0; i <= i1; i++) {
for (let j = j0; j <= j1; j++) {
const cellCx = i + 0.5, cellCy = j + 0.5;
if ((cellCx - cx) ** 2 + (cellCy - cy) ** 2 <= r * r) {
out.add(`${i},${j}`);
}
}
}
return out;
}
// ViewBox state (in game coordinates) โ€” vw/vh adapt to the container's aspect ratio
// so the map fills the screen without letterboxing. vh is the reference height (18 units);
// vw is computed from the actual container width/height ratio.
const DEFAULT_VIEW_H = 18;
let vx = 0, vy = 0, vw = DEFAULT_VIEW_H, vh = DEFAULT_VIEW_H;
let svgEl: SVGSVGElement;
let cameraInitialized = false;
function updateViewSize() {
if (!svgEl) return;
const { width, height } = svgEl.getBoundingClientRect();
if (height > 0) {
vw = DEFAULT_VIEW_H * (width / height);
vx = clamp(vx, 0, MAP_W - vw);
vy = clamp(vy, 0, MAP_H - vh);
}
}
// Touch tracking (pan only, no pinch zoom)
type TouchState =
| { kind: 'pan'; startX: number; startY: number; lastX: number; lastY: number; moved: boolean };
let touchState: TouchState | null = null;
// โ”€โ”€ Coordinate helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function screenToWorld(sx: number, sy: number): [number, number] {
const rect = svgEl.getBoundingClientRect();
return [
vx + (sx - rect.left) * (vw / rect.width),
vy + (sy - rect.top) * (vh / rect.height),
];
}
function clamp(val: number, min: number, max: number) {
return Math.max(min, Math.min(max, val));
}
// โ”€โ”€ Touch handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function onTouchStart(e: TouchEvent) {
if (e.touches.length === 1) {
touchState = {
kind: 'pan',
startX: e.touches[0].clientX, startY: e.touches[0].clientY,
lastX: e.touches[0].clientX, lastY: e.touches[0].clientY,
moved: false,
};
}
}
function onTouchMove(e: TouchEvent) {
if (!touchState) return;
if (touchState.kind === 'pan' && e.touches.length === 1) {
const rect = svgEl.getBoundingClientRect();
const sx = vw / rect.width;
const sy = vh / rect.height;
const dx = (e.touches[0].clientX - touchState.lastX) * sx;
const dy = (e.touches[0].clientY - touchState.lastY) * sy;
const moved = Math.hypot(
e.touches[0].clientX - touchState.startX,
e.touches[0].clientY - touchState.startY
) > 6;
pendingPanDx -= dx;
pendingPanDy -= dy;
touchState = { ...touchState, lastX: e.touches[0].clientX, lastY: e.touches[0].clientY, moved };
}
}
function onTouchEnd(e: TouchEvent) {
if (touchState?.kind === 'pan' && !touchState.moved) {
const [wx, wy] = screenToWorld(touchState.startX, touchState.startY);
handleTap(wx, wy);
}
if (e.touches.length === 0) touchState = null;
}
// Mouse pan for desktop
let mouseDown = false;
let mouseLastX = 0, mouseLastY = 0;
// Pending pan deltas accumulated from input events, applied in animLoop to keep
// viewBox and sprite positions in the same RAF frame (avoids 1-frame jump).
let pendingPanDx = 0, pendingPanDy = 0;
function onMouseDown(e: MouseEvent) {
mouseDown = true;
mouseLastX = e.clientX;
mouseLastY = e.clientY;
}
function onMouseMove(e: MouseEvent) {
if (!mouseDown) return;
const rect = svgEl.getBoundingClientRect();
const sx = vw / rect.width;
const sy = vh / rect.height;
pendingPanDx -= (e.clientX - mouseLastX) * sx;
pendingPanDy -= (e.clientY - mouseLastY) * sy;
mouseLastX = e.clientX;
mouseLastY = e.clientY;
}
function onMouseUp() { mouseDown = false; }
// โ”€โ”€ Tap to select โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function handleTap(wx: number, wy: number) {
const gs = $gameState;
if (!gs) return;
// Check units first (smaller hit area)
for (const player of Object.values(gs.players)) {
for (const unit of Object.values(player.units)) {
if (Math.hypot(unit.x - wx, unit.y - wy) < 0.7) {
selectedUnit.set(unit);
selectedBuilding.set(null);
return;
}
}
}
// Check buildings
for (const player of Object.values(gs.players)) {
for (const b of Object.values(player.buildings)) {
const size = BUILDING_SIZES[b.building_type];
if (wx >= b.x - size.w / 2 && wx < b.x + size.w / 2 && wy >= b.y - size.h / 2 && wy < b.y + size.h / 2) {
selectedBuilding.set(b);
selectedUnit.set(null);
return;
}
}
}
// Tap on empty: deselect
selectedUnit.set(null);
selectedBuilding.set(null);
}
// โ”€โ”€ Derived rendering data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
$: gs = $gameState;
$: myId = $myPlayerId;
$: isObserver = !myId && !!gs;
// When game state changes, record new server positions for each unit.
// This is done via an explicit store subscription in onMount (NOT a $: reactive block)
// to avoid unitRenderStates being tracked as a reactive dependency, which would cause
// the $: block to re-run on every animLoop frame and reset updateTime each frame.
function onGameStateUpdate(newGs: typeof gs) {
if (!newGs) return;
const now = performance.now();
const activeIds = new Set<string>();
for (const player of Object.values(newGs.players)) {
for (const u of Object.values(player.units)) {
activeIds.add(u.id);
const prev = unitRenderStates[u.id];
if (!prev) {
unitRenderStates[u.id] = {
renderX: u.x, renderY: u.y,
prevX: u.x, prevY: u.y,
nextX: u.x, nextY: u.y,
angle: 0,
updateTime: now,
};
} else {
const dx = u.x - prev.nextX;
const dy = u.y - prev.nextY;
const dist = Math.hypot(dx, dy);
// atan2: dy/dx in SVG coords (y down). Sprites face north (up) by default โ†’ +90ยฐ
let angle = prev.angle;
if (dist > 0.02) {
angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90;
} else if (u.attack_target_id || u.attack_target_building_id) {
// Unit is stationary but shooting โ€” face the target
let tx: number | undefined, ty: number | undefined;
if (u.attack_target_id) {
for (const p of Object.values(newGs.players)) {
const t = p.units[u.attack_target_id];
if (t) { tx = t.x; ty = t.y; break; }
}
} else if (u.attack_target_building_id) {
for (const p of Object.values(newGs.players)) {
const t = p.buildings[u.attack_target_building_id];
if (t) { tx = t.x; ty = t.y; break; }
}
}
if (tx !== undefined && ty !== undefined) {
const adx = tx - u.x;
const ady = ty - u.y;
if (Math.hypot(adx, ady) > 0.01) {
angle = Math.atan2(ady, adx) * (180 / Math.PI) + 90;
}
}
}
unitRenderStates[u.id] = {
...prev,
prevX: prev.renderX,
prevY: prev.renderY,
nextX: u.x,
nextY: u.y,
angle,
updateTime: now,
};
}
}
}
// Remove stale entries for dead units
for (const id of Object.keys(unitRenderStates)) {
if (!activeIds.has(id)) delete unitRenderStates[id];
}
// Track attack flashes: trigger only when attack_cooldown resets (unit just fired)
for (const player of Object.values(newGs.players)) {
for (const u of Object.values(player.units)) {
const prev = prevCooldowns[u.id];
if (prev !== undefined && u.attack_cooldown > prev) {
beamFlashTimes[u.id] = now;
}
prevCooldowns[u.id] = u.attack_cooldown;
}
}
for (const id of Object.keys(prevCooldowns)) {
if (!activeIds.has(id)) delete prevCooldowns[id];
}
unitRenderStates = { ...unitRenderStates };
// Accumulate fog explored sources (reliable here: explicit subscription, not $: block)
accumulateExploredSources(newGs as GameState);
}
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
function animLoop() {
const now = performance.now();
let changed = false;
// Apply accumulated pan deltas here so viewBox + sprite positions update in the same frame.
if (pendingPanDx !== 0 || pendingPanDy !== 0) {
vx = clamp(vx + pendingPanDx, 0, MAP_W - vw);
vy = clamp(vy + pendingPanDy, 0, MAP_H - vh);
pendingPanDx = 0;
pendingPanDy = 0;
}
for (const id of Object.keys(unitRenderStates)) {
const s = unitRenderStates[id];
const t = Math.min((now - s.updateTime) / TICK_MS, 1);
const nx = lerp(s.prevX, s.nextX, t);
const ny = lerp(s.prevY, s.nextY, t);
if (Math.abs(nx - s.renderX) > 0.0005 || Math.abs(ny - s.renderY) > 0.0005) {
unitRenderStates[id] = { ...s, renderX: nx, renderY: ny };
changed = true;
}
}
if (changed) unitRenderStates = { ...unitRenderStates };
// Fade beam opacities
let beamsChanged = false;
for (const [id, t] of Object.entries(beamFlashTimes)) {
const elapsed = now - t;
const newOp = elapsed < BEAM_DURATION_MS ? 0.85 * (1 - elapsed / BEAM_DURATION_MS) + 0.1 : 0;
if (Math.abs((beamOpacities[id] ?? 0) - newOp) > 0.005) {
beamOpacities[id] = newOp;
beamsChanged = true;
}
if (newOp <= 0) {
delete beamFlashTimes[id];
delete beamOpacities[id];
beamsChanged = true;
}
}
if (beamsChanged) beamOpacities = { ...beamOpacities };
rafId = requestAnimationFrame(animLoop);
}
// Center camera on own Command Center on first game state received
$: if (!cameraInitialized && gs && myId) {
const myP = gs.players[myId];
const cc = myP && Object.values(myP.buildings).find((b) => b.building_type === 'command_center');
if (cc) {
const cx = cc.x;
const cy = cc.y;
vx = Math.max(0, Math.min(MAP_W - vw, cx - vw / 2));
vy = Math.max(0, Math.min(MAP_H - vh, cy - vh / 2));
cameraInitialized = true;
}
}
// Observer: center camera on map center
$: if (!cameraInitialized && gs && isObserver) {
vx = Math.max(0, MAP_W / 2 - vw / 2);
vy = Math.max(0, MAP_H / 2 - vh / 2);
cameraInitialized = true;
}
$: resources = gs?.game_map.resources ?? [];
const PLAYER_COLORS_BG = ['#1a3a6b', '#6b1a1a'];
const PLAYER_COLORS_BORDER = ['#58a6ff', '#f85149'];
$: allBuildings = gs
? Object.values(gs.players).flatMap((p, pi) =>
Object.values(p.buildings).map((b) => ({
b,
isOwn: p.player_id === myId,
colorBg: isObserver ? PLAYER_COLORS_BG[pi % 2] : (p.player_id === myId ? '#1a3a6b' : '#6b1a1a'),
colorBorder: isObserver ? PLAYER_COLORS_BORDER[pi % 2] : (p.player_id === myId ? '#58a6ff' : '#f85149'),
}))
)
: [];
$: allUnits = gs
? Object.values(gs.players).flatMap((p, pi) =>
Object.values(p.units).map((u) => ({
u,
isOwn: p.player_id === myId,
color: isObserver ? PLAYER_COLORS_BORDER[pi % 2] : (p.player_id === myId ? '#58a6ff' : '#f85149'),
}))
)
: [];
// Building center positions for beam targeting
$: buildingPosMap = gs
? Object.fromEntries(
Object.values(gs.players).flatMap((p) =>
Object.values(p.buildings).map((b) => {
return [b.id, { x: b.x, y: b.y }];
})
)
)
: {} as Record<string, { x: number; y: number }>;
// Fog of war: visible cells from own units and buildings
$: visibleCells = (() => {
const set = new Set<string>();
if (!gs || !myId) return set;
const myP = gs.players[myId];
if (!myP) return set;
for (const u of Object.values(myP.units)) {
const r = visionRadiusUnit(u);
cellsInRadius(u.x, u.y, r).forEach((c) => set.add(c));
}
for (const b of Object.values(myP.buildings)) {
if (b.status === 'destroyed') continue;
const r = visionRadiusBuilding(b);
cellsInRadius(b.x, b.y, r).forEach((c) => set.add(c));
}
return set;
})();
// Persist explored cells (once seen, stay in explored). Reassign so Svelte reactivity updates the fog masks.
let exploredCells = new Set<string>();
$: {
const next = new Set(exploredCells);
visibleCells.forEach((c) => next.add(c));
if (next.size !== exploredCells.size) exploredCells = next;
}
// Fog of war: circle-based vision sources for SVG mask rendering.
// getVisibleSources is called INLINE in the template (not via $: block) so that
// Svelte's template renderer tracks gs/myId as dependencies โ€” same pattern as the
// original buildFogRects, which avoids the production-build reactive scheduling issue.
type FogSource = { x: number; y: number; r: number };
function getVisibleSources(
_gs: typeof gs,
_myId: typeof myId,
): FogSource[] {
const sources: FogSource[] = [];
if (!_gs || !_myId) return sources;
const myP = _gs.players[_myId];
if (!myP) return sources;
for (const u of Object.values(myP.units)) {
sources.push({ x: u.x, y: u.y, r: visionRadiusUnit(u) });
}
for (const b of Object.values(myP.buildings)) {
if (b.status === 'destroyed') continue;
sources.push({ x: b.x, y: b.y, r: visionRadiusBuilding(b) });
}
return sources;
}
// Explored sources: accumulated in onGameStateUpdate (explicit subscription, reliable in prod).
let exploredSources: FogSource[] = [];
let _exploredSourceKeys = new Set<string>();
function accumulateExploredSources(newGs: GameState) {
if (!myId) return;
const myP = newGs.players[myId];
if (!myP) return;
let changed = false;
for (const u of Object.values(myP.units) as Unit[]) {
const r = visionRadiusUnit(u);
const key = `${Math.round(u.x * 2)},${Math.round(u.y * 2)},${r}`;
if (!_exploredSourceKeys.has(key)) {
_exploredSourceKeys.add(key);
exploredSources.push({ x: u.x, y: u.y, r });
changed = true;
}
}
for (const b of Object.values(myP.buildings) as Building[]) {
if (b.status === 'destroyed') continue;
const r = visionRadiusBuilding(b);
const key = `${Math.round(b.x * 2)},${Math.round(b.y * 2)},${r}`;
if (!_exploredSourceKeys.has(key)) {
_exploredSourceKeys.add(key);
exploredSources.push({ x: b.x, y: b.y, r });
changed = true;
}
}
if (changed) exploredSources = [...exploredSources];
}
// Expose viewport and fog for Minimap
$: mapViewport.set({ vx, vy, vw, vh });
$: mapVisibleCells.set(visibleCells);
$: mapExploredCells.set(exploredCells);
// Recenter when Minimap requests (click on minimap) โ€” subscribe so it always runs
let unsubCenter: (() => void) | null = null;
let unsubGameState: (() => void) | null = null;
onMount(() => {
updateViewSize();
const ro = new ResizeObserver(updateViewSize);
ro.observe(svgEl);
unsubCenter = mapCenterRequest.subscribe((req) => {
if (!req) return;
vx = clamp(req.cx - vw / 2, 0, MAP_W - vw);
vy = clamp(req.cy - vh / 2, 0, MAP_H - vh);
mapCenterRequest.set(null);
});
unsubGameState = gameState.subscribe(onGameStateUpdate);
rafId = requestAnimationFrame(animLoop);
return () => ro.disconnect();
});
onDestroy(() => {
unsubCenter?.();
unsubGameState?.();
if (rafId) cancelAnimationFrame(rafId);
});
function isVisible(wx: number, wy: number): boolean {
if (isObserver) return true;
const ix = Math.floor(wx), iy = Math.floor(wy);
if (ix < 0 || ix >= MAP_W || iy < 0 || iy >= MAP_H) return false;
return visibleCells.has(`${ix},${iy}`);
}
function hpColor(hp: number, max: number): string {
const r = hp / max;
if (r > 0.6) return '#3fb950';
if (r > 0.3) return '#d29922';
return '#f85149';
}
function buildingOpacity(status: string): number {
if (status === 'destroyed') return 0.2;
if (status === 'constructing') return 0.55;
return 1;
}
// โ”€โ”€ Walkable overlay (debug) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const SCALE = MAP_W / 100.0;
let showWalkable = false;
let showNavPoints = false;
let walkablePolygons: string[] = [];
let navPoints: [number, number][] = [];
onMount(() => {
fetch(`${backendUrl()}/static/walkable.json`)
.then((r) => r.json())
.then((data) => {
walkablePolygons = (data.polygons ?? []).map((poly: number[][]) =>
poly.map(([x, y]) => `${x * SCALE},${y * SCALE}`).join(' ')
);
})
.catch(() => {});
fetch(`${backendUrl()}/static/compiled_map.json`)
.then((r) => r.json())
.then((data) => {
navPoints = (data.nav_points ?? []) as [number, number][];
})
.catch(() => {});
const onKey = (e: KeyboardEvent) => {
if (e.altKey && (e.key === 'w' || e.key === 'W')) showWalkable = !showWalkable;
if (e.altKey && (e.key === 'n' || e.key === 'N')) showNavPoints = !showNavPoints;
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
</script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<svg
bind:this={svgEl}
role="img"
aria-label="Game map"
viewBox="{vx} {vy} {vw} {vh}"
class="map-svg"
on:touchstart|nonpassive={onTouchStart}
on:touchmove|nonpassive={onTouchMove}
on:touchend={onTouchEnd}
on:mousedown={onMouseDown}
on:mousemove={onMouseMove}
on:mouseup={onMouseUp}
on:mouseleave={onMouseUp}
>
<!-- Background: MAP.png (game grid 40x40; image scaled to fit) -->
{#if mapImageUrl}
<image href={mapImageUrl} x="0" y="0" width={MAP_W} height={MAP_H} preserveAspectRatio="xMidYMid slice" />
{:else}
<rect x="0" y="0" width={MAP_W} height={MAP_H} fill="#0b1a0b" />
{/if}
<!-- Grid lines (subtle) -->
{#each { length: MAP_W + 1 } as _, i}
<line x1={i} y1={0} x2={i} y2={MAP_H} stroke="rgba(255,255,255,0.025)" stroke-width="0.02" />
{/each}
{#each { length: MAP_H + 1 } as _, i}
<line x1={0} y1={i} x2={MAP_W} y2={i} stroke="rgba(255,255,255,0.025)" stroke-width="0.02" />
{/each}
<!-- Geographic landmark labels -->
<defs>
<filter id="landmark-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
{#each MAP_LANDMARKS as lm}
<g pointer-events="none" opacity="0.55">
<text
x={lm.x} y={lm.y}
text-anchor="middle"
dominant-baseline="middle"
font-size="1.35"
font-family="'Courier New', monospace"
font-weight="600"
letter-spacing="0.08"
fill="rgba(200,220,255,0.9)"
stroke="rgba(0,0,0,0.7)"
stroke-width="0.25"
paint-order="stroke"
filter="url(#landmark-glow)"
style="text-transform: uppercase;"
>{lm.name}</text>
</g>
{/each}
<!-- Resources -->
{#each resources as res}
{#if res.resource_type === 'mineral' && res.amount > 0}
<image
href="{spriteBase}/resources/mineral.png"
x={res.x + 0.05} y={res.y + 0.05}
width="0.9" height="0.9"
preserveAspectRatio="xMidYMid meet"
/>
{:else if res.resource_type === 'geyser'}
<image
href="{spriteBase}/resources/geyser.png"
x={res.x + 0.05} y={res.y + 0.05}
width="0.9" height="0.9"
opacity={res.has_refinery ? 0.5 : 1}
preserveAspectRatio="xMidYMid meet"
/>
{/if}
{/each}
<!-- Buildings (enemies only if visible in fog of war) -->
{#each allBuildings as { b, isOwn, colorBg, colorBorder }}
{#if b.status !== 'destroyed' && (isOwn || isVisible(b.x, b.y))}
{@const size = BUILDING_SIZES[b.building_type]}
{@const color = colorBg}
{@const border = colorBorder}
{@const bx = b.x - size.w / 2}
{@const by = b.y - size.h / 2}
<g opacity={buildingOpacity(b.status)}>
<!-- Selection border -->
{#if b.id === $selectedBuilding?.id}
<rect
x={bx + 0.04} y={by + 0.04}
width={size.w - 0.08} height={size.h - 0.08}
fill="none"
stroke={border}
stroke-width="0.12"
rx="0.15"
/>
{/if}
<!-- Player color tint background -->
<rect
x={bx + 0.06} y={by + 0.06}
width={size.w - 0.12} height={size.h - 0.12}
fill={color}
rx="0.12"
opacity="0.55"
/>
<!-- Building sprite -->
<image
href="{spriteBase}/buildings/{b.building_type}.png"
x={bx + 0.08} y={by + 0.08}
width={size.w - 0.16} height={size.h - 0.16}
preserveAspectRatio="xMidYMid meet"
/>
<!-- Under construction: dashed overlay -->
{#if b.status === 'constructing'}
<rect
x={bx + 0.06} y={by + 0.06}
width={size.w - 0.12} height={size.h - 0.12}
fill="none"
stroke={border}
stroke-width="0.07"
stroke-dasharray="0.25 0.15"
rx="0.15"
opacity="0.8"
/>
{/if}
<!-- HP bar -->
<rect
x={bx + 0.1} y={by + size.h - 0.22}
width={size.w - 0.2} height="0.12"
fill="rgba(0,0,0,0.5)"
rx="0.06"
/>
<rect
x={bx + 0.1} y={by + size.h - 0.22}
width={(size.w - 0.2) * (b.hp / b.max_hp)}
height="0.12"
fill={hpColor(b.hp, b.max_hp)}
rx="0.06"
/>
<!-- Production dots -->
{#if b.production_queue.length > 0}
{#each { length: Math.min(b.production_queue.length, 5) } as _, qi}
<circle
cx={bx + 0.3 + qi * 0.28} cy={by + 0.22}
r="0.1"
fill={border}
opacity="0.9"
/>
{/each}
{/if}
</g>
{/if}
{/each}
<!-- Attack beams (rendered below units, above buildings) -->
<defs>
<filter id="beam-glow" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.18" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
{#each allUnits as { u, isOwn } (u.id)}
{@const op = beamOpacities[u.id] ?? 0}
{#if op > 0 && (isOwn || isVisible(u.x, u.y))}
{@const beam = UNIT_BEAM[u.unit_type]}
{@const isSiegedTank = u.unit_type === 'tank' && u.is_sieged}
{@const bw = isSiegedTank ? beam.width * 1.6 : beam.width}
{@const srcRs = unitRenderStates[u.id]}
{@const srcX = srcRs?.renderX ?? u.x}
{@const srcY = srcRs?.renderY ?? u.y}
{@const targetPos = u.attack_target_id
? (() => { const t = allUnits.find(a => a.u.id === u.attack_target_id); const trs = unitRenderStates[u.attack_target_id]; return t ? { x: trs?.renderX ?? t.u.x, y: trs?.renderY ?? t.u.y } : null; })()
: u.attack_target_building_id
? (buildingPosMap[u.attack_target_building_id] ?? null)
: null}
{#if targetPos}
<!-- Glow halo -->
<line
x1={srcX} y1={srcY} x2={targetPos.x} y2={targetPos.y}
stroke={beam.color}
stroke-width={bw * 3}
stroke-linecap="round"
opacity={op * 0.25}
filter="url(#beam-glow)"
pointer-events="none"
/>
<!-- Core beam -->
<line
x1={srcX} y1={srcY} x2={targetPos.x} y2={targetPos.y}
stroke={beam.color}
stroke-width={bw}
stroke-linecap="round"
stroke-dasharray={beam.dasharray ?? ''}
opacity={op}
pointer-events="none"
/>
{/if}
{/if}
{/each}
<!-- Units (enemies only if visible in fog of war) -->
{#each allUnits as { u, isOwn, color } (u.id)}
{#if isOwn || isVisible(u.x, u.y)}
{@const rs = unitRenderStates[u.id]}
{@const rx = rs?.renderX ?? u.x}
{@const ry = rs?.renderY ?? u.y}
{@const angle = rs?.angle ?? 0}
{@const isSelected = u.id === $selectedUnit?.id}
{@const isLargeUnit = u.unit_type === 'goliath' || u.unit_type === 'tank' || u.unit_type === 'wraith'}
{@const isTank = u.unit_type === 'tank'}
{@const spriteHalf = isTank ? 1.52 : isLargeUnit ? 0.76 : 0.38}
{@const spriteSize = isTank ? 3.04 : isLargeUnit ? 1.52 : 0.76}
<g transform="translate({rx},{ry})">
<!-- Selection ring -->
{#if isSelected}
<circle r="0.55" fill="none" stroke={color} stroke-width="0.1" opacity="0.7" />
{/if}
<!-- Shadow -->
<circle r="0.38" fill="rgba(0,0,0,0.35)" />
<!-- Health ring: black background + player color arc proportional to HP -->
<circle r="0.42" fill="none" stroke="black" stroke-width="0.1" />
<circle r="0.42" fill="none" stroke={color} stroke-width="0.1"
stroke-dasharray="{2 * Math.PI * 0.42 * (u.hp / u.max_hp)} {2 * Math.PI * 0.42 * (1 - u.hp / u.max_hp)}"
transform="rotate(-90)"
/>
<!-- Unit sprite, rotated toward movement direction -->
<image
href="{spriteBase}/units/{UNIT_SPRITE[u.unit_type]}"
x={-spriteHalf} y={-spriteHalf}
width={spriteSize} height={spriteSize}
opacity={u.is_cloaked ? 0.35 : 1}
preserveAspectRatio="xMidYMid meet"
transform="rotate({angle})"
/>
<!-- Siege mode indicator -->
{#if u.is_sieged}
<polygon points="0,-0.48 0.14,-0.28 -0.14,-0.28" fill="#ffa726" />
{/if}
<!-- Cloaked indicator -->
{#if u.is_cloaked}
<circle r="0.32" fill="none" stroke="#b39ddb" stroke-width="0.08" stroke-dasharray="0.1 0.08" />
{/if}
</g>
{/if}
{/each}
<!-- Walkable zone debug overlay (toggle with W key) -->
{#if showWalkable}
{#each walkablePolygons as pts}
<polygon
points={pts}
fill="rgba(0,255,100,0.18)"
stroke="rgba(0,255,100,0.7)"
stroke-width="0.12"
pointer-events="none"
/>
{/each}
{/if}
<!-- Nav-points debug overlay (toggle with N key) -->
{#if showNavPoints}
{#each navPoints as [nx, ny]}
<circle cx={nx} cy={ny} r="0.18" fill="rgba(255,200,0,0.8)" pointer-events="none" />
{/each}
{/if}
<!-- Fog of war: single black rect covering the viewport, with vision circles masked out.
getVisibleSources is called INLINE so Svelte's template renderer tracks gs/myId as
dependencies โ€” avoids the production-build $: scheduling issue (same pattern as the
original buildFogRects). exploredSources is populated via onGameStateUpdate. -->
{#if !isObserver}
{@const _visSrc = getVisibleSources(gs, myId)}
<defs>
<mask id="fog-explored-mask" maskUnits="userSpaceOnUse" x="0" y="0" width={MAP_W} height={MAP_H}>
<rect x="0" y="0" width={MAP_W} height={MAP_H} fill="white"/>
{#each exploredSources as s}
<circle cx={s.x} cy={s.y} r={s.r} fill="black"/>
{/each}
</mask>
<mask id="fog-visible-mask" maskUnits="userSpaceOnUse" x="0" y="0" width={MAP_W} height={MAP_H}>
<rect x="0" y="0" width={MAP_W} height={MAP_H} fill="white"/>
{#each _visSrc as s}
<circle cx={s.x} cy={s.y} r={s.r} fill="black"/>
{/each}
</mask>
</defs>
<!-- Unexplored fog (dark) -->
<rect x={vx} y={vy} width={vw} height={vh} fill="rgba(0,0,0,0.85)" mask="url(#fog-explored-mask)" pointer-events="none"/>
<!-- Explored but not visible (dim) -->
<rect x={vx} y={vy} width={vw} height={vh} fill="rgba(0,0,0,0.45)" mask="url(#fog-visible-mask)" pointer-events="none"/>
{/if}
<!-- Tutorial target beacon (rendered above fog so it's always visible) -->
{#if $isTutorial && $gameState?.tutorial_target_x != null && $gameState?.tutorial_target_y != null}
{@const bx = $gameState.tutorial_target_x}
{@const by = $gameState.tutorial_target_y}
<defs>
<filter id="beacon-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.5" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<g pointer-events="none">
<!-- Outer pulse ring 1 -->
<circle cx={bx} cy={by} r="2.2" fill="none" stroke="rgba(255,60,60,0.35)" stroke-width="0.18">
<animate attributeName="r" values="1.8;3.2;1.8" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0;0.5" dur="2s" repeatCount="indefinite" />
</circle>
<!-- Outer pulse ring 2 (offset phase) -->
<circle cx={bx} cy={by} r="2.8" fill="none" stroke="rgba(255,60,60,0.2)" stroke-width="0.12">
<animate attributeName="r" values="2.4;4.0;2.4" dur="2s" begin="1s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.4;0;0.4" dur="2s" begin="1s" repeatCount="indefinite" />
</circle>
<!-- Inner solid circle -->
<circle cx={bx} cy={by} r="0.7" fill="rgba(255,40,40,0.85)" filter="url(#beacon-glow)">
<animate attributeName="r" values="0.6;0.8;0.6" dur="1s" repeatCount="indefinite" />
</circle>
<!-- Cross-hair lines -->
<line x1={bx - 1.4} y1={by} x2={bx - 0.85} y2={by} stroke="rgba(255,80,80,0.75)" stroke-width="0.12" stroke-linecap="round" />
<line x1={bx + 0.85} y1={by} x2={bx + 1.4} y2={by} stroke="rgba(255,80,80,0.75)" stroke-width="0.12" stroke-linecap="round" />
<line x1={bx} y1={by - 1.4} x2={bx} y2={by - 0.85} stroke="rgba(255,80,80,0.75)" stroke-width="0.12" stroke-linecap="round" />
<line x1={bx} y1={by + 0.85} x2={bx} y2={by + 1.4} stroke="rgba(255,80,80,0.75)" stroke-width="0.12" stroke-linecap="round" />
<!-- Label -->
<text
x={bx} y={by - 1.7}
text-anchor="middle"
font-size="1.0"
font-family="'Courier New', monospace"
font-weight="700"
fill="#ff4444"
stroke="rgba(0,0,0,0.8)"
stroke-width="0.2"
paint-order="stroke"
filter="url(#beacon-glow)"
>OBJECTIVE</text>
</g>
{/if}
</svg>
<style>
.map-svg {
width: 100%;
height: 100%;
display: block;
cursor: grab;
touch-action: none;
}
.map-svg:active { cursor: grabbing; }
</style>