From 22d8358a8dabd88e97e653f27040bf63e77606f0 Mon Sep 17 00:00:00 2001 From: adamd Date: Sun, 26 Jul 2026 07:19:46 +0200 Subject: [PATCH 1/5] feat: power sources for electrifying cursors --- backend/src/rooms/MainRoom.ts | 27 +++++++++++++++++- frontend/src/gameplay/electric.ts | 47 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 frontend/src/gameplay/electric.ts diff --git a/backend/src/rooms/MainRoom.ts b/backend/src/rooms/MainRoom.ts index 6b7994e..e71d3a1 100644 --- a/backend/src/rooms/MainRoom.ts +++ b/backend/src/rooms/MainRoom.ts @@ -11,6 +11,8 @@ import { COLOR_STATIONS, DOOR_IDS, DOORS, + ELECTRIC_LINK_RANGE, + ELECTRIC_SOURCE, PLATES, PLAYER_RADIUS, SPAWN_POINT, @@ -201,14 +203,37 @@ export class MainRoom extends Room { const occupants: PlateOccupant[] = []; for (const player of this.state.players.values()) { if (this.playerOverlaps(player, definition)) { - occupants.push({ entityKind: "player", color: player.color }); + occupants.push({ entityKind: "player", color: player.color, charged: player.charged }); } } occupants.push(...this.world.occupantsInRect(definition)); return occupants; } + private updateElectricity() { + const players = [...this.state.players.values()]; + const charged = new Set(); + for (const player of players) { + if (this.playerOverlaps(player, ELECTRIC_SOURCE)) charged.add(player); + } + for (let grew = true; grew; ) { + grew = false; + for (const a of players) { + if (!charged.has(a)) continue; + for (const b of players) { + if (charged.has(b) || a === b) continue; + if (Math.hypot(a.x - b.x, a.y - b.y) <= ELECTRIC_LINK_RANGE) { + charged.add(b); + grew = true; + } + } + } + } + for (const player of players) player.charged = charged.has(player); + } + private updateGameplay() { + this.updateElectricity(); const now = Date.now(); const totalPlayers = this.state.players.size; diff --git a/frontend/src/gameplay/electric.ts b/frontend/src/gameplay/electric.ts new file mode 100644 index 0000000..a20a77e --- /dev/null +++ b/frontend/src/gameplay/electric.ts @@ -0,0 +1,47 @@ +import Phaser from "phaser"; +import type { Rect } from "@shared"; +import { ACCENT_SPARK, INK } from "./palette"; + +export function buildElectricSource(scene: Phaser.Scene, source: Rect) { + const cx = source.x + source.width / 2; + const cy = source.y + source.height / 2; + const dot = scene.add + .circle(cx, cy, Math.min(source.width, source.height) / 2, ACCENT_SPARK, 0.85) + .setStrokeStyle(4, INK, 1) + .setDepth(1); + scene.tweens.add({ + targets: dot, + alpha: 0.35, + duration: 260, + yoyo: true, + repeat: -1, + ease: "Sine.easeInOut", + }); +} + +export function setElectrified( + scene: Phaser.Scene, + body: Phaser.GameObjects.Image, + ink: Phaser.GameObjects.Image, + baseColor: number, + charged: boolean, +) { + if (charged === (body.getData("charged") ?? false)) return; + body.setData("charged", charged); + scene.tweens.killTweensOf([body, ink]); + if (!charged) { + body.setScale(1).setTint(baseColor); + ink.setScale(1); + return; + } + scene.tweens.add({ + targets: [body, ink], + scaleX: 1.15, + scaleY: 0.88, + duration: 60 + Math.random() * 40, + yoyo: true, + repeat: -1, + ease: "Sine.easeInOut", + onUpdate: () => body.setTint(Math.random() < 0.5 ? ACCENT_SPARK : baseColor), + }); +} From a1843e719a8cfa25410a69e7826bf96e98b916f6 Mon Sep 17 00:00:00 2001 From: adamd Date: Sun, 26 Jul 2026 07:20:09 +0200 Subject: [PATCH 2/5] feat: 'charged' flag foe players --- backend/src/rooms/schema/GeneralSchemas.ts | 1 + frontend/src/gameplay/palette.ts | 4 ++++ frontend/src/gameplay/renderPlayers.ts | 4 +++- frontend/src/network/state.ts | 1 + frontend/src/scenes/GameScene.ts | 10 ++++++++++ shared/src/plates.ts | 2 ++ 6 files changed, 21 insertions(+), 1 deletion(-) diff --git a/backend/src/rooms/schema/GeneralSchemas.ts b/backend/src/rooms/schema/GeneralSchemas.ts index 2aeb766..ea9c8c4 100644 --- a/backend/src/rooms/schema/GeneralSchemas.ts +++ b/backend/src/rooms/schema/GeneralSchemas.ts @@ -5,6 +5,7 @@ export class Player extends Schema { @type("string") color = "#4ade80"; @type("number") x = 1600; @type("number") y = 1200; + @type("boolean") charged = false; } export class Door extends Schema { diff --git a/frontend/src/gameplay/palette.ts b/frontend/src/gameplay/palette.ts index c8a7568..34915f8 100644 --- a/frontend/src/gameplay/palette.ts +++ b/frontend/src/gameplay/palette.ts @@ -35,4 +35,8 @@ export const ACCENT_GOAL_CSS = "#f2c14e"; export const ACCENT_DANGER = 0xc4553f; export const ACCENT_DANGER_CSS = "#c4553f"; +/** the electric source, and the flicker on a charged cursor */ +export const ACCENT_SPARK = 0xfff59d; +export const ACCENT_SPARK_CSS = "#fff59d"; + export const FONT_HAND = '"Patrick Hand", "Comic Sans MS", cursive'; diff --git a/frontend/src/gameplay/renderPlayers.ts b/frontend/src/gameplay/renderPlayers.ts index 1a44308..a39dcaf 100644 --- a/frontend/src/gameplay/renderPlayers.ts +++ b/frontend/src/gameplay/renderPlayers.ts @@ -1,6 +1,7 @@ import Phaser from "phaser"; import type { RoomState } from "../network/state"; import { parseColor } from "./color"; +import { setElectrified } from "./electric"; interface RemotePlayer { cursor: Phaser.GameObjects.Image; @@ -49,7 +50,8 @@ export class RenderPlayers { } remote.target.set(player.x, player.y); remote.color = color; - remote.cursor.setTint(color); + if (!player.charged) remote.cursor.setTint(color); + setElectrified(this.scene, remote.cursor, remote.ink, color, player.charged); }); for (const [sessionId, remote] of this.players) { diff --git a/frontend/src/network/state.ts b/frontend/src/network/state.ts index df07a76..fea102b 100644 --- a/frontend/src/network/state.ts +++ b/frontend/src/network/state.ts @@ -5,6 +5,7 @@ export interface Player { color: string; x: number; y: number; + charged: boolean; } export interface DoorState { diff --git a/frontend/src/scenes/GameScene.ts b/frontend/src/scenes/GameScene.ts index 7b105d3..d646c3a 100644 --- a/frontend/src/scenes/GameScene.ts +++ b/frontend/src/scenes/GameScene.ts @@ -4,6 +4,7 @@ import { BUTTON, COLOR_STATIONS, CHEESE, + ELECTRIC_SOURCE, END_CREDITS_GITHUB, GITHUB_URL, LAVA_ZONES, @@ -42,6 +43,7 @@ import { import type { MovingWallRuntime } from "../gameplay/lava"; import { SLIDE_DEFAULT_SPEED, SLIDE_MAX_MS, buildSlides, findSlide } from "../gameplay/slide"; import { buildWorldTexts } from "../gameplay/worldText"; +import { buildElectricSource, setElectrified } from "../gameplay/electric"; import { FONT_HAND, INK_SOFT_CSS } from "../gameplay/palette"; import { RenderPlayers } from "../gameplay/renderPlayers"; import { SprayLayer } from "../gameplay/spray"; @@ -113,6 +115,7 @@ export class GameScene extends Phaser.Scene { this.movingWalls = buildMovingLavaWalls(this, MOVING_LAVA_WALLS); buildSlides(this, 1); buildWorldTexts(this, WORLD_TEXTS); + buildElectricSource(this, ELECTRIC_SOURCE); this.button = buildButton(this); this.renderPlayers = new RenderPlayers(this); @@ -445,6 +448,13 @@ export class GameScene extends Phaser.Scene { private syncState(state: RoomState, localSessionId: string) { this.renderPlayers.sync(state, localSessionId); + setElectrified( + this, + this.cursor, + this.cursorOutline, + this.localColor, + state.players.get(localSessionId)?.charged ?? false, + ); state.doors.forEach((door, id) => { const runtime = this.doors.find(({ def }) => def.id === id); diff --git a/shared/src/plates.ts b/shared/src/plates.ts index dd6b4df..7b0993d 100644 --- a/shared/src/plates.ts +++ b/shared/src/plates.ts @@ -4,12 +4,14 @@ export interface PlateOccupant { /** "player" or an ENTITY_KINDS key */ entityKind: string; color: string; + charged?: boolean; } export function occupantMatchesFilter(occupant: PlateOccupant, filter?: PlateFilter): boolean { if (!filter) return true; if (filter.entityKind && occupant.entityKind !== filter.entityKind) return false; if (filter.color && occupant.color.toLowerCase() !== filter.color.toLowerCase()) return false; + if (filter.charged && !occupant.charged) return false; return true; } From cc2800fcb529d02093fe7065f12526c1c2f036d8 Mon Sep 17 00:00:00 2001 From: adamd Date: Sun, 26 Jul 2026 07:20:23 +0200 Subject: [PATCH 3/5] feat: power source on the map --- shared/src/level.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/shared/src/level.ts b/shared/src/level.ts index 6a9e5c9..52abbb9 100644 --- a/shared/src/level.ts +++ b/shared/src/level.ts @@ -25,6 +25,8 @@ export interface PlateFilter { entityKind?: string; /** occupant's color must match (case-insensitive) */ color?: string; + /** occupant must be carrying an electric charge */ + charged?: boolean; } export type PlateCountRule = @@ -287,7 +289,7 @@ export const DOORS: Door[] = [ width: T, height: ROOM_GAP_HEIGHT, id: "practice", - plateGroups: [["plate-practice-1", "plate-practice-2"]], + plateGroups: [["plate-practice-1", "plate-practice-2"], ["plate-electric-target"]], }, ]; @@ -390,11 +392,24 @@ export const PLATES: PressurePlate[] = [ doorIds: ["practice"], count: { mode: "atLeast", value: 1 }, }, + { + id: "plate-electric-target", + x: 1150, + y: 9420, + width: 100, + height: 100, + doorIds: ["practice"], + filter: { entityKind: "player", charged: true }, + }, ]; export const TRASH_PLATE_ID = "plate-trash"; export const TRASH_DOOR_ID = "2"; +export const ELECTRIC_SOURCE: Rect = { x: 650, y: 9420, width: 90, height: 90 }; +/** how close two players' centers must be to conduct a charge between them */ +export const ELECTRIC_LINK_RANGE = 70; + export const DOOR_RECTS: Record = Object.fromEntries( DOORS.map((door) => [door.id, door]), ); From 627d07fb43b4c000469fe121aa75b5f3805df5d6 Mon Sep 17 00:00:00 2001 From: adamd Date: Sun, 26 Jul 2026 07:28:43 +0200 Subject: [PATCH 4/5] feat: particle system for power indication --- backend/src/rooms/MainRoom.ts | 2 +- frontend/src/gameplay/electric.ts | 63 ++++++++++++++------------ frontend/src/gameplay/palette.ts | 10 +--- frontend/src/gameplay/renderPlayers.ts | 4 +- frontend/src/scenes/GameScene.ts | 8 +--- 5 files changed, 39 insertions(+), 48 deletions(-) diff --git a/backend/src/rooms/MainRoom.ts b/backend/src/rooms/MainRoom.ts index e71d3a1..df2c22e 100644 --- a/backend/src/rooms/MainRoom.ts +++ b/backend/src/rooms/MainRoom.ts @@ -216,7 +216,7 @@ export class MainRoom extends Room { for (const player of players) { if (this.playerOverlaps(player, ELECTRIC_SOURCE)) charged.add(player); } - for (let grew = true; grew; ) { + for (let grew = true; grew;) { grew = false; for (const a of players) { if (!charged.has(a)) continue; diff --git a/frontend/src/gameplay/electric.ts b/frontend/src/gameplay/electric.ts index a20a77e..8b0ea6b 100644 --- a/frontend/src/gameplay/electric.ts +++ b/frontend/src/gameplay/electric.ts @@ -1,47 +1,50 @@ import Phaser from "phaser"; import type { Rect } from "@shared"; -import { ACCENT_SPARK, INK } from "./palette"; +import { ACCENT_SPARK } from "./palette"; -export function buildElectricSource(scene: Phaser.Scene, source: Rect) { - const cx = source.x + source.width / 2; - const cy = source.y + source.height / 2; - const dot = scene.add - .circle(cx, cy, Math.min(source.width, source.height) / 2, ACCENT_SPARK, 0.85) - .setStrokeStyle(4, INK, 1) - .setDepth(1); - scene.tweens.add({ - targets: dot, - alpha: 0.35, - duration: 260, - yoyo: true, - repeat: -1, - ease: "Sine.easeInOut", +function sparkTexture(scene: Phaser.Scene) { + if (!scene.textures.exists("sparkDot")) { + scene.add + .graphics() + .fillStyle(0xffffff) + .fillCircle(2, 2, 2) + .generateTexture("sparkDot", 4, 4) + .destroy(); + } + return "sparkDot"; +} + +function sparkEmitter(scene: Phaser.Scene, x: number, y: number) { + return scene.add.particles(x, y, sparkTexture(scene), { + tint: ACCENT_SPARK, + blendMode: "ADD", + speed: { min: 30, max: 90 }, + lifespan: { min: 150, max: 320 }, + scale: { start: 1.6, end: 0 }, + quantity: 1, + frequency: 25, }); } +export function buildElectricSource(scene: Phaser.Scene, source: Rect) { + sparkEmitter(scene, source.x + source.width / 2, source.y + source.height / 2).setDepth(1); +} + export function setElectrified( scene: Phaser.Scene, body: Phaser.GameObjects.Image, - ink: Phaser.GameObjects.Image, - baseColor: number, charged: boolean, ) { if (charged === (body.getData("charged") ?? false)) return; body.setData("charged", charged); - scene.tweens.killTweensOf([body, ink]); + let emitter = body.getData("sparks") as Phaser.GameObjects.Particles.ParticleEmitter | undefined; if (!charged) { - body.setScale(1).setTint(baseColor); - ink.setScale(1); + emitter?.stop(); return; } - scene.tweens.add({ - targets: [body, ink], - scaleX: 1.15, - scaleY: 0.88, - duration: 60 + Math.random() * 40, - yoyo: true, - repeat: -1, - ease: "Sine.easeInOut", - onUpdate: () => body.setTint(Math.random() < 0.5 ? ACCENT_SPARK : baseColor), - }); + if (!emitter) { + emitter = sparkEmitter(scene, body.x, body.y).setDepth(9).startFollow(body); + body.setData("sparks", emitter); + } + emitter.start(); } diff --git a/frontend/src/gameplay/palette.ts b/frontend/src/gameplay/palette.ts index 34915f8..28ae15e 100644 --- a/frontend/src/gameplay/palette.ts +++ b/frontend/src/gameplay/palette.ts @@ -15,28 +15,22 @@ export const PAPER_CSS = "#f4efe3"; export const INK = 0x1a1a1a; export const INK_CSS = "#1a1a1a"; -/** pencil-weight ink for secondary text and hints */ export const INK_SOFT = 0x6b6459; export const INK_SOFT_CSS = "#6b6459"; -/** highlighter under a plate that is satisfied */ export const ACCENT_ACTIVE = 0x5bbf6a; export const ACCENT_ACTIVE_CSS = "#5bbf6a"; -/** highlighter under a plate that is waiting */ export const ACCENT_IDLE = 0xd9a441; export const ACCENT_IDLE_CSS = "#d9a441"; -/** the cheese, and anything that should read as the goal */ export const ACCENT_GOAL = 0xf2c14e; export const ACCENT_GOAL_CSS = "#f2c14e"; -/** danger / the button */ export const ACCENT_DANGER = 0xc4553f; export const ACCENT_DANGER_CSS = "#c4553f"; -/** the electric source, and the flicker on a charged cursor */ -export const ACCENT_SPARK = 0xfff59d; -export const ACCENT_SPARK_CSS = "#fff59d"; +export const ACCENT_SPARK = 0x4fc3f7; +export const ACCENT_SPARK_CSS = "#4fc3f7"; export const FONT_HAND = '"Patrick Hand", "Comic Sans MS", cursive'; diff --git a/frontend/src/gameplay/renderPlayers.ts b/frontend/src/gameplay/renderPlayers.ts index a39dcaf..a13ebd6 100644 --- a/frontend/src/gameplay/renderPlayers.ts +++ b/frontend/src/gameplay/renderPlayers.ts @@ -50,8 +50,8 @@ export class RenderPlayers { } remote.target.set(player.x, player.y); remote.color = color; - if (!player.charged) remote.cursor.setTint(color); - setElectrified(this.scene, remote.cursor, remote.ink, color, player.charged); + remote.cursor.setTint(color); + setElectrified(this.scene, remote.cursor, player.charged); }); for (const [sessionId, remote] of this.players) { diff --git a/frontend/src/scenes/GameScene.ts b/frontend/src/scenes/GameScene.ts index d646c3a..5a1c8b7 100644 --- a/frontend/src/scenes/GameScene.ts +++ b/frontend/src/scenes/GameScene.ts @@ -448,13 +448,7 @@ export class GameScene extends Phaser.Scene { private syncState(state: RoomState, localSessionId: string) { this.renderPlayers.sync(state, localSessionId); - setElectrified( - this, - this.cursor, - this.cursorOutline, - this.localColor, - state.players.get(localSessionId)?.charged ?? false, - ); + setElectrified(this, this.cursor, state.players.get(localSessionId)?.charged ?? false); state.doors.forEach((door, id) => { const runtime = this.doors.find(({ def }) => def.id === id); From a04e4f9c94ae0f4d867bf51e5559465db75faf09 Mon Sep 17 00:00:00 2001 From: adamd Date: Sun, 26 Jul 2026 07:38:45 +0200 Subject: [PATCH 5/5] feat: better particles + code cleanup part 1 --- frontend/src/entities/BallView.ts | 8 +-- frontend/src/entities/registry.ts | 2 - frontend/src/gameplay/collision.ts | 1 - frontend/src/gameplay/electric.ts | 61 ++++++++++++---------- frontend/src/gameplay/levelView.ts | 71 ++------------------------ frontend/src/gameplay/palette.ts | 14 +---- frontend/src/gameplay/renderPlayers.ts | 6 +-- frontend/src/gameplay/slide.ts | 11 ---- frontend/src/gameplay/spray.ts | 4 -- frontend/src/gameplay/worldText.ts | 2 - 10 files changed, 42 insertions(+), 138 deletions(-) diff --git a/frontend/src/entities/BallView.ts b/frontend/src/entities/BallView.ts index de8d2b6..35fb3fc 100644 --- a/frontend/src/entities/BallView.ts +++ b/frontend/src/entities/BallView.ts @@ -19,17 +19,13 @@ function parseColor(color: string) { return Number.isFinite(parsed) ? parsed : 0xffffff; } -/** - * Client-side prediction with the shared physics: local hits react instantly, - * while a "ghost" body (hard-set to each server snapshot, then advanced by the - * same physics without local input) is what the prediction reconciles against. - */ export class BallView implements EntityView { readonly radius: number; private config: EntityKindConfig; private predicted: CircleBody; private ghost: CircleBody; private container: Phaser.GameObjects.Container; + private body: Phaser.GameObjects.Arc; private fillColor: number; @@ -40,8 +36,6 @@ export class BallView implements EntityView { this.ghost = { ...this.predicted }; this.fillColor = parseColor(state.color); - // A colour disc under the ink barrel: the pack art is black linework, and - // setTint multiplies, so tinting the sprite itself would do nothing. this.body = scene.add.circle(0, 0, state.radius * 0.82, this.fillColor, 0.85); const barrel = scene.add .image(0, 0, "barrel") diff --git a/frontend/src/entities/registry.ts b/frontend/src/entities/registry.ts index 1d0e27f..35136b4 100644 --- a/frontend/src/entities/registry.ts +++ b/frontend/src/entities/registry.ts @@ -3,7 +3,6 @@ import { BallView } from "./BallView"; import type { Rect, Sweep } from "@shared"; import type { EntityState } from "../network/state"; -/** Per-frame prediction inputs: local cursor sweep + currently solid rects. */ export interface PredictionContext { sweep: Sweep | null; solids: readonly Rect[]; @@ -17,7 +16,6 @@ export interface EntityView { type ViewFactory = (scene: Phaser.Scene, state: EntityState) => EntityView; -/** Register new entity kinds here; unknown kinds fall back to a plain ball. */ const VIEWS: Record = { ball: (scene, state) => new BallView(scene, state), boulder: (scene, state) => new BallView(scene, state), diff --git a/frontend/src/gameplay/collision.ts b/frontend/src/gameplay/collision.ts index d22875c..f46aaac 100644 --- a/frontend/src/gameplay/collision.ts +++ b/frontend/src/gameplay/collision.ts @@ -2,7 +2,6 @@ import Phaser from "phaser"; import { moveCircle } from "@shared"; import type { Rect } from "@shared"; -/** Cursor movement uses the same shared collision code as simulated entities. */ export function moveAndCollide( cursor: { x: number; y: number }, velocity: Phaser.Math.Vector2, diff --git a/frontend/src/gameplay/electric.ts b/frontend/src/gameplay/electric.ts index 8b0ea6b..6c09d4b 100644 --- a/frontend/src/gameplay/electric.ts +++ b/frontend/src/gameplay/electric.ts @@ -2,32 +2,43 @@ import Phaser from "phaser"; import type { Rect } from "@shared"; import { ACCENT_SPARK } from "./palette"; -function sparkTexture(scene: Phaser.Scene) { - if (!scene.textures.exists("sparkDot")) { - scene.add - .graphics() - .fillStyle(0xffffff) - .fillCircle(2, 2, 2) - .generateTexture("sparkDot", 4, 4) - .destroy(); +function drawBolt(scene: Phaser.Scene, x: number, y: number) { + const angle = + (Math.random() < 0.5 ? -1 : 1) * (Math.PI / 2) + Phaser.Math.FloatBetween(-0.5, 0.5); + const length = Phaser.Math.Between(18, 32); + const segments = 4; + const bolt = scene.add.graphics().setPosition(x, y).setDepth(20).lineStyle(3, ACCENT_SPARK, 1); + bolt.beginPath(); + bolt.moveTo(0, 0); + for (let i = 1; i <= segments; i++) { + const along = (length * i) / segments; + const across = i === segments ? 0 : Phaser.Math.Between(-7, 7); + bolt.lineTo( + Math.cos(angle) * along - Math.sin(angle) * across, + Math.sin(angle) * along + Math.cos(angle) * across, + ); } - return "sparkDot"; + bolt.strokePath(); + scene.tweens.add({ targets: bolt, alpha: 0, duration: 160, onComplete: () => bolt.destroy() }); } -function sparkEmitter(scene: Phaser.Scene, x: number, y: number) { - return scene.add.particles(x, y, sparkTexture(scene), { - tint: ACCENT_SPARK, - blendMode: "ADD", - speed: { min: 30, max: 90 }, - lifespan: { min: 150, max: 320 }, - scale: { start: 1.6, end: 0 }, - quantity: 1, - frequency: 25, +function sparkLoop(scene: Phaser.Scene, getPos: () => { x: number; y: number } | null) { + const timer = scene.time.addEvent({ + delay: 70, + loop: true, + callback: () => { + const pos = getPos(); + if (!pos) return timer.remove(); + drawBolt(scene, pos.x, pos.y); + }, }); + return timer; } export function buildElectricSource(scene: Phaser.Scene, source: Rect) { - sparkEmitter(scene, source.x + source.width / 2, source.y + source.height / 2).setDepth(1); + const cx = source.x + source.width / 2; + const cy = source.y + source.height / 2; + sparkLoop(scene, () => ({ x: cx, y: cy })); } export function setElectrified( @@ -37,14 +48,12 @@ export function setElectrified( ) { if (charged === (body.getData("charged") ?? false)) return; body.setData("charged", charged); - let emitter = body.getData("sparks") as Phaser.GameObjects.Particles.ParticleEmitter | undefined; if (!charged) { - emitter?.stop(); + (body.getData("sparks") as Phaser.Time.TimerEvent | undefined)?.remove(); return; } - if (!emitter) { - emitter = sparkEmitter(scene, body.x, body.y).setDepth(9).startFollow(body); - body.setData("sparks", emitter); - } - emitter.start(); + body.setData( + "sparks", + sparkLoop(scene, () => (body.isDestroyed ? null : { x: body.x, y: body.y })), + ); } diff --git a/frontend/src/gameplay/levelView.ts b/frontend/src/gameplay/levelView.ts index 36f562b..a0470f1 100644 --- a/frontend/src/gameplay/levelView.ts +++ b/frontend/src/gameplay/levelView.ts @@ -1,5 +1,5 @@ import Phaser from "phaser"; -import { WORLD_HEIGHT, WORLD_WIDTH } from "../config"; +import { WORLD_HEIGHT, WORLD_WIDTH } from "@shared"; import { BALL_SPAWN_COUNT, BUTTON, @@ -31,7 +31,6 @@ import { PAPER, } from "./palette"; -/** texture that shows what is allowed to stand on a plate, or undefined if it takes anything */ function plateIcon(entityKind?: string): string | undefined { if (!entityKind) return undefined; // no filter -- anything counts, so no icon to show if (entityKind === "ball" || entityKind === "boulder") return "barrel"; @@ -42,9 +41,7 @@ export interface DoorRuntime { def: Door; solid: boolean; open: boolean; - /** two leaves that part from the centre; each retracts into its own jamb */ leaves: [Phaser.GameObjects.Image, Phaser.GameObjects.Image]; - /** which screen axis the leaves slide along — doors can sit in either wall */ axis: "x" | "y"; closedPos: [number, number]; openPos: [number, number]; @@ -65,10 +62,8 @@ export function animateDoor(scene: Phaser.Scene, runtime: DoorRuntime, opening: export interface PlateRuntime { def: PressurePlate; active: boolean; - /** colour wash under the ink, since tinting the pack art would be a no-op */ glow: Phaser.GameObjects.Rectangle; plate: Phaser.GameObjects.Rectangle; - /** absent when the plate has no entityKind filter, i.e. anything counts */ icon?: Phaser.GameObjects.Image; countLabel: Phaser.GameObjects.Text; progress?: { @@ -85,8 +80,6 @@ export function updateCollectorProgress(runtime: PlateRuntime, remaining: number } export function animatePlate(scene: Phaser.Scene, runtime: PlateRuntime, active: boolean) { - // idle plates stay empty so you can see what is standing on them. The glow - // (highlight) and icon are left alone here -- only the outline pulses. runtime.glow.setFillStyle(active ? ACCENT_ACTIVE : ACCENT_IDLE, active ? 0.5 : 0); runtime.countLabel.setColor(active ? ACCENT_ACTIVE_CSS : INK_SOFT_CSS); @@ -177,17 +170,9 @@ export function updateButtonView( runtime.rect.setFillStyle(fill, 1); } -/** world px covered by one floor cell (floor_cell.png is 64x64) */ const FLOOR_TILE = 72; -/** thickness of a stamped wall line, in world px */ const WALL_INK = 22; -/** - * How finely an obstacle edge is probed for "is the neighbour solid here?". - * Runs snap to this grid, so a coarse value leaves visible notches where two - * walls meet -- keep it well under the wall weight. - */ const EDGE_PROBE = 6; -/** slabs at or under this thickness are drawn as a single centred line */ const THIN_WALL = WALL_INK * 2.2; const DEPTH_FLOOR = -10; @@ -203,17 +188,11 @@ function isSolidAt(x: number, y: number, skip: Rect) { return false; } -/** - * Runs along one edge of `rect`, returning the [from, to] spans that face open - * floor. Spans buried inside a neighbouring obstacle are dropped so we never - * draw a wall line through the middle of a solid mass. - */ function exposedSpans(rect: Rect, side: "top" | "bottom" | "left" | "right") { const vertical = side === "left" || side === "right"; const start = vertical ? rect.y : rect.x; const end = start + (vertical ? rect.height : rect.width); - // a point just outside this edge, offset along the edge's normal const probe = (at: number) => { switch (side) { case "top": @@ -232,8 +211,6 @@ function exposedSpans(rect: Rect, side: "top" | "bottom" | "left" | "right") { for (let at = start; at <= end; at += EDGE_PROBE) { const p = probe(Math.min(at, end)); - // outside the world bounds there is no room to draw into, only blank - // page: treat it as solid so we never stamp a wall facing off the map. const outOfBounds = p.x < 0 || p.x > WORLD_WIDTH || p.y < WORLD_TOP || p.y > WORLD_HEIGHT; const open = !outOfBounds && !isSolidAt(p.x, p.y, rect); if (open && runStart === null) runStart = at; @@ -247,7 +224,6 @@ function exposedSpans(rect: Rect, side: "top" | "bottom" | "left" | "right") { return spans.filter(([from, to]) => to - from > EDGE_PROBE / 2); } -/** merge two span lists into one, collapsing anything that touches or overlaps */ function unionSpans(a: Array<[number, number]>, b: Array<[number, number]>) { const all = [...a, ...b].sort((p, q) => p[0] - q[0]); const merged: Array<[number, number]> = []; @@ -268,37 +244,20 @@ interface WallSegment { function stampWall(scene: Phaser.Scene, seg: WallSegment) { const { from, to, fixed, vertical } = seg; - // Run a full line-weight past each end. Corners are formed by two runs from - // *different* obstacle rects, so they only close if both overshoot into the - // solid mass behind them. const a = from - WALL_INK; const b = to + WALL_INK; const length = b - a; const mid = (a + b) / 2; - // wall_seam.png (96x48) is wall.png with its drawn end-caps cropped off, so - // it tiles into one continuous wobbling ink line instead of a row of closed - // boxes. Scale both axes equally to keep the wobble from being squashed. - const scale = WALL_INK / 48; - // Always lay it out horizontally (length along local x, matching the - // texture's own orientation) then rotate 90 for vertical runs -- tileScale - // applies in the texture's pre-rotation axes, so swapping width/height - // instead of rotating would sample the wrong part of the source. const strip = scene.add .tileSprite(vertical ? fixed : mid, vertical ? mid : fixed, length, WALL_INK, "wall_seam") .setOrigin(0.5) - .setTileScale(scale, scale) + .setTileScale(WALL_INK / 48, WALL_INK / 48) .setDepth(DEPTH_WALL); if (vertical) strip.setAngle(90); } -/** - * wall_seam is a hollow two-line tube, not a solid stroke -- fine down a - * straight run, but where a horizontal and a vertical run cross it reads as - * four lines tangled together instead of a corner. Cap every such crossing - * with a solid ink blot so it reads as one deliberate joint. - */ function stampWallCrossings(scene: Phaser.Scene, segments: WallSegment[]) { const horizontals = segments.filter((s) => !s.vertical); const verticals = segments.filter((s) => s.vertical); @@ -318,14 +277,10 @@ function stampWallCrossings(scene: Phaser.Scene, segments: WallSegment[]) { } export function drawLevel(scene: Phaser.Scene) { - // The camera may follow the slide south of the dungeon, but only the actual - // rooms receive floor tiles. The extended world below remains blank paper. scene.add .tileSprite(0, 0, WORLD_WIDTH, MAIN_LOBBY_BOTTOM, "floor_cell") .setOrigin(0, 0) .setTileScale(FLOOR_TILE / 64) - // floor_cell.png only carries its top/left border (the right/bottom are - // baked out) so adjacent tiles share a single seam instead of doubling it .setAlpha(0.85) .setDepth(DEPTH_FLOOR); scene.add @@ -335,8 +290,6 @@ export function drawLevel(scene: Phaser.Scene) { .setAlpha(0.85) .setDepth(DEPTH_FLOOR); - // Solid mass reads as blank paper: no floor grid "behind" the walls, so the - // rooms are what the grid picks out. const solid = scene.add.graphics().setDepth(DEPTH_SOLID); solid.fillStyle(PAPER, 1); for (const o of OBSTACLES) solid.fillRect(o.x, o.y, o.width, o.height); @@ -378,7 +331,6 @@ export function drawLevel(scene: Phaser.Scene) { .setOrigin(0.5) .setDepth(DEPTH_DECOR + 1); - // a slow bob so the goal reads as the one live thing in the room scene.tweens.add({ targets: cheese, y: CHEESE.y - 8, @@ -399,9 +351,6 @@ export function drawLevel(scene: Phaser.Scene) { const segments: WallSegment[] = []; for (const o of OBSTACLES) { - // A slab only a couple of line weights thick IS a wall, not a mass with two - // faces. Drawing both of its edges leaves a pale gap between them and it - // reads as a hollow tube, so thin slabs get one line down the spine. if (o.height <= THIN_WALL) { const spine = o.y + o.height / 2; for (const [from, to] of unionSpans(exposedSpans(o, "top"), exposedSpans(o, "bottom"))) { @@ -434,10 +383,7 @@ export function drawLevel(scene: Phaser.Scene) { for (const seg of segments) stampWall(scene, seg); stampWallCrossings(scene, segments); - // The canvas is 1920x1080 but the world is authored at roughly half that - // density, so zoom in to keep objects at a readable on-screen size. - // Handy while authoring levels: setZoom(0.35) frames the whole map. - scene.cameras.main.setZoom(1.5); + scene.cameras.main.setZoom(1.5); // SCALE, ZOOM, SIZE (keywords lol) return { cheese, githubLink }; } @@ -452,8 +398,6 @@ export function buildInteractables(scene: Phaser.Scene) { .setOrigin(0.5) .setDepth(1); - // Frame only -- the middle stays empty so you can see what is standing on - // it. The requirement is spelled out along the bottom edge instead. const plate = scene.add .rectangle(cx, cy, def.width, def.height, PAPER, 0) .setOrigin(0.5) @@ -481,7 +425,6 @@ export function buildInteractables(scene: Phaser.Scene) { } } - // Without an icon (anything counts) the count reads alone, centred. const labelText = def.countLabel ?? plateCountLabel(def.count); const labelScale = labelText.length > 3 ? 0.55 : 0.95; const countLabel = scene.add @@ -511,8 +454,6 @@ export function buildInteractables(scene: Phaser.Scene) { }); const doors: DoorRuntime[] = DOORS.map((def) => { - // A door can sit in a horizontal or a vertical wall; the leaves always part - // along the opening, not along a fixed screen axis. const vertical = def.height > def.width; const axis = vertical ? "y" : "x"; const span = vertical ? def.height : def.width; @@ -522,17 +463,12 @@ export function buildInteractables(scene: Phaser.Scene) { const cross = vertical ? def.x + def.width / 2 : def.y + def.height / 2; const closedPos: [number, number] = [start + half / 2, start + half + half / 2]; - // each leaf retracts a full leaf-length outward, clearing the opening const openPos: [number, number] = [closedPos[0] - half, closedPos[1] + half]; const leaves = closedPos.map((pos, i) => { const leaf = scene.add .image(vertical ? cross : pos, vertical ? pos : cross, "doorLeaf") .setOrigin(0.5) - // doorLeaf is drawn hinge-left/handle-right, so size it along the - // opening first and only then rotate it into a vertical jamb. The - // far leaf is mirrored so its hinge lands on its own jamb instead of - // both leaves hinging on the same side. .setDisplaySize(half, thickness * 1.6) .setFlipX(i === 1) .setDepth(4); @@ -547,7 +483,6 @@ export function buildInteractables(scene: Phaser.Scene) { const cx = station.x + station.width / 2; const cy = station.y + station.height / 2; - // a rug with a blot of paint on it, rather than a flat swatch scene.add .image(cx, cy, "carpet") .setDisplaySize(station.width * 1.35, station.height * 1.35) diff --git a/frontend/src/gameplay/palette.ts b/frontend/src/gameplay/palette.ts index 28ae15e..00a84ec 100644 --- a/frontend/src/gameplay/palette.ts +++ b/frontend/src/gameplay/palette.ts @@ -1,17 +1,7 @@ -/** - * Ink-on-paper palette for the Kenney Scribble Dungeons look. - * - * The pack is black linework on transparency, drawn for a light background, so - * everything here assumes paper underneath. Note that Phaser's setTint - * multiplies: tinting a pack sprite does nothing (black x colour = black). - * Colour is expressed with shapes drawn *under* the ink instead. - */ - -/** page colour — everything sits on this */ +// ITS COLOR NOT COLOUR export const PAPER = 0xf4efe3; export const PAPER_CSS = "#f4efe3"; -/** the pen */ export const INK = 0x1a1a1a; export const INK_CSS = "#1a1a1a"; @@ -24,7 +14,7 @@ export const ACCENT_ACTIVE_CSS = "#5bbf6a"; export const ACCENT_IDLE = 0xd9a441; export const ACCENT_IDLE_CSS = "#d9a441"; -export const ACCENT_GOAL = 0xf2c14e; +export const ACCENT_GOAL = 0xf2c14e; // css for formating export const ACCENT_GOAL_CSS = "#f2c14e"; export const ACCENT_DANGER = 0xc4553f; diff --git a/frontend/src/gameplay/renderPlayers.ts b/frontend/src/gameplay/renderPlayers.ts index a13ebd6..38a4ef9 100644 --- a/frontend/src/gameplay/renderPlayers.ts +++ b/frontend/src/gameplay/renderPlayers.ts @@ -12,9 +12,7 @@ interface RemotePlayer { heading: number; } -/** below which per-frame travel is treated as jitter rather than a new heading */ -const TURN_EPSILON = 0.5; - +const TURN_EPSILON = 0.5; // physics const INTERPOLATION_RATE = 14; const SNAP_DISTANCE = 400; @@ -82,8 +80,6 @@ export class RenderPlayers { remote.cursor.y = Phaser.Math.Linear(remote.cursor.y, remote.target.y, interpolation); } - // Remote peers only send position, so face them along the direction they - // actually travelled this frame; hold the last heading while idle. const dx = remote.cursor.x - fromX; const dy = remote.cursor.y - fromY; if (Math.hypot(dx, dy) > TURN_EPSILON) { diff --git a/frontend/src/gameplay/slide.ts b/frontend/src/gameplay/slide.ts index c30d2c5..b9c27c6 100644 --- a/frontend/src/gameplay/slide.ts +++ b/frontend/src/gameplay/slide.ts @@ -3,14 +3,10 @@ import { SLIDES, circleOverlapsRect } from "@shared"; import type { Slide } from "@shared"; import { INK, PAPER } from "./palette"; -/** fallback ride speed when a slide doesn't specify one, in world px/sec */ export const SLIDE_DEFAULT_SPEED = 900; -/** hard stop so a badly placed chute can never trap a player forever */ export const SLIDE_MAX_MS = 8000; -/** inner width of the chute, in world px */ const CHUTE = 54; -/** ink weight of the chute walls */ const CHUTE_WALL = 9; export function findSlide( @@ -21,12 +17,6 @@ export function findSlide( return slides.find((slide) => circleOverlapsRect(cursor.x, cursor.y, radius, slide.entry)); } -/** - * Draws each chute as a tube: a thick ink stroke with a paper stroke laid over - * it, so what shows is two hand-drawn walls with floor between them. Joints get - * a disc each because Phaser's Graphics strokes use mitre joins, which spike - * badly at the bends. - */ export function buildSlides(scene: Phaser.Scene, depth: number) { for (const slide of SLIDES) { const points = slide.path; @@ -48,7 +38,6 @@ export function buildSlides(scene: Phaser.Scene, depth: number) { floor.fillCircle(p.x, p.y, CHUTE / 2); } - // the mouth, so it's obvious where you get on const { entry } = slide; scene.add .rectangle( diff --git a/frontend/src/gameplay/spray.ts b/frontend/src/gameplay/spray.ts index bb50019..3ee4dde 100644 --- a/frontend/src/gameplay/spray.ts +++ b/frontend/src/gameplay/spray.ts @@ -11,10 +11,6 @@ interface SprayBlob { const FADE_MS = 6000; const BASE_ALPHA = 0.55; -/** - * Graffiti-can style ground paint: dots are added while spraying and - * redrawn every frame with alpha fading out by age, then dropped. - */ export class SprayLayer { private readonly graphics: Phaser.GameObjects.Graphics; private readonly blobs: SprayBlob[] = []; diff --git a/frontend/src/gameplay/worldText.ts b/frontend/src/gameplay/worldText.ts index cce384d..59daacd 100644 --- a/frontend/src/gameplay/worldText.ts +++ b/frontend/src/gameplay/worldText.ts @@ -2,7 +2,6 @@ import Phaser from "phaser"; import type { WorldText } from "@shared"; import { FONT_HAND, INK_CSS } from "./palette"; -/** signs scribbled on the dungeon floor */ export function buildWorldTexts(scene: Phaser.Scene, texts: WorldText[]) { for (const t of texts) { scene.add @@ -12,7 +11,6 @@ export function buildWorldTexts(scene: Phaser.Scene, texts: WorldText[]) { color: INK_CSS, }) .setOrigin(0.5) - // a few degrees off true so it reads as written by hand, not typeset .setRotation(Phaser.Math.DegToRad(t.rotation ?? -2)) .setDepth(2); }