Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
35 kB · 786 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787// The live game view: the bridge between the wire's authoritative ProjectedState and the imperative// Pixi renderer (render/*). React owns the connection and pushes each new ProjectedState in as a// prop; this component mounts Pixi exactly once, then on every state push reconciles the player set// onto depth-sorted entities that *glide* toward the server's positions (the server is authoritative// — input only sends intents, it never moves anything locally). Stations/debris are drawn as static// markers for legibility; animated map objects become cosmetic path-triggered auto-gates.//// Controls are mobile-first — every action has a touch affordance, keyboard is a desktop convenience// only (never required): tap to path (#move{vec}) · on-screen buttons for Open / Drop. Players are// camera-locked to their avatar (follow); only spectators (kicked / non-players, who have no other// interactions) get a free pan/zoom camera. Desktop extras: WASD/arrows step (#move{dir}).
import { useEffect, useRef } from 'react'import { Application, Container, Graphics, Sprite, Texture } from 'pixi.js'import { buildScene, createCamera, createEntityLayer, createGate, createNpcGroup, loadTexture, mulberry32, npcEntriesOf, npcWanderRadiusOf, objectAutoGateOf, rectOverlapsAny, walkSheetClips, type Camera, type Entity, type EntityLayer, type Facing, type Gate, type MapScene, type NpcGroup,} from '../../render/index'import { loadMapExport } from './map-import'import { loadSpriteClips } from './sprite-import'import { loadNpcAccount, buildNpc, weightedPick, bboxOf, scatterPoint, seedFromId } from './npc-import'import type { ProjectedState, StationInteraction } from './wire-client'import type { ResourceCatalog } from './resources-import'import { StationMenu } from './StationMenu'
type Option = StationInteraction['options'][number]type Rect = ProjectedState['stations'][number]['area']
const inArea = (p: { x: number; y: number }, a: Rect): boolean => p.x >= a.x && p.x <= a.x + a.w && p.y >= a.y && p.y <= a.y + a.h
type Clips = ReturnType<typeof walkSheetClips>
const FOLLOW_WIDTH = 560 // world px across the viewport in follow modeconst GATE_LOOKAHEAD = 110 // px of forward path that opens a gateconst MOVE_HZ_MS = 100 // resend cadence for held-direction movementconst FALLBACK_SPEED = 150 // px/sec when config.moveSpeedPxPerSec is missing// Sit the area markers on the floor: above the ground art (render sorts ground canvases at −1e9) but// below every object/player (which sort by world-coordinate depth, |coords| ≪ 1e8). So the boxes// read as decals painted on the ground, with players and props drawing over them.const MARKER_GROUND_Z = -1e8// The action timer floats above everything (incl. sky/roofs) so it's always visible.const ACTION_TIMER_Z = 2e9const ACTION_TIMER_R = 9 // ring radius (world px)const ACTION_TIMER_RISE = 60 // world px above the avatar's feet anchor// Debris indicators sit just under the action timer — above all objects/roofs — so a piece tucked// behind a building or tree still shows a ring on top. Pulses gently to draw the eye.const DEBRIS_RING_Z = 1.5e9const DEBRIS_RING_COLOR = 0xffc24d // warm amber, reads against grass and interiors alike// Footprint box (world px) an NPC tests against scene colliders so it doesn't wander into walls.// NPCs are non-colliding to players (PLAN §9) — this only keeps their own cosmetic motion off walls.const NPC_FOOT = 12// Trash art cycled across resource discards (debris); one is picked stably per debris id.const DEBRIS_SPRITES = [ '/crumpled-paper-1.png', '/crumpled-paper-2.png', '/rotting-food-1.png', '/rotting-food-2.png',]
/** Everything Pixi owns for one mounted game. Held in a ref so React re-renders never rebuild it. */interface Engine { app: Application scene: MapScene layer: EntityLayer /** decorative NPCs spawned from the map's markers (set dressing; PLAN §9). Shares `layer`'s pool; * driven each frame with player positions so NPCs yield to passers-by. Empty when none authored. */ npcGroup: NpcGroup camera: Camera /** walk clips per `actor.rpg.sprite` AT-URI, lazily loaded from each player's spriteRef. There is * no fallback sheet: a player is drawn only once their own sprite has loaded (players must have an * actor.rpg.sprite record to play — the lobby gates on it). */ spriteClips: Map<string, Clips> /** spriteRefs whose load is in flight (don't start a second). */ loadingSprites: Set<string> /** spriteRefs whose load failed — not retried this mount (the player stays undrawn). */ failedSprites: Set<string> /** re-run reconcile with the freshest state (e.g. after a sprite finishes loading). */ requestReconcile: () => void entities: Map<string, Entity> gates: Gate[] markers: Graphics /** radial countdown drawn above our avatar while we run a timed station action. */ actionTimer: Graphics /** high-z pulsing rings marking every debris position, visible even through occluding objects. */ debrisRings: Graphics /** the depth-sorted world container — debris sprites are added here so they interleave by depth. */ world: Container /** loaded trash textures (empty if they failed to load); indexed into per debris id. */ debrisTextures: Texture[] /** live debris sprites, keyed by debris id. */ debrisSprites: Map<string, Sprite> followBound: boolean}
/** Kick off a lazy load of a player's sprite clips (once), re-reconciling when it resolves so the * entity gets spawned with its art. A failed load is recorded and not retried this mount. */function ensureSprite(engine: Engine, ref: string): void { if (engine.spriteClips.has(ref) || engine.loadingSprites.has(ref) || engine.failedSprites.has(ref)) return engine.loadingSprites.add(ref) loadSpriteClips(ref) .then((clips) => engine.spriteClips.set(ref, clips)) .catch((err) => { console.warn('[GameView] sprite load failed; player will not be drawn:', ref, err) engine.failedSprites.add(ref) }) .finally(() => { engine.loadingSprites.delete(ref) engine.requestReconcile() })}
type Player = ProjectedState['players'][number]
const facingOf = (dx: number, dy: number): Facing => Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? 'west' : 'east') : dy < 0 ? 'north' : 'south'
export function GameView({ mapUri, state, self, sendIntent, catalog, menu, onCloseMenu, locale, interactive = true, paused = false,}: { mapUri: string state: ProjectedState self: string sendIntent: (t: string, body?: unknown) => void catalog: ResourceCatalog /** the open station menu (server reply to #interact), or null when none is open */ menu: StationInteraction | null onCloseMenu: () => void locale?: string /** false renders the world + movement only, omitting the deception station/drop HUD and its * keyboard shortcuts — used by deduction mode, which layers its own investigation HUD on top. */ interactive?: boolean /** Freeze the Pixi render loop while a full-screen overlay covers the world. The canvas is hidden * behind the modal anyway, and re-blurring a *live* WebGL surface under a `backdrop-blur` overlay * flickers on many GPUs — so we stop the ticker rather than burn frames the player can't see. */ paused?: boolean}) { const hostRef = useRef<HTMLDivElement>(null) const engineRef = useRef<Engine | null>(null) // latest props the Pixi loop / listeners read without re-mounting const stateRef = useRef(state) const selfRef = useRef(self) const sendRef = useRef(sendIntent) const pausedRef = useRef(paused) stateRef.current = state selfRef.current = self sendRef.current = sendIntent pausedRef.current = paused // Anchor server time to a local monotonic clock so the action countdown ticks smoothly between // network pushes; re-sync only when the server's `now` actually advances (not on every re-render). const clockRef = useRef({ serverNow: 0, perfAt: 0 }) if (clockRef.current.serverNow !== state.now) { clockRef.current = { serverNow: state.now, perfAt: performance.now() } }
// --- one-time mount (rebuild only if the map itself changes) --------------- useEffect(() => { let cancelled = false const cleanups: Array<() => void> = []
;(async () => { const app = new Application() await app.init({ background: '#1b1b1b', resizeTo: hostRef.current!, antialias: false, roundPixels: true }) if (cancelled || !hostRef.current) return void app.destroy(true, { children: true }) hostRef.current.appendChild(app.canvas)
const world = new Container() world.sortableChildren = true app.stage.addChild(world)
const { exp, atlasUrl } = await loadMapExport(mapUri) if (cancelled) return void app.destroy(true, { children: true }) const atlas = atlasUrl ? await loadTexture(atlasUrl) : null if (cancelled) return void app.destroy(true, { children: true }) const scene = buildScene(world, exp, atlas)
const layer = createEntityLayer(world, scene.depthAxis) const npcGroup = createNpcGroup(layer, world) // world doubles as the emote-bubble overlay const camera = createCamera(world)
// legibility markers for stations + debris (non-interactive this pass) const markers = new Graphics() markers.eventMode = 'none' markers.zIndex = MARKER_GROUND_Z // draw on the ground plane, beneath objects/players world.addChild(markers)
// radial action countdown, on top of everything const actionTimer = new Graphics() actionTimer.eventMode = 'none' actionTimer.zIndex = ACTION_TIMER_Z world.addChild(actionTimer)
// debris indicator rings, above objects/roofs so occluded debris still shows const debrisRings = new Graphics() debrisRings.eventMode = 'none' debrisRings.zIndex = DEBRIS_RING_Z world.addChild(debrisRings)
// trash textures for resource discards (best-effort — a miss just leaves that debris undrawn) const debrisTextures = ( await Promise.all(DEBRIS_SPRITES.map((u) => loadTexture(u).catch(() => null))) ).filter((t): t is Texture => t !== null) if (cancelled) return void (scene.destroy(), app.destroy(true, { children: true }))
// objects tagged `town.deception.autoGate` become cosmetic, path-triggered // auto-gates (animation is scrubbed across frames, so they want >1 frame) const gates = scene.objects .filter((o) => objectAutoGateOf(o.properties) && o.def.frames.length > 1) .map((o) => createGate(o, { span: 64, lookahead: GATE_LOOKAHEAD, sweepMs: 200 }))
const engine: Engine = { app, scene, layer, npcGroup, camera, spriteClips: new Map(), loadingSprites: new Set(), failedSprites: new Set(), requestReconcile: () => {}, entities: new Map(), gates, markers, actionTimer, debrisRings, world, debrisTextures, debrisSprites: new Map(), followBound: false, } // a sprite load resolving later re-reconciles against the freshest state/self engine.requestReconcile = () => reconcile(engine, stateRef.current, selfRef.current) engineRef.current = engine
reconcile(engine, stateRef.current, selfRef.current) // first pass with the freshest state
app.ticker.add((ticker) => { const polylines = [...engine.entities.values()].map((e) => extendForward([{ x: e.x, y: e.y }, ...e.path])) for (const gate of engine.gates) gate.update(ticker.deltaMS, polylines) engine.layer.update(ticker.deltaMS) // Advance decorative NPCs, dodging our live (interpolated) player positions. No-op when the // map authored none. NPCs share `layer` but aren't in `entities` (that's the wire's players). const npcPlayers = [...engine.entities.values()].map((e) => ({ x: e.x, y: e.y })) engine.npcGroup.update(ticker.deltaMS, npcPlayers) engine.camera.update(app.screen.width, app.screen.height) // Redraw area markers each frame against OUR avatar's live (interpolated) position, so they // brighten/glow smoothly as we approach — not in steps on each network push. const me = engine.entities.get(selfRef.current) drawMarkers(engine, stateRef.current, me ? { x: me.x, y: me.y } : null) // Radial countdown above our avatar while we're mid-action, on a smoothly-interpolated clock. const active = stateRef.current.players.find((p) => p.did === selfRef.current)?.active const estNow = clockRef.current.serverNow + (performance.now() - clockRef.current.perfAt) drawActionTimer(engine.actionTimer, me, active, estNow) // Debris indicator rings, redrawn each frame so the pulse animates (and tracks the debris set). drawDebrisRings(engine.debrisRings, stateRef.current.debris, performance.now()) })
installInput(app, engine, cleanups, (t, b) => sendRef.current(t, b))
// If we mounted while already covered by a full-screen overlay, start frozen (the [paused] // effect below resumes us when it lifts). Pixi keeps the last rendered frame on screen. if (pausedRef.current) app.ticker.stop()
// Decorative NPCs (set dressing; PLAN §9): each `town.deception.npc` value is an account DID // (+ optional weight). A point MARKER spawns a single NPC (weighted pick across its entries); an // AREA spawns a scattered crowd of sum(weights) NPCs in those proportions, seeded so every viewer // matches. Sprites + behaviour load directly from each account (cached per DID). Off the critical // path — fired without blocking the live game, guarded by `cancelled` against a torn-down scene. // NPCs avoid walls (own footprint vs scene colliders) but never block players — non-colliding. void (async () => { if (cancelled) return const blocked = (x: number, y: number): boolean => rectOverlapsAny({ x: x - NPC_FOOT / 2, y: y - NPC_FOOT / 2, w: NPC_FOOT, h: NPC_FOOT }, scene.colliders)
// Resolve every placement up front (DID + position + stable seed + leash bounds), then load & // spawn. Marker: one NPC at the point. Area: one NPC per unit of weight, scattered in its rects. const placements: { did: string; x: number; y: number; seed: number; area?: Rect; wanderOverride?: number }[] = [] for (const m of scene.markers) { const entries = npcEntriesOf(m.properties) if (entries.length === 0) continue const seed = seedFromId(m.node.id) const did = weightedPick(entries, seed) if (!did) continue const wanderOverride = npcWanderRadiusOf(m.properties) placements.push({ did, x: m.x, y: m.y, seed, ...(wanderOverride !== undefined ? { wanderOverride } : {}) }) } for (const a of scene.areas) { const entries = npcEntriesOf(a.properties) const area = bboxOf(a.rects) if (entries.length === 0 || !area) continue let i = 0 for (const { did, weight } of entries) { for (let w = 0; w < weight; w++) { const seed = seedFromId(`${a.node.id}#${i++}`) const pos = scatterPoint(a.rects, mulberry32(seed)) if (pos) placements.push({ did, x: pos.x, y: pos.y, seed, area }) } } }
for (const p of placements) { const loaded = await loadNpcAccount(p.did).catch((err) => { console.warn('[npc] account load failed; skipping placement:', p.did, err) return null }) if (cancelled) return if (!loaded) continue const built = buildNpc(loaded, { x: p.x, y: p.y, seed: p.seed, cellH: scene.cell.h, ...(p.area ? { area: p.area } : {}), ...(p.wanderOverride !== undefined ? { wanderOverride: p.wanderOverride } : {}), }) npcGroup.spawn({ def: built.def, x: p.x, y: p.y, rng: built.rng, blocked }) } })().catch((err) => console.error('[GameView] NPC spawn failed', err)) })().catch((err) => console.error('[GameView] init failed', err))
return () => { cancelled = true for (const off of cleanups) off() const e = engineRef.current if (e) { e.npcGroup.destroy() // destroy NPC entities first; layer.destroy then frees the players e.scene.destroy() e.layer.destroy() e.app.destroy(true, { children: true }) } engineRef.current = null } }, [mapUri])
// --- reconcile on every state push (no Pixi teardown) ---------------------- useEffect(() => { const e = engineRef.current if (!e) return // state arrived before the map finished loading; the mount pass handles it reconcile(e, state, self) }, [state, self])
// --- freeze/thaw the render loop when a full-screen overlay covers the world --- // Stopping the ticker halts the per-frame redraw, so a `backdrop-blur` modal over the canvas isn't // re-blurring a moving WebGL surface every frame (the source of the flashing). Resumes cleanly: // entities glide to the latest authoritative state once the ticker restarts. useEffect(() => { const e = engineRef.current if (!e) return // not mounted yet; the mount pass honours pausedRef if (paused) e.app.ticker.stop() else e.app.ticker.start() }, [paused])
// The station the self player can open right now: in-round, alive, idle, and standing in its // footprint. Goes null the moment an action starts (active set) — which also closes any open menu. const me = state.players.find((p) => p.did === self) const here = me && state.phase === 'round' && me.status === 'alive' && !me.active ? (state.stations.find((s) => inArea(me.pos, s.area)) ?? null) : null
// Close a stale menu when the player walks off the station it was opened for (or it closed above). useEffect(() => { if (menu && here?.id !== menu.stationId) onCloseMenu() }, [menu, here, onCloseMenu])
// Keyboard is a desktop convenience only (mobile uses the on-screen buttons): E opens the station // you're in, G drops your carried item, Escape closes an open menu. const carrying = !!me?.carrying useEffect(() => { if (!interactive) return const onKey = (ev: KeyboardEvent) => { const k = ev.key.toLowerCase() if (k === 'escape' && menu) onCloseMenu() else if (k === 'e' && here && !menu) sendIntent('#interact', { stationId: here.id }) else if (k === 'g' && carrying && !menu) sendIntent('#drop') } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [here, menu, carrying, onCloseMenu, sendIntent, interactive])
const choose = (opt: Option) => { if (opt.kind === 'doNothing') return onCloseMenu() // every real option is a timed station activity now (deposit / fakeDeposit / callVote included), // routed through chooseAction; `discardFirst` rides as `discard` so the server drops-then-acts. if (menu) { sendIntent('#chooseAction', { stationId: menu.stationId, actionType: opt.kind, ...(opt.resourceId ? { resourceId: opt.resourceId } : {}), ...(opt.discardFirst ? { discard: true } : {}), }) } onCloseMenu() }
const stationLabel = (s: NonNullable<typeof here>): string => { const keys = [...new Set([...s.collect, ...s.deposit])] if (keys.length) return keys.map((k) => catalog.name(k, locale)).join(' / ') if (s.scoreboard.length) return 'Scoreboard' if (s.meeting) return 'Meeting' return 'Station' } const hereName = here ? stationLabel(here) : ''
return ( <> <div ref={hostRef} className="absolute inset-0" /> <div className={`pointer-events-none absolute inset-0 ${interactive ? '' : 'hidden'}`}> {here && !menu && ( <button onClick={() => sendIntent('#interact', { stationId: here.id })} className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full border border-zinc-600 bg-zinc-900/90 px-4 py-1.5 text-sm font-medium text-zinc-100 shadow-lg backdrop-blur hover:bg-zinc-800" > Open {hereName} <span className="ml-1 text-xs text-zinc-500">(E)</span> </button> )} {carrying && !menu && ( <button onClick={() => sendIntent('#drop')} className="pointer-events-auto absolute bottom-4 right-4 rounded-full border border-zinc-600 bg-zinc-900/90 px-4 py-1.5 text-sm font-medium text-zinc-100 shadow-lg backdrop-blur hover:bg-zinc-800" > Drop {me?.carrying ? catalog.name(me.carrying.resourceId, locale) : ''}{' '} <span className="ml-1 text-xs text-zinc-500">(G)</span> </button> )} {menu && ( <StationMenu menu={menu} state={state} catalog={catalog} locale={locale} onChoose={choose} onClose={onCloseMenu} /> )} </div> </> )}
/** Spawn/update/despawn entities to match the wire's player set, easing each toward its server pos. */function reconcile(engine: Engine, state: ProjectedState, self: string) { const { layer, camera, scene, entities } = engine const speed = state.config.moveSpeedPxPerSec || FALLBACK_SPEED const seen = new Set<string>()
for (const p of state.players) { if (p.status === 'kicked') continue // treated as absent → despawned below seen.add(p.did) let ent = entities.get(p.did)
if (!ent) { // Each player wears their own actor.rpg.sprite (the wire's per-player spriteRef). There is no // fallback sheet — players must have a sprite record to play (the lobby gates on it) — so we // spawn only once their clips have loaded; until then they're simply not drawn. A player with // no spriteRef, or whose sprite failed to load, is never spawned. const ref = p.spriteRef if (!ref) continue const clips = engine.spriteClips.get(ref) if (!clips) { ensureSprite(engine, ref) // load in flight → re-reconcile spawns them when ready continue } ent = layer.spawn({ clips, clip: `idle-${p.facing}`, x: p.pos.x, y: p.pos.y, glideSpeed: speed }) ent.setPosition(p.pos.x, p.pos.y) // snap on first appearance, then glide thereafter entities.set(p.did, ent) if (p.did === self && !engine.followBound) { camera.follow(ent, { worldWidth: FOLLOW_WIDTH, clampTo: scene.bounds }) engine.followBound = true } }
// drive position from the authoritative sample: a stub path (self=full, others=1–2 waypoints) // interpolates smoothly; otherwise ease straight to the latest pos. if (p.path.length > 0) { ent.followPath(p.path) } else if (Math.hypot(p.pos.x - ent.x, p.pos.y - ent.y) > 0.5) { ent.glideTo(p.pos.x, p.pos.y) }
ent.play(`${ent.gliding ? 'walk' : 'idle'}-${p.facing}`) ent.sprite.alpha = p.status === 'disconnected' ? 0.4 : 1 }
for (const [did, ent] of entities) { if (!seen.has(did)) { ent.destroy() entities.delete(did) } }
// Camera follows your own avatar while you're a live player; a spectator (kicked or a non-player // viewer — no live avatar, no interactions) gets a free pan/zoom camera. We only switch to free // ONCE on becoming a spectator (the mode guard), so it never fights their panning each push. const selfAlive = state.players.some((p) => p.did === self && p.status === 'alive') if (!selfAlive && camera.mode !== 'free') { camera.free() engine.followBound = false } reconcileDebris(engine, state) // debris is static — update the sprite set on each push, not per frame // markers are redrawn per-frame from the ticker (proximity-reactive), not here.}
const STATION_COLOR = 0x4f8fffconst MARKER_NEAR = 28 // world px: at/inside this distance the area is fully "obvious"const MARKER_FAR = 240 // world px: beyond this it's just the soft resting borderconst MARKER_CORNER = 12 // rounded-corner radius (world px)
/** Distance from a point to the nearest edge of a rect (0 when inside). */function distToRect(p: { x: number; y: number }, a: Rect): number { const dx = Math.max(a.x - p.x, 0, p.x - (a.x + a.w)) const dy = Math.max(a.y - p.y, 0, p.y - (a.y + a.h)) return Math.hypot(dx, dy)}
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v)/** Smoothstep ease — gentle in/out so the approach glow ramps naturally. */const smooth = (t: number): number => t * t * (3 - 2 * t)
/** * Draw each station's area as a rounded, softly-glowing border that intensifies as OUR avatar * (`selfPos`, null while we have no live avatar) nears it — distant areas rest at a faint outline, * near ones brighten with a wider side-glow. Other players' positions never affect it. Debris stays * a simple dot. Called every frame, so `proximity` tracks the interpolated avatar smoothly. */function drawMarkers(engine: Engine, state: ProjectedState, selfPos: { x: number; y: number } | null) { const g = engine.markers g.clear() for (const s of state.stations) { const a = s.area // 0 when far (resting) → 1 when within MARKER_NEAR (fully obvious) const prox = selfPos ? smooth(clamp01((MARKER_FAR - distToRect(selfPos, a)) / (MARKER_FAR - MARKER_NEAR))) : 0 const radius = Math.min(MARKER_CORNER, a.w / 2, a.h / 2) // Outer two strokes are the soft side-glow (wide, low alpha); the inner one is the crisp edge. // Each alpha lerps from a faint resting floor up to its near-field peak by `prox`. g.roundRect(a.x, a.y, a.w, a.h, radius).stroke({ color: STATION_COLOR, width: 9, alpha: 0.03 + 0.15 * prox }) g.roundRect(a.x, a.y, a.w, a.h, radius).stroke({ color: STATION_COLOR, width: 4, alpha: 0.05 + 0.22 * prox }) g.roundRect(a.x, a.y, a.w, a.h, radius).stroke({ color: STATION_COLOR, width: 1.25, alpha: 0.14 + 0.66 * prox }) }}
/** Stable index into the trash textures from a debris id, so each discard keeps the same art for its * lifetime. Deliberately keyed on `id` only — NOT `authentic` (real/fake is hidden until Inspected, * so the sprite must never leak it). */function debrisTextureIndex(id: string, n: number): number { let h = 0 for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0 return h % n}
/** Spawn/position/despawn trash sprites to match the wire's debris (resource discards). Debris is * static, so this runs on state pushes (not per frame); each sprite depth-sorts into the world by * its position so players occlude it correctly. */function reconcileDebris(engine: Engine, state: ProjectedState) { const { debrisSprites, debrisTextures, world, scene } = engine if (debrisTextures.length === 0) return const seen = new Set<string>() for (const d of state.debris) { seen.add(d.id) if (!debrisSprites.has(d.id)) { const spr = new Sprite(debrisTextures[debrisTextureIndex(d.id, debrisTextures.length)]!) spr.eventMode = 'none' spr.anchor.set(0.5, 0.7) // sit roughly on the drop point spr.position.set(d.pos.x, d.pos.y) spr.zIndex = scene.depthOf(d.pos.x, d.pos.y) - 1 // just behind a player standing on the same spot world.addChild(spr) debrisSprites.set(d.id, spr) } } for (const [id, spr] of debrisSprites) { if (!seen.has(id)) { world.removeChild(spr) spr.destroy() debrisSprites.delete(id) } }}
/** * Draw a pulsing locator ring on every debris position, on a high-z layer so a piece tucked behind a * building, tree, or roof still shows its ring on top. Redrawn each frame for the pulse; the ring is a * legibility aid only (it never reveals real-vs-fake — keyed purely on position). */function drawDebrisRings(g: Graphics, debris: ProjectedState['debris'], now: number) { g.clear() // 0→1→0 once per ~1.4s, eased so it breathes rather than blinks. const phase = (now % 1400) / 1400 const pulse = 0.5 - 0.5 * Math.cos(phase * Math.PI * 2) for (const d of debris) { const r = 8 + pulse * 5 // outer breathing halo, then a crisp inner ring + centre dot pinning the exact spot g.circle(d.pos.x, d.pos.y, r + 3).stroke({ color: DEBRIS_RING_COLOR, width: 2, alpha: 0.12 + 0.18 * (1 - pulse) }) g.circle(d.pos.x, d.pos.y, r).stroke({ color: DEBRIS_RING_COLOR, width: 1.5, alpha: 0.55 }) g.circle(d.pos.x, d.pos.y, 2).fill({ color: DEBRIS_RING_COLOR, alpha: 0.9 }) }}
/** * Draw a radial countdown ring above the avatar while a timed action runs: a faint full track with a * coloured arc that starts full and *empties* clockwise from the top as the action's `endsAt` * approaches. Cleared (nothing drawn) when idle, the entity is absent, or the action has elapsed. */function drawActionTimer(g: Graphics, entity: Entity | undefined, active: Player['active'], now: number) { g.clear() if (!entity || !active) return const dur = active.endsAt - active.startedAt if (dur <= 0) return const remaining = clamp01((active.endsAt - now) / dur) if (remaining <= 0) return const cx = entity.x const cy = entity.y - ACTION_TIMER_RISE const top = -Math.PI / 2 g.circle(cx, cy, ACTION_TIMER_R).stroke({ color: 0xffffff, width: 2, alpha: 0.18 }) // moveTo the arc's start first, else Pixi draws a connector line from the pen's last position // (the origin) — a stray streak toward the top-left of the world. g.moveTo(cx + ACTION_TIMER_R * Math.cos(top), cy + ACTION_TIMER_R * Math.sin(top)) g.arc(cx, cy, ACTION_TIMER_R, top, top + remaining * Math.PI * 2) g.stroke({ color: STATION_COLOR, width: 2.5, alpha: 0.95 })}
/** Extend the final segment of a forward polyline by GATE_LOOKAHEAD so a remote player's short wire * stub still reaches a gate's doorway — without the wire ever revealing more of their route. */function extendForward(poly: { x: number; y: number }[]): { x: number; y: number }[] { if (poly.length < 2) return poly const a = poly[poly.length - 2]! const b = poly[poly.length - 1]! const len = Math.hypot(b.x - a.x, b.y - a.y) if (len < 1e-3) return poly return [...poly, { x: b.x + ((b.x - a.x) / len) * GATE_LOOKAHEAD, y: b.y + ((b.y - a.y) / len) * GATE_LOOKAHEAD }]}
/** Wire keyboard/pointer input to upstream intents. Never moves entities locally — the server is * authoritative and its resulting state updates drive movement. */function installInput( app: Application, engine: Engine, cleanups: Array<() => void>, send: (t: string, body?: unknown) => void,) { const keys = new Set<string>() let lastDir: Facing | null = null let timer: ReturnType<typeof setInterval> | null = null
const dirFromKeys = (): Facing | null => { let dx = 0 let dy = 0 if (keys.has('a') || keys.has('arrowleft')) dx -= 1 if (keys.has('d') || keys.has('arrowright')) dx += 1 if (keys.has('w') || keys.has('arrowup')) dy -= 1 if (keys.has('s') || keys.has('arrowdown')) dy += 1 return dx === 0 && dy === 0 ? null : facingOf(dx, dy) }
const stopTimer = () => { if (timer) clearInterval(timer) timer = null lastDir = null }
const onKeyDown = (e: KeyboardEvent) => { const k = e.key.toLowerCase() if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(k)) e.preventDefault() // 'v' (camera toggle) is handled in the React layer so the on-screen button stays in sync keys.add(k) const dir = dirFromKeys() if (dir && dir !== lastDir) { lastDir = dir send('#move', { dir }) } if (dir && !timer) timer = setInterval(() => lastDir && send('#move', { dir: lastDir }), MOVE_HZ_MS) }
const onKeyUp = (e: KeyboardEvent) => { keys.delete(e.key.toLowerCase()) const dir = dirFromKeys() if (!dir) stopTimer() else if (dir !== lastDir) { lastDir = dir send('#move', { dir }) } }
// click-to-move (follow mode only): convert to a world point and let the server pathfind const onClick = (e: MouseEvent) => { if (engine.camera.mode !== 'follow') return const r = app.canvas.getBoundingClientRect() const w = engine.camera.worldFromScreen(e.clientX - r.left, e.clientY - r.top) send('#move', { vec: { x: Math.round(w.x), y: Math.round(w.y) } }) }
// Pan + zoom by pointer. A single dragging finger/mouse pans; two fingers pinch-zoom (touch). A // quick tap doesn't drag, so its synthesized `click` still drives tap-to-move (handled above). // touch-action:none lets the canvas own multi-touch instead of the browser scrolling/zooming. app.canvas.style.touchAction = 'none' const pointers = new Map<number, { x: number; y: number }>() let lx = 0 let ly = 0 let pinchDist = 0 const spread = () => { const [a, b] = [...pointers.values()] return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 } const onPointerDown = (e: PointerEvent) => { pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) if (pointers.size === 2) pinchDist = spread() else { lx = e.clientX ly = e.clientY } } const onPointerUp = (e: PointerEvent) => { pointers.delete(e.pointerId) const rest = [...pointers.values()][0] if (rest) { lx = rest.x // resume panning from the finger that's still down (no jump) ly = rest.y } } const onPointerMove = (e: PointerEvent) => { if (!pointers.has(e.pointerId)) return // only pointers that went down on the canvas pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) if (pointers.size >= 2) { const d = spread() const [a, b] = [...pointers.values()] if (a && b && pinchDist > 0 && d > 0) { engine.camera.zoomAtScreen((a.x + b.x) / 2, (a.y + b.y) / 2, d / pinchDist, app.screen.width, app.screen.height) } pinchDist = d } else { engine.camera.panByScreen(e.clientX - lx, e.clientY - ly) lx = e.clientX ly = e.clientY } } const onWheel = (e: WheelEvent) => { e.preventDefault() engine.camera.zoomAtScreen(e.clientX, e.clientY, e.deltaY < 0 ? 1.1 : 1 / 1.1, app.screen.width, app.screen.height) }
window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) window.addEventListener('pointerup', onPointerUp) window.addEventListener('pointercancel', onPointerUp) window.addEventListener('pointermove', onPointerMove) app.canvas.addEventListener('pointerdown', onPointerDown) app.canvas.addEventListener('click', onClick) app.canvas.addEventListener('wheel', onWheel, { passive: false })
cleanups.push(() => { stopTimer() window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointercancel', onPointerUp) window.removeEventListener('pointermove', onPointerMove) app.canvas.removeEventListener('pointerdown', onPointerDown) app.canvas.removeEventListener('click', onClick) app.canvas.removeEventListener('wheel', onWheel) })}