Spaces:
Running
Running
| <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> | |