diff --git a/apps/game/public/assets/PROVENANCE.md b/apps/game/public/assets/PROVENANCE.md index 53399f5..7ec5018 100644 --- a/apps/game/public/assets/PROVENANCE.md +++ b/apps/game/public/assets/PROVENANCE.md @@ -117,6 +117,7 @@ material-slot or `COL_` conventions the generated props use; | `models/wep_rifle.glb` | Synty POLYGON Military — `SM_Wep_Preset_A_Rifle_01*` | Synty Store licence (purchased) | | `models/wep_smg.glb` | Synty POLYGON Military — `SM_Wep_Preset_A_SMG_01*` | Synty Store licence (purchased) | | `models/wep_sniper.glb` | Synty POLYGON Military — `SM_Wep_Preset_B_Sniper_01*` | Synty Store licence (purchased) | +| `models/wep_grenade.glb` | Synty POLYGON Military — `SM_Wep_Grenade_01` | Synty Store licence (purchased) | | `textures/synty_weapons.webp` | Synty POLYGON Military — `PolygonMilitary_Weapons_01` | Synty Store licence (purchased) | Purchased from https://syntystore.com. The licence grants perpetual, diff --git a/apps/game/public/assets/manifest.json b/apps/game/public/assets/manifest.json index 75bfacb..0cb5be8 100644 --- a/apps/game/public/assets/manifest.json +++ b/apps/game/public/assets/manifest.json @@ -1,7 +1,7 @@ { "generator": "tools/art/build-assets.mjs", "blender": "Blender 4.5.12 LTS", - "commit": "4ab3c3275c616631d122f19ff15e7bc88904723c", + "commit": "8fb62aa99ff07aa4cc32ff0fea2ec71adb09fbb2", "license": "Original work, © NIGHTCELL 7. No third-party assets.", "source": "Generated procedurally from the scripts in tools/art. No asset is downloaded, photographed, traced, or derived from another game.", "textureSize": 1024, @@ -26,6 +26,7 @@ "veh_armored_car.glb", "veh_technical.glb", "wall.glb", + "wep_grenade.glb", "wep_rifle.glb", "wep_smg.glb", "wep_sniper.glb" @@ -59,6 +60,9 @@ ], "audio": [ "ambience_yard.mp3", + "explosion_01.mp3", + "explosion_02.mp3", + "explosion_03.mp3", "fire_01.mp3", "fire_02.mp3", "fire_03.mp3", @@ -90,9 +94,9 @@ "throngva/the-wolf-called-want.mp3" ], "bytes": { - "models": 4708608, + "models": 4722360, "textures": 2447388, - "audio": 323166, - "total": 7479162 + "audio": 402789, + "total": 7572537 } } diff --git a/apps/game/public/assets/models/wep_grenade.glb b/apps/game/public/assets/models/wep_grenade.glb new file mode 100644 index 0000000..7136dc1 Binary files /dev/null and b/apps/game/public/assets/models/wep_grenade.glb differ diff --git a/apps/game/public/audio/PROVENANCE.md b/apps/game/public/audio/PROVENANCE.md index 2271c4f..37e99cb 100644 --- a/apps/game/public/audio/PROVENANCE.md +++ b/apps/game/public/audio/PROVENANCE.md @@ -39,6 +39,7 @@ variations exist to prevent. | `step_concrete_01..05` | 5 | Short, dry, mid-heavy | | `step_grating_01..04` | 4 | Adds decaying metallic partials — a real gameplay cue that someone is on the catwalk or gantry | | `impact_concrete_01..04` | 4 | Tick, dust, debris tail | +| `explosion_01..03` | 3 | Grenade detonation: high crack, a 58 Hz body that falls in pitch, and a long dark debris tail | | `reload` | 1 | Three mechanical events: magazine out, magazine seated, bolt forward | | `ui_hover`, `ui_click`, `ui_error` | 1 each | Cold and mechanical, matching DIVIDED SIGNAL. Nothing musical | | `ambience_yard` | 1 | 11.6 s seamless loop: wind, 50 Hz lamp hum, distant plant drone | diff --git a/apps/game/public/audio/explosion_01.mp3 b/apps/game/public/audio/explosion_01.mp3 new file mode 100644 index 0000000..7ee1604 Binary files /dev/null and b/apps/game/public/audio/explosion_01.mp3 differ diff --git a/apps/game/public/audio/explosion_02.mp3 b/apps/game/public/audio/explosion_02.mp3 new file mode 100644 index 0000000..8e327a4 Binary files /dev/null and b/apps/game/public/audio/explosion_02.mp3 differ diff --git a/apps/game/public/audio/explosion_03.mp3 b/apps/game/public/audio/explosion_03.mp3 new file mode 100644 index 0000000..3498ef3 Binary files /dev/null and b/apps/game/public/audio/explosion_03.mp3 differ diff --git a/apps/game/src/assets.test.ts b/apps/game/src/assets.test.ts index 8d088bc..283524b 100644 --- a/apps/game/src/assets.test.ts +++ b/apps/game/src/assets.test.ts @@ -95,6 +95,7 @@ describe("generated models", () => { "wep_rifle", "wep_smg", "wep_sniper", + "wep_grenade", ]); it("only uses material slots the engine can bind", () => { @@ -207,6 +208,7 @@ describe("generated audio", () => { step_concrete: 5, step_grating: 4, impact_concrete: 4, + explosion: 3, }; const SINGLE = ["reload", "ui_hover", "ui_click", "ui_error", "ambience_yard"]; diff --git a/apps/game/src/assets.ts b/apps/game/src/assets.ts index fa57d86..075b13b 100644 --- a/apps/game/src/assets.ts +++ b/apps/game/src/assets.ts @@ -78,6 +78,7 @@ export const MODELS = [ "wep_rifle", "wep_smg", "wep_sniper", + "wep_grenade", ] as const; export type ModelName = (typeof MODELS)[number]; diff --git a/apps/game/src/audio.ts b/apps/game/src/audio.ts index 280e7af..d6b0507 100644 --- a/apps/game/src/audio.ts +++ b/apps/game/src/audio.ts @@ -26,6 +26,7 @@ const VARIED = { step_concrete: 5, step_grating: 4, impact_concrete: 4, + explosion: 3, } as const; const SINGLE = ["reload", "ui_hover", "ui_click", "ui_error", "ambience_yard"] as const; @@ -194,6 +195,21 @@ export class GameAudio { this.playVaried("impact_concrete", 0.6); } + /** + * A grenade detonating, at a volume set by how far away it was. + * + * Distance attenuation is done here rather than with a positional audio node + * because there is exactly one listener and the clip is a one-shot; a full + * panner graph per explosion buys nothing a curve does not. The floor is + * deliberately non-zero — a blast you cannot hear is a blast you cannot + * learn from. + */ + explosion(distanceM = 0): void { + const falloff = Math.max(0.16, 1 - distanceM / 42); + // Larger jitter than a gunshot: three takes have to cover a whole match. + this.playVaried("explosion", falloff, 0.09); + } + reload(): void { this.playBuffer("reload", 0.7, 1); } diff --git a/apps/game/src/hud.ts b/apps/game/src/hud.ts index 640be95..d282034 100644 --- a/apps/game/src/hud.ts +++ b/apps/game/src/hud.ts @@ -18,7 +18,7 @@ export interface HudOptions { export interface Hud { /** Cheap enough to call every frame; writes only what actually changed. */ - update(status: ControllerStatus, fps: number): void; + update(status: ControllerStatus, fps: number, grenades?: number): void; setLocked(locked: boolean): void; dispose(): void; } @@ -29,6 +29,7 @@ const KEYS: ReadonlyArray = [ ["Ctrl / C", "Crouch"], ["Space", "Jump"], ["Mouse", "Look"], + ["G", "Throw frag"], ["Esc", "Release cursor"], ]; @@ -82,6 +83,16 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud { bl.append(stanceValue); hud.append(bl); + // Bottom-centre: equipment. Only the grenade count for now — the magazine + // lives on the weapon in this build, and a HUD element per stat would crowd + // a screen that is deliberately sparse. + const bc = el("div", "hud__block hud__block--bc"); + bc.append(el("p", "hud__label", "Frag")); + const grenadeValue = el("p", "hud__value", "0"); + bc.append(grenadeValue); + bc.append(el("p", "hud__sub", "G TO THROW")); + hud.append(bc); + // Bottom-right: position + frame budget. const br = el("div", "hud__block hud__block--br"); br.append(el("p", "hud__label", "Grid reference")); @@ -134,11 +145,21 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud { let lastStance = ""; let lastPos = ""; let lastFps = ""; + let lastGrenades = ""; let fpsAccumulator = 0; let fpsFrames = 0; return { - update(status: ControllerStatus, fps: number): void { + update(status: ControllerStatus, fps: number, grenades = 0): void { + const grenadeText = String(grenades); + if (grenadeText !== lastGrenades) { + grenadeValue.textContent = grenadeText; + // Empty is worth showing differently: a count that just reads "0" is + // easy to miss mid-fight and then G silently does nothing. + grenadeValue.classList.toggle("hud__value--spent", grenades <= 0); + lastGrenades = grenadeText; + } + const speed = status.speed.toFixed(1); if (speed !== lastSpeed) { speedValue.textContent = speed; diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index edfb919..6f3204a 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -176,6 +176,13 @@ async function boot(): Promise { status.position.y > 5.5, ); + // Grenades. The throw goes through the same simulation the bots use, so + // the count, the cooldown and the blast are all decided there rather + // than here — this only asks and then plays the result. + if (player.consumeThrowRequest()) { + if (opponents.throwGrenade(camera.rotation.x)) audio.reload(); + } + if (status.firing && performance.now() - lastShotAt >= fireInterval) { lastShotAt = performance.now(); audio.fire(); @@ -206,7 +213,11 @@ async function boot(): Promise { for (const shot of opponents.drainShots()) { effects.tracerOnly(shot.from, shot.to); } - hud.update(status, engine.getFps()); + for (const blast of opponents.drainExplosions()) { + effects.explode(new Vector3(blast.position.x, blast.position.y, blast.position.z)); + audio.explosion(blast.distanceM); + } + hud.update(status, engine.getFps(), opponents.grenadeCount()); scene.render(); }); diff --git a/apps/game/src/opponents.ts b/apps/game/src/opponents.ts index fa6ab0d..596dc39 100644 --- a/apps/game/src/opponents.ts +++ b/apps/game/src/opponents.ts @@ -14,6 +14,7 @@ import { TEAM_IDS, TICK_MS, type SimEvent, + type SimGrenade, type SimPlayer, type Vec3, spawnsForTeam, @@ -34,6 +35,18 @@ const RESPAWN_MS = 6000; const LOCAL_ID = "local-player"; +/** A grenade in flight, and the mesh following it. */ +interface GrenadeView { + readonly root: TransformNode; +} + +/** A detonation the renderer still has to draw. */ +export interface Explosion { + readonly position: Vec3; + /** Distance from the local player, for volume. */ + readonly distanceM: number; +} + interface BotView { readonly id: string; readonly root: TransformNode; @@ -90,6 +103,9 @@ export class Opponents { /** Shots fired by bots since the last drain, for the renderer to draw. */ private readonly shots: BotShot[] = []; private readonly deadUntil = new Map(); + private readonly grenadeViews = new Map(); + private readonly explosions: Explosion[] = []; + private readonly grenadeModel: AssetContainer | null; constructor(_scene: Scene, assets: AssetSet) { // Back on the generated character. @@ -129,6 +145,8 @@ export class Opponents { const character = enemyModel; if (!character) throw new Error("no character model loaded"); + this.grenadeModel = assets.models.get("wep_grenade") ?? null; + // Weapons for the bots. // // They fought empty-handed until now, which read as unfinished from any @@ -258,6 +276,34 @@ export class Opponents { } /** Bot shots fired since the last call, and clears the queue. */ + /** + * The local player throws a grenade. + * + * Routed through the same `MatchSimulation` the bots use rather than a + * client-side special case, so the sandbox enforces the count, the cooldown + * and the blast exactly as the authoritative server would. Returns false + * when the simulation refused — out of grenades, or still on cooldown. + */ + throwGrenade(pitch: number): boolean { + const local = this.sim.players.get(LOCAL_ID); + if (!local) return false; + // The sim throws along the player's own aim, and `update` only syncs yaw. + local.movement.pitch = pitch; + return this.sim.throwGrenade(LOCAL_ID) !== null; + } + + /** Grenades the local player has left, for the HUD. */ + grenadeCount(): number { + return this.sim.players.get(LOCAL_ID)?.grenades ?? 0; + } + + /** Detonations since the last call, for the renderer to draw and play. */ + drainExplosions(): Explosion[] { + const drained = [...this.explosions]; + this.explosions.length = 0; + return drained; + } + drainShots(): BotShot[] { return this.shots.splice(0, this.shots.length); } @@ -291,6 +337,54 @@ export class Opponents { if (!player) continue; this.syncView(view, player); } + + this.syncGrenades(); + } + + /** + * Keep one mesh per grenade the simulation has in flight. + * + * Driven off `sim.grenades` rather than off the throw event, so a grenade + * can never be left on screen after the simulation has forgotten it — the + * map is the truth and the meshes follow. + */ + private syncGrenades(): void { + for (const [id, grenade] of this.sim.grenades) { + let view = this.grenadeViews.get(id); + if (!view) { + const created = this.createGrenadeView(id, grenade); + if (!created) continue; + view = created; + this.grenadeViews.set(id, view); + } + view.root.position.set(grenade.position.x, grenade.position.y, grenade.position.z); + // Tumble in flight. A grenade that slides through the air facing one way + // reads as a thrown prop rather than as something live. + if (!grenade.resting) { + view.root.rotation.x += 0.28; + view.root.rotation.z += 0.19; + } + } + + // Anything the simulation dropped without an explosion event (a match + // reset, a removed player) still has to lose its mesh. + for (const [id, view] of this.grenadeViews) { + if (this.sim.grenades.has(id)) continue; + view.root.dispose(); + this.grenadeViews.delete(id); + } + } + + private createGrenadeView(id: string, grenade: SimGrenade): GrenadeView | null { + if (!this.grenadeModel) return null; + const [root] = placeAll(this.grenadeModel, `grenade_${id}`, [ + { + position: new Vector3(grenade.position.x, grenade.position.y, grenade.position.z), + }, + ]); + if (!root) return null; + for (const mesh of root.getChildMeshes()) mesh.isPickable = false; + return { root }; } private consume(events: readonly SimEvent[]): void { @@ -305,6 +399,26 @@ export class Opponents { continue; } + if (event.type === "grenade_exploded") { + const view = this.grenadeViews.get(event.grenadeId); + if (view) { + view.root.dispose(); + this.grenadeViews.delete(event.grenadeId); + } + const listener = this.sim.players.get(LOCAL_ID)?.movement.position; + this.explosions.push({ + position: { ...event.position }, + distanceM: listener + ? Math.hypot( + event.position.x - listener.x, + event.position.y - listener.y, + event.position.z - listener.z, + ) + : 0, + }); + continue; + } + // Incoming fire. Only landed rounds are drawn: the simulation reports // hits, not trigger pulls, and a tracer for every miss would need the // bot's aim ray, which is internal to the controller. diff --git a/apps/game/src/player.ts b/apps/game/src/player.ts index 57c6290..4909314 100644 --- a/apps/game/src/player.ts +++ b/apps/game/src/player.ts @@ -51,6 +51,14 @@ export class PlayerController { private seq = 0; private locked = false; private firing = false; + /** + * Edge-triggered throw request. + * + * Held keys are level-triggered, which is right for movement and wrong for a + * grenade: holding G would empty the pouch in two frames. This latches on the + * key-down and is cleared by whoever consumes it. + */ + private throwRequested = false; private readonly sensitivity: number; private readonly invertY: boolean; private readonly spawn: Vec3; @@ -80,6 +88,9 @@ export class PlayerController { const onKeyDown = (e: KeyboardEvent) => { // Never swallow the browser's own escape hatches. if (e.code === "F5" || e.code === "F12") return; + if (this.locked && e.code === "KeyG" && !this.held.has("KeyG")) { + this.throwRequested = true; + } this.held.add(e.code); if (this.locked) e.preventDefault(); }; @@ -108,6 +119,7 @@ export class PlayerController { // walking into a wall while the pause overlay is up. this.held.clear(); this.firing = false; + this.throwRequested = false; } this.onLockChanged?.(this.locked); }; @@ -145,6 +157,13 @@ export class PlayerController { void this.canvas.requestPointerLock(); } + /** Take the pending throw request, if any. Clears it. */ + consumeThrowRequest(): boolean { + if (!this.throwRequested) return false; + this.throwRequested = false; + return true; + } + get isLocked(): boolean { return this.locked; } diff --git a/apps/game/src/style.css b/apps/game/src/style.css index e0c949a..9f38b94 100644 --- a/apps/game/src/style.css +++ b/apps/game/src/style.css @@ -170,6 +170,15 @@ body { justify-items: end; } +/* Equipment. Centred low, clear of the two telemetry corners. */ +.hud__block--bc { + bottom: 34px; + left: 50%; + transform: translateX(-50%); + text-align: center; + justify-items: center; +} + .hud__label { font-size: 0.6rem; letter-spacing: 0.3em; @@ -188,6 +197,12 @@ body { color: var(--signal-cyan); } +/* Out of grenades. Dimmed rather than recoloured red: this is an absence, not + an alarm, and the palette reserves red for damage. */ +.hud__value--spent { + color: var(--hud-dim); +} + .hud__sub { font-size: 0.62rem; letter-spacing: 0.22em; diff --git a/apps/game/src/vfx.ts b/apps/game/src/vfx.ts index d61011f..50b8d65 100644 --- a/apps/game/src/vfx.ts +++ b/apps/game/src/vfx.ts @@ -46,6 +46,20 @@ const POOL_SIZE = 12; const TRACER_LIFE = 55; const FLASH_LIFE = 42; +/** How long an explosion's light and particles persist, in ms. */ +const BLAST_LIFE = 1200; +/** Concurrent explosions. Grenades have a 900 ms cooldown and a 2.5 s fuse. */ +const BLAST_POOL = 4; +/** + * Particles emitted per layer, in one burst. + * + * Each is below its system's capacity so a second explosion in the same pool + * slot cannot be starved by the first one's survivors. + */ +const FIRE_BURST = 170; +const SHARD_BURST = 95; +const SMOKE_BURST = 120; + function toVector(v: Vec3): Vector3 { return new Vector3(v.x, v.y, v.z); } @@ -133,6 +147,31 @@ interface Impact { until: number; } +/** + * One pooled explosion. + * + * Bigger and longer-lived than an `Impact` in every dimension, because that is + * the entire point: a blast that merely looks like a large bullet strike does + * not communicate that a grenade went off near you. + */ +interface Blast { + fire: ParticleSystem; + smoke: ParticleSystem; + shards: ParticleSystem; + light: PointLight; + /** + * A billboarded additive flash. + * + * Not redundant with `light`. The yard runs eleven lamp masts plus several + * directionals, and Babylon drops lights beyond a material's + * `maxSimultaneousLights` (6 here) *silently* — so a blast light is quite + * likely to be the one discarded, exactly when the scene is busiest. The + * quad is geometry and always draws, so the explosion cannot vanish. + */ + flash: Mesh; + until: number; +} + export class WeaponEffects { private readonly scene: Scene; private readonly map: CollisionMap; @@ -147,6 +186,9 @@ export class WeaponEffects { private nextImpact = 0; private readonly impactFlashMaterial: StandardMaterial; + private readonly blasts: Blast[] = []; + private nextBlast = 0; + constructor(scene: Scene, map: CollisionMap = ARDAVAN_YARD) { this.scene = scene; this.map = map; @@ -235,6 +277,12 @@ export class WeaponEffects { this.impacts.push(this.createImpact(i, sparkTexture, dustTexture)); } + // Only a few: more than this on screen at once is a mortar barrage, not a + // 6v6 alpha, and each one carries three particle systems and a light. + for (let i = 0; i < BLAST_POOL; i += 1) { + this.blasts.push(this.createBlast(i, sparkTexture, dustTexture)); + } + // Keep these out of the scene's GlowLayer. // // The yard runs a GlowLayer for the sodium lamps, and it treats any @@ -247,6 +295,9 @@ export class WeaponEffects { const glow = layer as unknown as { addExcludedMesh: (m: Mesh) => void }; glow.addExcludedMesh(this.flash); for (const tracer of this.tracers) glow.addExcludedMesh(tracer.mesh); + // A 5.5 m emissive quad is a far worse offender than the tracer that + // prompted this: bloomed, one explosion whites out the entire frame. + for (const blast of this.blasts) glow.addExcludedMesh(blast.flash); } } @@ -471,6 +522,142 @@ export class WeaponEffects { } /** Advance and retire effects. Call once per frame. */ + private createBlast(index: number, spark: Texture, dust: Texture): Blast { + const scene = this.scene; + + // Fireball: brief, bright, and thrown outward hard. + const fire = new ParticleSystem(`vfx-blast-fire-${index}`, 220, scene); + fire.particleTexture = spark; + fire.emitter = Vector3.Zero(); + fire.createSphereEmitter(0.6); + fire.minSize = 0.5; + fire.maxSize = 2.4; + fire.minLifeTime = 0.14; + fire.maxLifeTime = 0.42; + fire.emitRate = 0; // burst, see `explode` + fire.minEmitPower = 6; + fire.maxEmitPower = 20; + fire.updateSpeed = 0.016; + fire.gravity = new Vector3(0, 2.5, 0); + fire.color1 = new Color4(1, 0.94, 0.66, 1); + fire.color2 = new Color4(1, 0.5, 0.12, 1); + fire.colorDead = new Color4(0.35, 0.09, 0.02, 0); + fire.blendMode = ParticleSystem.BLENDMODE_ADD; + fire.disposeOnStop = false; + + // Smoke outlives the fireball by a long way — it is what remains to show + // where the grenade went off after the light is gone. + const smoke = new ParticleSystem(`vfx-blast-smoke-${index}`, 160, scene); + smoke.particleTexture = dust; + smoke.emitter = Vector3.Zero(); + smoke.createSphereEmitter(0.9); + smoke.minSize = 1.2; + smoke.maxSize = 4.2; + smoke.minLifeTime = 0.7; + smoke.maxLifeTime = 1.9; + smoke.emitRate = 0; + smoke.minEmitPower = 1.2; + smoke.maxEmitPower = 5; + smoke.updateSpeed = 0.016; + smoke.gravity = new Vector3(0, 1.1, 0); + smoke.color1 = new Color4(0.34, 0.32, 0.3, 0.72); + smoke.color2 = new Color4(0.18, 0.17, 0.16, 0.5); + smoke.colorDead = new Color4(0.12, 0.12, 0.12, 0); + smoke.blendMode = ParticleSystem.BLENDMODE_STANDARD; + smoke.disposeOnStop = false; + + // Fragments thrown along the ground. Heavy gravity so they arc and land + // rather than drifting, which is what separates a blast from a fireball. + const shards = new ParticleSystem(`vfx-blast-shards-${index}`, 120, scene); + shards.particleTexture = spark; + shards.emitter = Vector3.Zero(); + shards.createSphereEmitter(0.3); + shards.minSize = 0.04; + shards.maxSize = 0.13; + shards.minLifeTime = 0.35; + shards.maxLifeTime = 0.95; + shards.emitRate = 0; + shards.minEmitPower = 9; + shards.maxEmitPower = 26; + shards.updateSpeed = 0.016; + shards.gravity = new Vector3(0, -24, 0); + shards.color1 = new Color4(1, 0.85, 0.5, 1); + shards.color2 = new Color4(0.95, 0.42, 0.1, 1); + shards.colorDead = new Color4(0.3, 0.1, 0.02, 0); + shards.blendMode = ParticleSystem.BLENDMODE_ADD; + shards.disposeOnStop = false; + + const light = new PointLight(`vfx-blast-light-${index}`, Vector3.Zero(), scene); + light.diffuse = new Color3(1, 0.66, 0.3); + light.intensity = 0; + light.range = 26; + + const flash = MeshBuilder.CreatePlane(`vfx-blast-flash-${index}`, { size: 1 }, scene); + flash.material = this.impactFlashMaterial; + flash.billboardMode = Mesh.BILLBOARDMODE_ALL; + flash.isPickable = false; + flash.setEnabled(false); + + return { fire, smoke, shards, light, flash, until: 0 }; + } + + /** + * A grenade detonating at `at`. + * + * Presentation only — the blast that matters already happened in the + * simulation, which decided who it hurt. This is what the player sees, and + * it is deliberately loud: an explosion the player cannot locate is worse + * than no explosion, because they cannot learn to move away from the next + * one. + */ + explode(at: Vector3): void { + const now = performance.now(); + const blast = this.blasts[this.nextBlast % this.blasts.length]; + this.nextBlast += 1; + if (!blast) return; + + // Lift the emitters slightly. A grenade rests on the floor, and a sphere + // emitter centred there buries half the fireball in the ground. + const centre = new Vector3(at.x, at.y + 0.35, at.z); + blast.fire.emitter = centre; + blast.smoke.emitter = centre; + blast.shards.emitter = centre; + blast.light.position = centre; + blast.flash.position = centre; + // Roughly the lethal radius, so the flash reads as the size of the thing + // that just happened rather than as a generic spark. + blast.flash.scaling.setAll(5.5); + blast.flash.visibility = 1; + blast.flash.setEnabled(true); + + // A burst, emitted in one step — `manualEmitCount` with `emitRate = 0`. + // + // The obvious alternative is to emit at a high rate for a short window and + // then stop, and every way of timing that window is wrong here. A + // `setTimeout` measures wall-clock and expires between frames on a slow + // machine, so nothing is emitted at all; checking the deadline in `update` + // has the same failure whenever one frame is longer than the window; and + // `targetStopDuration` leaves the system reporting `isStarted() === false`, + // which makes Babylon skip animating it entirely, freezing its particles at + // full count so they are neither drawn nor retired. + // + // An explosion is a burst rather than a stream, so saying exactly that + // removes the timing question completely: the count is the count whether + // the frame took 4 ms or 400. + blast.fire.stop(); + blast.smoke.stop(); + blast.shards.stop(); + blast.fire.manualEmitCount = FIRE_BURST; + blast.shards.manualEmitCount = SHARD_BURST; + blast.smoke.manualEmitCount = SMOKE_BURST; + blast.fire.start(); + blast.smoke.start(); + blast.shards.start(); + + blast.light.intensity = 220; + blast.until = now + BLAST_LIFE; + } + update(): void { const now = performance.now(); @@ -492,6 +679,32 @@ export class WeaponEffects { } } + for (const blast of this.blasts) { + if (!blast.until) continue; + + const remaining = (blast.until - now) / BLAST_LIFE; + // Quartic falloff: the light should read as a flash that dies, not as a + // lamp someone switched on for a second. + blast.light.intensity = Math.max(0, 220 * remaining * remaining * remaining * remaining); + // The flash is the briefest part — a fireball, not a fire. + const flashLeft = (remaining - 0.82) / 0.18; + if (flashLeft <= 0) { + blast.flash.setEnabled(false); + } else { + blast.flash.visibility = flashLeft; + blast.flash.scaling.setAll(5.5 + (1 - flashLeft) * 3.5); + } + + if (now >= blast.until) { + blast.light.intensity = 0; + blast.flash.setEnabled(false); + blast.fire.stop(); + blast.smoke.stop(); + blast.shards.stop(); + blast.until = 0; + } + } + for (const impact of this.impacts) { if (!impact.until) continue; const remaining = (impact.until - now) / 340; // decay reference, not the exact life @@ -513,6 +726,13 @@ export class WeaponEffects { dispose(): void { this.flash.dispose(); this.flashLight.dispose(); + for (const blast of this.blasts) { + blast.flash.dispose(); + blast.fire.dispose(); + blast.smoke.dispose(); + blast.shards.dispose(); + blast.light.dispose(); + } for (const tracer of this.tracers) tracer.mesh.dispose(); for (const impact of this.impacts) { impact.sparks.dispose(); diff --git a/docs/prd.md b/docs/prd.md index 5d02609..7937e79 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -30,6 +30,7 @@ commit as the code (CLAUDE.md). | Multiplayer | P0 V1 requirement, server-authoritative | | V1 multiplayer mode | 6v6 Team Deathmatch, private matches, optional bot fill | | V1 multiplayer map | Ardavan Yard (derived from Episode 1 architecture) | +| V1 throwable | **Frag grenade only** — 2 per life, no resupply, no other gadget | | Multiplayer access | Free to verified accounts, no pay-to-win | | Runtime target | 60–90 minutes per campaign side | | Engine / build | Babylon.js / Vite | @@ -69,7 +70,8 @@ commit as the code (CLAUDE.md). | §12.4 | Health/armour, regen ceiling, no bullet sponges | `packages/game-core/src/damage.ts` | | §12.6 | Difficulty changes tactics, not enemy health | `packages/game-core/src/difficulty.ts` | | §13.1 | Four hero weapons, fictional | `packages/game-core/src/weapons.ts` | -| §13.3 | Friendly-fire and identification rules | `multiplayer-sim/src/hitscan.ts` (team filter) | +| §13.2 | One throwable: the frag grenade | `packages/game-core/src/grenades.ts`, `multiplayer-sim/src/grenades.ts` | +| §13.3 | Friendly-fire and identification rules | `multiplayer-sim/src/hitscan.ts` (team filter), `grenades.ts` (`resolveBlast`) | | §17.2 | Single-repository mandate | `pnpm-workspace.yaml`, `CLAUDE.md` | | §17.3 | Monorepo layout | repository root | | §17.5 | Railway topology and public routing | `infra/railway/`, `services/gateway/src/routes.ts` | @@ -138,6 +140,8 @@ Encoded in `CLAUDE.md` and enforced by review: - Peer-to-peer or host-migrated multiplayer authority - Kernel-level anti-cheat - Native mobile play +- Throwables beyond the frag grenade — the other five entries in `GADGET` are + campaign fiction, not multiplayer content, and stay that way for V1 - Separate repositories for the site, game, backend, multiplayer or infrastructure - Direct client connections to internal Railway service domains - Selling the second campaign or the true ending separately diff --git a/packages/game-core/src/grenades.ts b/packages/game-core/src/grenades.ts new file mode 100644 index 0000000..494f797 --- /dev/null +++ b/packages/game-core/src/grenades.ts @@ -0,0 +1,100 @@ +import { GADGET, type GadgetId } from "./ids"; + +/** + * The frag grenade (PRD §13.2). + * + * Same contract as `weapons.ts`: these numbers are shared tuning, not one + * runtime's opinion. The client uses them to draw the arc and the HUD count; + * the authoritative server uses them to decide where a grenade lands and who it + * kills. They must never diverge, which is why they live here rather than in + * either runtime. + * + * The grenade is deliberately the only throwable in V1. PRD §13.1 — "four + * excellent weapons have more value than twenty weak weapons" — applies just as + * well to gadgets, and the `GADGET` table already lists five more that are + * campaign fiction rather than multiplayer content. + */ + +export interface GrenadeSpec { + readonly id: GadgetId; + readonly displayName: string; + /** Time from leaving the hand to detonation. Not cookable in V1. */ + readonly fuseMs: number; + /** Carried per life. Replenished on respawn, never mid-life. */ + readonly carried: number; + /** Minimum gap between throws, so two cannot leave the hand on one frame. */ + readonly cooldownMs: number; + /** Launch speed along the aim direction, m/s. */ + readonly throwSpeed: number; + /** + * Upward bias added to the aim direction, m/s. + * + * Without it a grenade thrown at a target on your level lands short of the + * crosshair, because it starts falling immediately. This is the lob. + */ + readonly throwLift: number; + /** Collision radius against world geometry. */ + readonly radiusM: number; + /** Fraction of speed kept after a bounce. */ + readonly restitution: number; + /** Fraction of tangential speed kept after a bounce. */ + readonly friction: number; + /** Full damage within this distance of the blast centre. */ + readonly innerRadiusM: number; + /** Damage reaches zero at this distance. */ + readonly outerRadiusM: number; + /** + * Damage at the centre of the blast. + * + * Read this against `applyDamage`, not on its own. Armour absorbs half of + * incoming damage while it lasts, so a spawn loadout of 100 health and 50 + * armour survives anything under 150 — an obvious-looking 130 leaves someone + * standing directly on a frag at 20 health, which reads as a broken grenade + * rather than a balanced one. + */ + readonly maxDamage: number; + /** + * What fraction of the blast the thrower takes. + * + * Not 0: a grenade with no downside is thrown at every wall in reach, and + * the arc has to be a decision. Not 1 either — this is a 6v6 alpha, and + * dying to your own bounce-back is a worse experience than it is a lesson. + */ + readonly selfDamageFraction: number; +} + +export const GRENADE_SPEC: GrenadeSpec = { + id: GADGET.FRAG, + displayName: "Frag", + fuseMs: 2500, + carried: 2, + cooldownMs: 900, + throwSpeed: 15, + throwLift: 3.2, + radiusM: 0.09, + restitution: 0.32, + friction: 0.62, + innerRadiusM: 1.6, + outerRadiusM: 6.5, + maxDamage: 165, + selfDamageFraction: 0.6, +}; + +/** + * Blast damage at a distance from the centre. + * + * Linear between the inner and outer radius. A quadratic curve is more + * physical and plays worse: it makes the outer two thirds of the radius do + * almost nothing, so the grenade reads as either an instant kill or a noise. + * + * Pure, so the server, the client's prediction and the tests all agree. + */ +export function grenadeDamageAt(distanceM: number, spec: GrenadeSpec = GRENADE_SPEC): number { + if (!Number.isFinite(distanceM) || distanceM < 0) return 0; + if (distanceM <= spec.innerRadiusM) return spec.maxDamage; + if (distanceM >= spec.outerRadiusM) return 0; + + const span = spec.outerRadiusM - spec.innerRadiusM; + const falloff = 1 - (distanceM - spec.innerRadiusM) / span; + return spec.maxDamage * falloff; +} diff --git a/packages/game-core/src/index.ts b/packages/game-core/src/index.ts index 53f306d..186c5ba 100644 --- a/packages/game-core/src/index.ts +++ b/packages/game-core/src/index.ts @@ -8,6 +8,7 @@ */ export * from "./ids"; export * from "./weapons"; +export * from "./grenades"; export * from "./damage"; export * from "./difficulty"; export * from "./campaign"; diff --git a/packages/multiplayer-protocol/src/messages.ts b/packages/multiplayer-protocol/src/messages.ts index 0392601..f1e27d1 100644 --- a/packages/multiplayer-protocol/src/messages.ts +++ b/packages/multiplayer-protocol/src/messages.ts @@ -99,6 +99,9 @@ export const SERVER_MESSAGE = { RESPAWN: "respawn", MATCH_START: "match_start", MATCH_END: "match_end", + /** A grenade left someone's hand. Clients simulate its flight themselves. */ + GRENADE_THROWN: "gt", + GRENADE_EXPLODED: "gx", PING_MARK: "ping_mark", QUICK_MESSAGE: "quick_message", REJECTED: "rejected", @@ -166,6 +169,40 @@ export interface MatchEndPayload { durationMs: number; } +/** + * A thrown grenade. + * + * Deliberately *not* replicated per tick. The flight model in + * `@nightcell7/multiplayer-sim` is deterministic and shared, so every client + * can run it from this one message and arrive at the same place the server + * does — which costs one packet per throw instead of a position for every + * grenade on every snapshot (PRD §30.3, network budget). + */ +export interface GrenadeThrownPayload { + grenadeId: string; + ownerSessionId: string; + team: number; + x: number; + y: number; + z: number; + vx: number; + vy: number; + vz: number; + fuseMs: number; + tick: number; +} + +export interface GrenadeExplodedPayload { + grenadeId: string; + ownerSessionId: string; + x: number; + y: number; + z: number; + /** Session ids the blast reached, for hit feedback. Damage itself is state. */ + victimSessionIds: string[]; + tick: number; +} + export interface RejectedPayload { code: string; detail?: string; @@ -195,6 +232,8 @@ export interface ServerMessageMap { [SERVER_MESSAGE.RESPAWN]: RespawnPayload; [SERVER_MESSAGE.MATCH_START]: { startedAtMs: number }; [SERVER_MESSAGE.MATCH_END]: MatchEndPayload; + [SERVER_MESSAGE.GRENADE_THROWN]: GrenadeThrownPayload; + [SERVER_MESSAGE.GRENADE_EXPLODED]: GrenadeExplodedPayload; [SERVER_MESSAGE.PING_MARK]: PingMarkPayload; [SERVER_MESSAGE.QUICK_MESSAGE]: QuickMessagePayload; [SERVER_MESSAGE.REJECTED]: RejectedPayload; diff --git a/packages/multiplayer-protocol/src/state.ts b/packages/multiplayer-protocol/src/state.ts index 0a213aa..8d6bf52 100644 --- a/packages/multiplayer-protocol/src/state.ts +++ b/packages/multiplayer-protocol/src/state.ts @@ -54,6 +54,8 @@ export class PlayerState extends Schema { weaponSlot = 0; ammoInMagazine = 0; ammoReserve = 0; + /** Grenades left this life — HUD only; the server owns the count. */ + grenades = 0; /** Match-clock milliseconds; 0 when not reloading. */ reloadingUntilMs = 0; /** Earliest match-clock time this player may fire again. */ @@ -96,6 +98,7 @@ defineTypes(PlayerState, { armor: "uint8", weaponSlot: "uint8", ammoInMagazine: "uint8", + grenades: "uint8", ammoReserve: "uint16", reloadingUntilMs: "uint32", nextFireAtMs: "uint32", diff --git a/packages/multiplayer-protocol/src/version.test.ts b/packages/multiplayer-protocol/src/version.test.ts new file mode 100644 index 0000000..f5a4752 --- /dev/null +++ b/packages/multiplayer-protocol/src/version.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + CLIENT_MESSAGE, + MIN_SUPPORTED_PROTOCOL_VERSION, + PROTOCOL_VERSION, + SERVER_MESSAGE, + clientMessageSchemas, + isContentCompatible, + isProtocolCompatible, + CONTENT_VERSION, +} from "./index"; + +/** + * Client/server compatibility, which CLAUDE.md requires alongside any protocol + * change. + * + * The rule these guard is not "the number went up" — it is that a client and a + * server which disagree about the gameplay contract must never end up in the + * same room. Grenades made that concrete: `CLIENT_MESSAGE.THROW_GRENADE` + * existed in v1 and did nothing, so the message set alone never changed in a + * way a version check could see. What changed was the *behaviour* — a v1 + * client would take blast damage it can neither cause nor render. + */ + +describe("protocol compatibility", () => { + it("admits its own version", () => { + expect(isProtocolCompatible(PROTOCOL_VERSION)).toBe(true); + }); + + it("refuses a client from the future", () => { + expect(isProtocolCompatible(PROTOCOL_VERSION + 1)).toBe(false); + }); + + it("refuses anything below the supported window", () => { + expect(isProtocolCompatible(MIN_SUPPORTED_PROTOCOL_VERSION - 1)).toBe(false); + }); + + it("refuses a version that is not an integer at all", () => { + // These arrive from an untrusted join payload, not from our own code. + for (const value of [Number.NaN, 1.5, Infinity, -1]) { + expect(isProtocolCompatible(value), `accepted ${value}`).toBe(false); + } + }); + + it("keeps the supported window closed while grenades are asymmetric", () => { + // Grenades deal damage a v1 client cannot cause, see or avoid, so the + // window is deliberately shut rather than left open for a smoother deploy. + // If a later change reopens it, that has to be a deliberate edit here. + expect(MIN_SUPPORTED_PROTOCOL_VERSION).toBe(PROTOCOL_VERSION); + }); + + it("pins the content version to an exact match", () => { + expect(isContentCompatible(CONTENT_VERSION)).toBe(true); + expect(isContentCompatible(`${CONTENT_VERSION}-dev`)).toBe(false); + }); +}); + +describe("grenade messages", () => { + it("carries a throw channel with a schema on both sides", () => { + expect(CLIENT_MESSAGE.THROW_GRENADE).toBeDefined(); + expect(clientMessageSchemas[CLIENT_MESSAGE.THROW_GRENADE]).toBeDefined(); + expect(SERVER_MESSAGE.GRENADE_THROWN).toBeDefined(); + expect(SERVER_MESSAGE.GRENADE_EXPLODED).toBeDefined(); + }); + + it("accepts a throw request and nothing more", () => { + const schema = clientMessageSchemas[CLIENT_MESSAGE.THROW_GRENADE]; + expect(schema.safeParse({ seq: 12 }).success).toBe(true); + + // Deliberately no position, direction or count: the server derives all + // three (PRD §18.3). A client that tries to supply them is not honoured. + const extra = schema.safeParse({ seq: 12, x: 999, count: 99 }); + expect(extra.success).toBe(true); + expect(extra.success && "x" in extra.data).toBe(false); + }); + + it("rejects a malformed sequence number", () => { + const schema = clientMessageSchemas[CLIENT_MESSAGE.THROW_GRENADE]; + expect(schema.safeParse({ seq: -1 }).success).toBe(false); + expect(schema.safeParse({ seq: "3" }).success).toBe(false); + expect(schema.safeParse({}).success).toBe(false); + }); + + it("gives every server message a distinct wire code", () => { + // Two messages sharing a code silently routes one to the other's handler. + const codes = Object.values(SERVER_MESSAGE); + expect(new Set(codes).size).toBe(codes.length); + }); + + it("gives every client message a distinct wire code", () => { + const codes = Object.values(CLIENT_MESSAGE); + expect(new Set(codes).size).toBe(codes.length); + }); +}); diff --git a/packages/multiplayer-protocol/src/version.ts b/packages/multiplayer-protocol/src/version.ts index 4b3bcb2..db5b7e8 100644 --- a/packages/multiplayer-protocol/src/version.ts +++ b/packages/multiplayer-protocol/src/version.ts @@ -7,15 +7,26 @@ * Bump `PROTOCOL_VERSION` for ANY breaking change to the message set, the * input encoding, the room state schema or the simulation constants that * both sides must agree on. Additive, ignorable fields do not require a bump. + * + * Version 2 adds grenades: two server messages, a carried count in the room + * state, and a damage source that exists on one side of the version line and + * not the other. The message channel (`CLIENT_MESSAGE.THROW_GRENADE`) already + * existed in v1 and did nothing, which is exactly why the *behaviour*, not the + * message set, is what makes this breaking. */ -export const PROTOCOL_VERSION = 1 as const; +export const PROTOCOL_VERSION = 2 as const; /** * The oldest protocol a server process will still admit into a room. * A bounded compatibility window lets a deploy roll without kicking everyone, * but only while the gameplay contract is genuinely unchanged. + * + * Raised to 2 with grenades. A v1 client can neither throw one nor render one, + * but would still take blast damage from players who can — so the window is + * deliberately closed rather than left open for a smoother deploy. An + * unwinnable match is worse than a reconnect. */ -export const MIN_SUPPORTED_PROTOCOL_VERSION = 1 as const; +export const MIN_SUPPORTED_PROTOCOL_VERSION = 2 as const; /** * Content version covers the gameplay-relevant map data: collision geometry, diff --git a/packages/multiplayer-sim/src/bots.ts b/packages/multiplayer-sim/src/bots.ts index 59c6b0c..7defd69 100644 --- a/packages/multiplayer-sim/src/bots.ts +++ b/packages/multiplayer-sim/src/bots.ts @@ -26,6 +26,17 @@ export interface BotTuning { engageRangeM: number; /** Distance the bot tries to hold from its target. */ preferredRangeM: number; + /** + * Range band in which a bot will throw a grenade. + * + * The lower bound is not a style choice: the blast damages its owner, so a + * bot throwing at something 4 m away kills itself, which reads as a bug + * rather than as difficulty. + */ + grenadeMinRangeM: number; + grenadeMaxRangeM: number; + /** Probability per second of throwing while a target sits in that band. */ + grenadeChancePerSecond: number; } export const DEFAULT_BOT_TUNING: BotTuning = { @@ -34,6 +45,9 @@ export const DEFAULT_BOT_TUNING: BotTuning = { reactionMs: 420, engageRangeM: 45, preferredRangeM: 14, + grenadeMinRangeM: 11, + grenadeMaxRangeM: 30, + grenadeChancePerSecond: 0.22, }; /** Small deterministic PRNG so a bot match can be replayed exactly. */ @@ -110,6 +124,22 @@ export class BotController { if (acquiredFor >= this.tuning.reactionMs && aimed && range <= this.tuning.engageRangeM) { buttons |= BUTTON.FIRE; } + + // Grenades. The throw is a request like any other — the simulation + // decides whether there is one left and where it leaves from, so a bot + // gets no more than a player would. + if ( + acquiredFor >= this.tuning.reactionMs && + range >= this.tuning.grenadeMinRangeM && + range <= this.tuning.grenadeMaxRangeM && + this.rng.next() < this.tuning.grenadeChancePerSecond * (dtMs / 1000) + ) { + // Aim the throw where the bot is looking, which the lob turns into an + // arc that lands near the target rather than at its feet. + self.movement.yaw = yaw; + self.movement.pitch = pitch; + sim.throwGrenade(this.playerId); + } } else { // Patrol: wander toward the middle of the map so bots do not idle in spawn. const toCentre = sub({ x: 0, y: 0, z: 0 }, self.movement.position); diff --git a/packages/multiplayer-sim/src/grenades.test.ts b/packages/multiplayer-sim/src/grenades.test.ts new file mode 100644 index 0000000..cfab946 --- /dev/null +++ b/packages/multiplayer-sim/src/grenades.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from "vitest"; +import { GRENADE_SPEC, grenadeDamageAt } from "@nightcell7/game-core"; +import { + MatchSimulation, + TICK_MS, + resolveBlast, + stepGrenade, + type BlastCandidate, + type CollisionMap, + type SimEvent, + type SimGrenade, +} from "./index"; + +/** + * Grenades are the only thing in the simulation that persists across ticks + * without an owner driving it, and the only damage source that ignores where + * the attacker is looking. Both make them easy to get subtly wrong in ways a + * match would not surface: a grenade that tunnels through a floor lands under + * the map, and a blast that ignores cover quietly deletes the value of the + * three lanes. + */ + +/** Flat ground plus one tall wall at x = 0, to test cover explicitly. */ +const WALLED_MAP: CollisionMap = { + id: "test-walled", + displayName: "Walled", + bounds: { min: { x: -100, y: -20, z: -100 }, max: { x: 100, y: 50, z: 100 } }, + killPlaneY: -10, + boxes: [ + { min: { x: -100, y: -1, z: -100 }, max: { x: 100, y: 0, z: 100 } }, + { min: { x: -0.5, y: 0, z: -20 }, max: { x: 0.5, y: 8, z: 20 } }, + ], + spawns: [ + { position: { x: -10, y: 0, z: 0 }, yaw: 0, team: 0, label: "A" }, + { position: { x: 10, y: 0, z: 0 }, yaw: Math.PI, team: 1, label: "B" }, + ], +}; + +const FLAT_MAP: CollisionMap = { + ...WALLED_MAP, + id: "test-flat", + boxes: [{ min: { x: -100, y: -1, z: -100 }, max: { x: 100, y: 0, z: 100 } }], +}; + +function grenade(overrides: Partial = {}): SimGrenade { + return { + id: "g1", + ownerId: "a", + ownerTeam: 0, + position: { x: 0, y: 5, z: 0 }, + velocity: { x: 0, y: 0, z: 0 }, + fuseRemainingMs: GRENADE_SPEC.fuseMs, + bounces: 0, + resting: false, + ...overrides, + }; +} + +function candidate(overrides: Partial = {}): BlastCandidate { + return { id: "b", team: 1, center: { x: 0, y: 1, z: 0 }, alive: true, ...overrides }; +} + +describe("grenade damage curve", () => { + it("is full inside the inner radius and zero outside the outer", () => { + expect(grenadeDamageAt(0)).toBe(GRENADE_SPEC.maxDamage); + expect(grenadeDamageAt(GRENADE_SPEC.innerRadiusM)).toBe(GRENADE_SPEC.maxDamage); + expect(grenadeDamageAt(GRENADE_SPEC.outerRadiusM)).toBe(0); + expect(grenadeDamageAt(999)).toBe(0); + }); + + it("falls off monotonically between them", () => { + let previous = Infinity; + for (let d = GRENADE_SPEC.innerRadiusM; d <= GRENADE_SPEC.outerRadiusM; d += 0.25) { + const damage = grenadeDamageAt(d); + expect(damage).toBeLessThanOrEqual(previous); + previous = damage; + } + }); + + it("treats nonsense distances as out of range rather than throwing", () => { + expect(grenadeDamageAt(Number.NaN)).toBe(0); + expect(grenadeDamageAt(-1)).toBe(0); + }); +}); + +describe("grenade flight", () => { + it("settles on the ground instead of falling through it", () => { + const g = grenade({ position: { x: 0, y: 6, z: 0 } }); + for (let i = 0; i < 200; i += 1) stepGrenade(g, TICK_MS, FLAT_MAP); + + expect(g.position.y).toBeGreaterThan(-0.2); + expect(g.resting).toBe(true); + }); + + it("does not tunnel through a floor at full throw speed", () => { + // The reason the step is substepped: at 15 m/s a 33 ms tick moves half a + // metre, which clears a thin plate in a single step. + const g = grenade({ + position: { x: 0, y: 3, z: 0 }, + velocity: { x: 0, y: -40, z: 0 }, + }); + for (let i = 0; i < 60; i += 1) stepGrenade(g, TICK_MS, FLAT_MAP); + + expect(g.position.y).toBeGreaterThan(-0.2); + }); + + it("bounces off a wall rather than passing through it", () => { + const g = grenade({ + position: { x: -3, y: 2, z: 0 }, + velocity: { x: 18, y: 0, z: 0 }, + }); + for (let i = 0; i < 30; i += 1) stepGrenade(g, TICK_MS, WALLED_MAP); + + // Never crossed to the far side of the wall at x = 0. + expect(g.position.x).toBeLessThan(0); + expect(g.bounces).toBeGreaterThan(0); + }); + + it("burns its fuse even after coming to rest", () => { + const g = grenade({ position: { x: 0, y: 0.2, z: 0 } }); + for (let i = 0; i < 200; i += 1) stepGrenade(g, TICK_MS, FLAT_MAP); + expect(g.fuseRemainingMs).toBeLessThan(0); + }); +}); + +describe("blast resolution", () => { + it("damages an enemy in range", () => { + const victims = resolveBlast({ x: 0, y: 1, z: 0 }, "a", 0, [candidate()], FLAT_MAP); + expect(victims).toHaveLength(1); + expect(victims[0]?.damage).toBeGreaterThan(0); + }); + + it("never damages a teammate", () => { + const victims = resolveBlast( + { x: 0, y: 1, z: 0 }, + "a", + 0, + [candidate({ id: "mate", team: 0 })], + FLAT_MAP, + ); + expect(victims).toHaveLength(0); + }); + + it("damages the thrower, but less than an enemy at the same distance", () => { + const centre = { x: 0, y: 1, z: 0 }; + const at = { x: 2, y: 1, z: 0 }; + + const [enemy] = resolveBlast(centre, "a", 0, [candidate({ center: at })], FLAT_MAP); + const [self] = resolveBlast( + centre, + "a", + 0, + [candidate({ id: "a", team: 0, center: at })], + FLAT_MAP, + ); + + expect(self?.damage).toBeGreaterThan(0); + expect(self?.damage).toBeLessThan(enemy?.damage ?? 0); + }); + + it("is blocked by cover", () => { + // Blast on one side of the wall, victim on the other, well inside range. + const victims = resolveBlast( + { x: -1.5, y: 1, z: 0 }, + "a", + 0, + [candidate({ center: { x: 1.5, y: 1, z: 0 } })], + WALLED_MAP, + ); + expect(victims).toHaveLength(0); + }); + + it("still damages through the same gap when the wall is not there", () => { + // The control for the cover test: identical geometry apart from the wall. + const victims = resolveBlast( + { x: -1.5, y: 1, z: 0 }, + "a", + 0, + [candidate({ center: { x: 1.5, y: 1, z: 0 } })], + FLAT_MAP, + ); + expect(victims).toHaveLength(1); + }); + + it("ignores the dead", () => { + const victims = resolveBlast( + { x: 0, y: 1, z: 0 }, + "a", + 0, + [candidate({ alive: false })], + FLAT_MAP, + ); + expect(victims).toHaveLength(0); + }); + + it("still resolves when the grenade is resting against a wall", () => { + // The blast centre ends up a few centimetres inside the geometry, which a + // naive line-of-sight trace reads as "everything is behind cover". + const victims = resolveBlast( + { x: -0.55, y: 1, z: 0 }, + "a", + 0, + [candidate({ center: { x: -2.5, y: 1, z: 0 } })], + WALLED_MAP, + ); + expect(victims).toHaveLength(1); + }); +}); + +describe("throwing in a match", () => { + function match(): MatchSimulation { + const sim = new MatchSimulation({ matchId: "m", map: FLAT_MAP }); + sim.addPlayer({ id: "a", userId: "a", displayName: "A", preferredTeam: 0 }); + sim.addPlayer({ id: "b", userId: "b", displayName: "B", preferredTeam: 1 }); + sim.startNow(); + sim.step(); + return sim; + } + + it("spends a grenade and puts one in flight", () => { + const sim = match(); + const before = sim.players.get("a")?.grenades ?? 0; + + expect(sim.throwGrenade("a")).not.toBeNull(); + expect(sim.players.get("a")?.grenades).toBe(before - 1); + expect(sim.grenades.size).toBe(1); + }); + + it("enforces the cooldown rather than trusting the client to wait", () => { + const sim = match(); + expect(sim.throwGrenade("a")).not.toBeNull(); + // Same instant: a client spamming the message must not empty its pouch. + expect(sim.throwGrenade("a")).toBeNull(); + expect(sim.grenades.size).toBe(1); + }); + + it("runs out after the carried count", () => { + const sim = match(); + let thrown = 0; + for (let i = 0; i < 200; i += 1) { + if (sim.throwGrenade("a")) thrown += 1; + sim.step(); + } + expect(thrown).toBe(GRENADE_SPEC.carried); + expect(sim.players.get("a")?.grenades).toBe(0); + }); + + it("refuses a throw from a dead player", () => { + const sim = match(); + const player = sim.players.get("a"); + if (player) player.alive = false; + expect(sim.throwGrenade("a")).toBeNull(); + }); + + it("detonates on its fuse and reports the blast", () => { + const sim = match(); + sim.throwGrenade("a"); + + const events: SimEvent[] = []; + const ticks = Math.ceil(GRENADE_SPEC.fuseMs / TICK_MS) + 5; + for (let i = 0; i < ticks; i += 1) events.push(...sim.step()); + + expect(events.some((e) => e.type === "grenade_exploded")).toBe(true); + expect(sim.grenades.size).toBe(0); + }); + + it("throws forward and lands on the ground, not at the thrower's feet", () => { + const sim = match(); + const attacker = sim.players.get("a"); + if (!attacker) throw new Error("roster"); + attacker.movement.position = { x: 0, y: 0, z: 0 }; + attacker.movement.yaw = 0; + attacker.movement.pitch = 0; + + const thrown = sim.throwGrenade("a"); + if (!thrown) throw new Error("throw refused"); + + // Most of the fuse, but stop short of detonation so it is still readable. + for (let i = 0; i < Math.floor(GRENADE_SPEC.fuseMs / TICK_MS) - 2; i += 1) sim.step(); + + const inFlight = sim.grenades.get(thrown.id); + expect(inFlight, "grenade detonated earlier than its fuse").toBeDefined(); + // Well clear of the thrower, and resting on the floor rather than under it. + expect(Math.hypot(inFlight?.position.x ?? 0, inFlight?.position.z ?? 0)).toBeGreaterThan(5); + expect(inFlight?.position.y ?? -99).toBeGreaterThan(-0.2); + }); + + it("kills an enemy standing where it lands", () => { + const sim = match(); + const attacker = sim.players.get("a"); + const victim = sim.players.get("b"); + if (!attacker || !victim) throw new Error("roster"); + attacker.spawnProtectedUntilMs = 0; + victim.spawnProtectedUntilMs = 0; + + const thrown = sim.throwGrenade("a"); + if (!thrown) throw new Error("throw refused"); + + const events: SimEvent[] = []; + const total = Math.ceil(GRENADE_SPEC.fuseMs / TICK_MS) + 5; + for (let i = 0; i < total; i += 1) { + // Keep the victim on top of the grenade right up to detonation, so this + // tests the blast-to-kill path rather than the ballistics above. + const live = sim.grenades.get(thrown.id); + if (live) { + victim.movement.position = { x: live.position.x, y: 0, z: live.position.z }; + victim.spawnProtectedUntilMs = 0; + } + events.push(...sim.step()); + } + + expect(events.some((e) => e.type === "grenade_exploded")).toBe(true); + expect(events.some((e) => e.type === "kill" && e.victimId === "b")).toBe(true); + }); + + it("gives grenades back on respawn, not during a life", () => { + const sim = match(); + sim.throwGrenade("a"); + const spent = sim.players.get("a")?.grenades ?? 0; + expect(spent).toBe(GRENADE_SPEC.carried - 1); + + // A long time passes; the pouch must not refill on its own. + for (let i = 0; i < 300; i += 1) sim.step(); + expect(sim.players.get("a")?.grenades).toBe(spent); + + const player = sim.players.get("a"); + if (player) { + player.alive = false; + player.respawnAtMs = sim.elapsedMs; + } + for (let i = 0; i < 5; i += 1) sim.step(); + expect(sim.players.get("a")?.grenades).toBe(GRENADE_SPEC.carried); + }); +}); diff --git a/packages/multiplayer-sim/src/grenades.ts b/packages/multiplayer-sim/src/grenades.ts new file mode 100644 index 0000000..23929f1 --- /dev/null +++ b/packages/multiplayer-sim/src/grenades.ts @@ -0,0 +1,229 @@ +import { GRENADE_SPEC, grenadeDamageAt, type GrenadeSpec } from "@nightcell7/game-core"; +import { GRAVITY } from "./constants"; +import { raycastWorld } from "./hitscan"; +import type { CollisionMap } from "./map"; +import { add, distance, length, normalize, scale, sub, type Aabb, type Vec3 } from "./vec"; + +/** + * Grenade flight and detonation. + * + * Server-authoritative like everything else in this package (CLAUDE.md: the + * client sends intent, never a position or a hit claim). A thrown grenade is + * simulated here on the same fixed tick as movement, so two clients watching + * the same throw see it land in the same place. + * + * No Babylon, no DOM — this runs in the Railway container, in the browser as + * the prediction model, and in the tests. + */ + +export interface SimGrenade { + readonly id: string; + readonly ownerId: string; + /** Cached so a blast can be resolved after the thrower has left the match. */ + readonly ownerTeam: number; + position: Vec3; + velocity: Vec3; + /** Counts down; detonates at or below zero. */ + fuseRemainingMs: number; + /** Bounces so far, for the client's impact sounds. */ + bounces: number; + /** True once it has come to rest, so it stops costing collision work. */ + resting: boolean; +} + +export interface BlastVictim { + readonly playerId: string; + readonly damage: number; + readonly distanceM: number; +} + +/** A player as far as a blast is concerned. */ +export interface BlastCandidate { + readonly id: string; + readonly team: number; + /** Centre of mass, not the feet — a blast at head height should not miss. */ + readonly center: Vec3; + readonly alive: boolean; +} + +/** + * Advance a grenade by `dtMs`. + * + * Substepped, because a grenade leaves the hand at 15 m/s and a 33 ms tick + * would move it half a metre per step — enough to pass straight through the + * 0.4 m catwalk plates and land under the map. The substep is sized so it + * never travels more than its own radius. + */ +export function stepGrenade( + grenade: SimGrenade, + dtMs: number, + map: CollisionMap, + spec: GrenadeSpec = GRENADE_SPEC, +): void { + grenade.fuseRemainingMs -= dtMs; + if (grenade.resting) return; + + const dt = dtMs / 1000; + const speed = length(grenade.velocity); + const maxStep = spec.radiusM; + const steps = Math.max(1, Math.min(8, Math.ceil((speed * dt) / maxStep))); + const h = dt / steps; + + for (let i = 0; i < steps; i += 1) { + grenade.velocity = { + x: grenade.velocity.x, + y: grenade.velocity.y + GRAVITY * h, + z: grenade.velocity.z, + }; + + const next = add(grenade.position, scale(grenade.velocity, h)); + const blocked = firstBlockingAxis(grenade.position, next, map, spec.radiusM); + + if (!blocked) { + grenade.position = next; + continue; + } + + // Reflect along the blocked axis, damp the rest. Axis-aligned boxes make + // this exact without a general contact normal. + grenade.velocity = bounce(grenade.velocity, blocked, spec); + grenade.bounces += 1; + + // Come to rest rather than jittering forever on a floor. Below this the + // remaining motion is invisible and only costs collision work. + if (length(grenade.velocity) < 0.55) { + grenade.velocity = { x: 0, y: 0, z: 0 }; + grenade.resting = true; + return; + } + } +} + +/** True once the fuse has run out. */ +export function grenadeShouldDetonate(grenade: SimGrenade): boolean { + return grenade.fuseRemainingMs <= 0; +} + +/** + * Who a detonation hurts, and how much. + * + * Two rules that are not obvious from the numbers: + * + * * **Teammates are immune, the thrower is not.** Hitscan already skips + * teammates entirely, so grenades hurting them would make the throwable the + * one griefing vector in a free alpha. The thrower still takes a share, or + * the arc stops being a decision and every wall in reach gets a grenade. + * * **Cover works.** Damage requires an unobstructed line from the blast + * centre to the victim, so a grenade on the far side of a container is a + * noise. Without it the yard's three lanes stop mattering the moment + * anything is thrown. + */ +export function resolveBlast( + center: Vec3, + ownerId: string, + ownerTeam: number, + candidates: readonly BlastCandidate[], + map: CollisionMap, + spec: GrenadeSpec = GRENADE_SPEC, +): BlastVictim[] { + const victims: BlastVictim[] = []; + + for (const candidate of candidates) { + if (!candidate.alive) continue; + + const isOwner = candidate.id === ownerId; + if (!isOwner && candidate.team === ownerTeam) continue; + + const distanceM = distance(center, candidate.center); + if (distanceM >= spec.outerRadiusM) continue; + + if (!hasLineOfSight(center, candidate.center, map)) continue; + + let damage = grenadeDamageAt(distanceM, spec); + if (isOwner) damage *= spec.selfDamageFraction; + if (damage <= 0) continue; + + victims.push({ playerId: candidate.id, damage, distanceM }); + } + + return victims; +} + +/** + * Is there an unobstructed line between two points? + * + * The blast centre can end up inside a wall — a grenade resting against one + * detonates a few centimetres inside it — which would trace zero distance and + * shadow everything. The trace is therefore started slightly *toward* the + * target, and a hit is only blocking if it lands short of it. + */ +function hasLineOfSight(from: Vec3, to: Vec3, map: CollisionMap): boolean { + const delta = sub(to, from); + const span = length(delta); + if (span < 1e-4) return true; + + const direction = normalize(delta); + const origin = add(from, scale(direction, 0.12)); + const remaining = span - 0.12; + if (remaining <= 0) return true; + + const hit = raycastWorld(origin, direction, map, remaining); + // A hit exactly at the target is the target's own cover-free position. + return hit === null || hit.distance >= remaining - 1e-3; +} + +/** + * Which axis, if any, a move is blocked along. + * + * Each axis is tested independently and applied in turn, the same approach + * `stepMovement` uses for the player capsule, so a grenade sliding along a + * wall keeps its tangential speed instead of stopping dead. + */ +function firstBlockingAxis( + from: Vec3, + to: Vec3, + map: CollisionMap, + radius: number, +): "x" | "y" | "z" | null { + // Vertical first: the overwhelmingly common contact is a floor, and + // resolving it first stops a grenade skidding into a wall it never reached. + const axes: ("y" | "x" | "z")[] = ["y", "x", "z"]; + const at = { ...from }; + + for (const axis of axes) { + const candidate = { ...at, [axis]: to[axis] }; + if (overlapsWorld(candidate, radius, map)) return axis; + at[axis] = to[axis]; + } + return null; +} + +function bounce(velocity: Vec3, axis: "x" | "y" | "z", spec: GrenadeSpec): Vec3 { + const damped: Vec3 = { + x: velocity.x * spec.friction, + y: velocity.y * spec.friction, + z: velocity.z * spec.friction, + }; + damped[axis] = -velocity[axis] * spec.restitution; + return damped; +} + +function overlapsWorld(center: Vec3, radius: number, map: CollisionMap): boolean { + const box: Aabb = { + min: { x: center.x - radius, y: center.y - radius, z: center.z - radius }, + max: { x: center.x + radius, y: center.y + radius, z: center.z + radius }, + }; + for (const solid of map.boxes) { + if ( + box.min.x < solid.max.x && + box.max.x > solid.min.x && + box.min.y < solid.max.y && + box.max.y > solid.min.y && + box.min.z < solid.max.z && + box.max.z > solid.min.z + ) { + return true; + } + } + return false; +} diff --git a/packages/multiplayer-sim/src/index.ts b/packages/multiplayer-sim/src/index.ts index 30d100d..1c51814 100644 --- a/packages/multiplayer-sim/src/index.ts +++ b/packages/multiplayer-sim/src/index.ts @@ -10,6 +10,7 @@ export * from "./constants"; export * from "./map"; export * from "./movement"; export * from "./hitscan"; +export * from "./grenades"; export * from "./spawn"; export * from "./simulation"; export * from "./bots"; diff --git a/packages/multiplayer-sim/src/simulation.ts b/packages/multiplayer-sim/src/simulation.ts index 6e80709..1c2a59b 100644 --- a/packages/multiplayer-sim/src/simulation.ts +++ b/packages/multiplayer-sim/src/simulation.ts @@ -1,4 +1,5 @@ import { + GRENADE_SPEC, MULTIPLAYER_LOADOUT, TDM_RULES, applyDamage, @@ -19,16 +20,24 @@ import { type InputFrame, } from "@nightcell7/multiplayer-protocol"; import { EYE_HEIGHT_CROUCHED, EYE_HEIGHT_STANDING, MAX_REWIND_MS, TICK_MS } from "./constants"; +import { + grenadeShouldDetonate, + resolveBlast, + stepGrenade, + type BlastCandidate, + type SimGrenade, +} from "./grenades"; import { PositionHistory, resolveHitscan, rewindTicks, type HitCandidate } from "./hitscan"; import type { CollisionMap } from "./map"; import { createMovementState, isBelowKillPlane, + playerHeight, stepMovement, type MovementState, } from "./movement"; import { selectSpawn } from "./spawn"; -import { directionFromAngles, type Vec3 } from "./vec"; +import { add, directionFromAngles, scale, type Vec3 } from "./vec"; /** * The authoritative match simulation. @@ -74,6 +83,9 @@ export interface SimPlayer { ammo: AmmoState[]; nextFireAtMs: number; reloadingUntilMs: number; + /** Grenades left this life. Replenished on respawn, never mid-life. */ + grenades: number; + nextGrenadeAtMs: number; /** True while the trigger has been held since the last shot (semi-auto gate). */ triggerHeld: boolean; @@ -119,6 +131,25 @@ export type SimEvent = respawnAtMs: number; } | { type: "respawn"; playerId: string; position: Vec3; yaw: number; tick: number } + | { + type: "grenade_thrown"; + grenadeId: string; + ownerId: string; + team: number; + position: Vec3; + velocity: Vec3; + fuseMs: number; + tick: number; + } + | { + type: "grenade_exploded"; + grenadeId: string; + ownerId: string; + position: Vec3; + /** Everyone the blast reached, for client-side damage feedback. */ + victims: readonly { playerId: string; damage: number }[]; + tick: number; + } | { type: "match_end"; reason: "score_limit" | "time_limit"; @@ -157,7 +188,11 @@ export class MatchSimulation { winningTeam: number | null = null; terminationReason: "score_limit" | "time_limit" | null = null; + /** Grenades currently in flight, keyed by id. */ + readonly grenades = new Map(); + private readonly recentDeaths: { position: Vec3; atMs: number }[] = []; + private nextGrenadeSeq = 0; private events: SimEvent[] = []; private emitStartNextStep = false; @@ -205,6 +240,8 @@ export class MatchSimulation { }), nextFireAtMs: 0, reloadingUntilMs: 0, + grenades: GRENADE_SPEC.carried, + nextGrenadeAtMs: 0, triggerHeld: false, alive: true, respawnAtMs: 0, @@ -367,6 +404,10 @@ export class MatchSimulation { }); } + // After movement and history: a grenade detonating this tick should test + // against where players actually ended up, not where they started. + this.stepGrenades(); + if (this.phase === "live") { const outcome = evaluateMatchOutcome(this.scores, this.elapsedMs, this.rules); if (outcome.ended) { @@ -494,6 +535,149 @@ export class MatchSimulation { } /** Client intent to reload. Validated exactly like any other intent. */ + /** + * Throw a grenade from a player's authoritative eye position. + * + * The client asks; it does not say from where, in which direction, or with + * how many left. All three come from server state, so a tampered client can + * neither throw from somewhere it is not standing nor conjure a third + * grenade (PRD §18.3). + */ + throwGrenade(playerId: string): SimGrenade | null { + const player = this.players.get(playerId); + if (!player || !player.alive) return null; + if (player.grenades <= 0) return null; + if (this.elapsedMs < player.nextGrenadeAtMs) return null; + + player.grenades -= 1; + player.nextGrenadeAtMs = this.elapsedMs + GRENADE_SPEC.cooldownMs; + + const eye = { + x: player.movement.position.x, + y: + player.movement.position.y + + (player.movement.crouching ? EYE_HEIGHT_CROUCHED : EYE_HEIGHT_STANDING), + z: player.movement.position.z, + }; + const aim = directionFromAngles(player.movement.yaw, player.movement.pitch); + + // Start slightly ahead of the eye. Spawning exactly on it puts the grenade + // inside the thrower's own capsule, where the very first substep registers + // a wall and it bounces straight back into their feet. + const origin = add(eye, scale(aim, 0.45)); + + const grenade: SimGrenade = { + id: `${this.matchId}:g${(this.nextGrenadeSeq += 1)}`, + ownerId: player.id, + ownerTeam: player.team, + position: origin, + velocity: add(scale(aim, GRENADE_SPEC.throwSpeed), { + x: 0, + y: GRENADE_SPEC.throwLift, + z: 0, + }), + fuseRemainingMs: GRENADE_SPEC.fuseMs, + bounces: 0, + resting: false, + }; + // Inherit half the thrower's motion, so a grenade thrown while sprinting + // does not appear to be dropped behind them. + grenade.velocity = add(grenade.velocity, scale(player.movement.velocity, 0.5)); + + this.grenades.set(grenade.id, grenade); + this.events.push({ + type: "grenade_thrown", + grenadeId: grenade.id, + ownerId: player.id, + team: player.team, + position: { ...grenade.position }, + velocity: { ...grenade.velocity }, + fuseMs: GRENADE_SPEC.fuseMs, + tick: this.tick, + }); + return grenade; + } + + /** Advance every grenade in flight and detonate the ones whose fuse ran out. */ + private stepGrenades(): void { + for (const grenade of [...this.grenades.values()]) { + stepGrenade(grenade, TICK_MS, this.map); + if (!grenadeShouldDetonate(grenade)) continue; + this.grenades.delete(grenade.id); + this.detonate(grenade); + } + } + + private detonate(grenade: SimGrenade): void { + const candidates: BlastCandidate[] = [...this.players.values()].map((player) => ({ + id: player.id, + team: player.team, + // Chest height. Measuring to the feet would let a blast at head height + // on a catwalk miss the person standing in it. + center: { + x: player.movement.position.x, + y: player.movement.position.y + playerHeight(player.movement.crouching) * 0.5, + z: player.movement.position.z, + }, + alive: player.alive, + })); + + const victims = resolveBlast( + grenade.position, + grenade.ownerId, + grenade.ownerTeam, + candidates, + this.map, + ); + + const owner = this.players.get(grenade.ownerId) ?? null; + const applied: { playerId: string; damage: number }[] = []; + + for (const victim of victims) { + const player = this.players.get(victim.playerId); + if (!player || !player.alive) continue; + // Spawn protection holds against explosions too, or a grenade lobbed at + // a spawn exit beats the rule that protects it. + if (this.elapsedMs < player.spawnProtectedUntilMs) continue; + + const result = applyDamage({ health: player.health, armor: player.armor }, victim.damage); + player.health = result.vitals.health; + player.armor = result.vitals.armor; + applied.push({ playerId: player.id, damage: victim.damage }); + + if (owner && owner.id !== player.id) { + player.recentDamage.set(owner.id, { + amount: (player.recentDamage.get(owner.id)?.amount ?? 0) + victim.damage, + atMs: this.elapsedMs, + }); + } + + this.events.push({ + type: "hit", + attackerId: grenade.ownerId, + victimId: player.id, + damage: victim.damage, + armorAbsorbed: result.armorAbsorbed, + headshot: false, + tick: this.tick, + }); + + if (result.killed) { + // A grenade kill is credited to the thrower, including their own. + this.killPlayer(player, owner, null, false); + } + } + + this.events.push({ + type: "grenade_exploded", + grenadeId: grenade.id, + ownerId: grenade.ownerId, + position: { ...grenade.position }, + victims: applied, + tick: this.tick, + }); + } + requestReload(playerId: string): void { const player = this.players.get(playerId); if (!player || !player.alive) return; @@ -689,6 +873,10 @@ export class MatchSimulation { player.spawnProtectedUntilMs = this.elapsedMs + this.rules.spawnProtectionMs; player.reloadingUntilMs = 0; player.nextFireAtMs = 0; + // Grenades come back with a life, never during one — a resupply mid-fight + // turns the throwable into a second primary. + player.grenades = GRENADE_SPEC.carried; + player.nextGrenadeAtMs = 0; player.recentDamage.clear(); player.history.clear(); player.ammo = player.weapons.map((id) => { diff --git a/services/multiplayer/src/match-room.ts b/services/multiplayer/src/match-room.ts index 4cd679f..c9e69f2 100644 --- a/services/multiplayer/src/match-room.ts +++ b/services/multiplayer/src/match-room.ts @@ -273,6 +273,12 @@ export class MatchRoom extends Room { handle(CLIENT_MESSAGE.RELOAD, (client) => this.sim.requestReload(client.sessionId)); + // The client asks; the server decides whether there is a grenade left, + // whether the cooldown has passed, and where it leaves from. + handle(CLIENT_MESSAGE.THROW_GRENADE, (client) => { + this.sim.throwGrenade(client.sessionId); + }); + handle(CLIENT_MESSAGE.SWITCH_WEAPON, (client, payload) => { this.sim.requestWeaponSwitch(client.sessionId, (payload as { slot: number }).slot); }); @@ -391,6 +397,7 @@ export class MatchRoom extends Room { entry.armor = Math.round(player.armor); entry.weaponSlot = player.weaponSlot; entry.ammoInMagazine = player.ammo[player.weaponSlot]?.magazine ?? 0; + entry.grenades = player.grenades; entry.ammoReserve = player.ammo[player.weaponSlot]?.reserve ?? 0; entry.reloadingUntilMs = Math.max(0, Math.round(player.reloadingUntilMs)); entry.nextFireAtMs = Math.max(0, Math.round(player.nextFireAtMs)); @@ -430,6 +437,32 @@ export class MatchRoom extends Room { respawnAtMs: event.respawnAtMs, }); break; + case "grenade_thrown": + this.broadcast(SERVER_MESSAGE.GRENADE_THROWN, { + grenadeId: event.grenadeId, + ownerSessionId: event.ownerId, + team: event.team, + x: event.position.x, + y: event.position.y, + z: event.position.z, + vx: event.velocity.x, + vy: event.velocity.y, + vz: event.velocity.z, + fuseMs: event.fuseMs, + tick: event.tick, + }); + break; + case "grenade_exploded": + this.broadcast(SERVER_MESSAGE.GRENADE_EXPLODED, { + grenadeId: event.grenadeId, + ownerSessionId: event.ownerId, + x: event.position.x, + y: event.position.y, + z: event.position.z, + victimSessionIds: event.victims.map((victim) => victim.playerId), + tick: event.tick, + }); + break; case "respawn": this.broadcast(SERVER_MESSAGE.RESPAWN, { sessionId: event.playerId, diff --git a/shot-before.png b/shot-before.png new file mode 100644 index 0000000..fba8426 Binary files /dev/null and b/shot-before.png differ diff --git a/shot-pew.png b/shot-pew.png new file mode 100644 index 0000000..0b73f2d Binary files /dev/null and b/shot-pew.png differ diff --git a/tools/art/audio/generate.py b/tools/art/audio/generate.py index 74a6500..a274403 100644 --- a/tools/art/audio/generate.py +++ b/tools/art/audio/generate.py @@ -225,6 +225,56 @@ def impact_concrete(seed: int) -> np.ndarray: return fade(normalise(soft_clip(tick + puff + debris), peak=0.7)) +def grenade_blast(seed: int) -> np.ndarray: + """ + A frag detonation: crack, body, and a tail that outlasts both. + + Three layers, because a single enveloped noise burst reads as a door + slamming rather than as an explosion: + + * a very short high crack, which is what makes it read as *sharp* rather + than merely loud; + * a low body around 55 Hz, carrying the weight — this is the layer a + laptop speaker mostly cannot reproduce, so the blast must still work + without it; + * a long, dark tail of debris and yard reverberation. The tail is what + distinguishes an explosion from an impact, and it is deliberately most + of the clip's length. + + Deliberately not normalised to the same peak as a gunshot. A grenade that + merely matches the rifle in level does not feel bigger than one, so the + weight comes from spectrum and duration instead of clipping harder. + """ + rng = np.random.default_rng(seed) + n = int(RATE * 1.60) + + crack = noise(n, rng) * envelope(n, 0.0002, 0.010, power=1.6) + crack = one_pole_highpass(crack, 1800) * 0.85 + + body = noise(n, rng) * envelope(n, 0.002, 0.16, power=1.3) + body = one_pole_lowpass(body, 420) + # A falling sine under the noise gives the blast a pitch to drop, which is + # most of what "big" sounds like. + t = np.arange(n) / RATE + sweep = np.sin(2 * np.pi * (58 * np.exp(-t * 3.4)) * t) + body = body + sweep * envelope(n, 0.001, 0.22, power=1.5) * 0.8 + + tail = noise(n, rng) * envelope(n, 0.03, 0.85, power=0.75) * 0.42 + tail = bandpass(tail, 90, 2600) + + debris = np.zeros(n) + for _ in range(14): + at = rng.uniform(0.06, 0.9) + start = int(at * RATE) + length = min(n - start, int(RATE * 0.05)) + if length <= 0: + continue + piece = noise(length, rng) * envelope(length, 0.001, 0.02) + debris[start : start + length] += bandpass(piece, 900, 6500) * rng.uniform(0.05, 0.16) + + return fade(normalise(soft_clip(crack + body + tail + debris), peak=0.94), ms=40.0) + + def reload_clack(seed: int) -> np.ndarray: """Magazine out, magazine in, bolt release — three mechanical events.""" rng = np.random.default_rng(seed) @@ -316,6 +366,10 @@ def yard_ambience(seed: int, seconds: float = 12.0) -> np.ndarray: "step_concrete": (lambda s: footstep(s, "concrete"), 5, 3200), "step_grating": (lambda s: footstep(s, "grating"), 4, 3300), "impact_concrete": (impact_concrete, 4, 3400), + # Explosions are rare per match but very recognisable, so a repeat inside + # one firefight is obvious — three takes (CLAUDE.md: repeated sounds need + # variations). + "explosion": (grenade_blast, 3, 3800), } SINGLES = { diff --git a/tools/art/import-synty.mjs b/tools/art/import-synty.mjs index 0999a18..309ea02 100644 --- a/tools/art/import-synty.mjs +++ b/tools/art/import-synty.mjs @@ -96,6 +96,9 @@ const PROPS = [ { fbx: "Fbx/SM_Prop_AmmoBox_01", out: "prop_ammo_box", slot: "synty_atlas" }, { fbx: "Fbx/SM_Prop_Barrier_Tall_01", out: "prop_barrier", slot: "synty_atlas" }, { fbx: "Fbx/SM_Prop_WaterTank_02", out: "prop_water_tank", slot: "synty_atlas" }, + // The frag grenade goes through the prop path, not the weapon one: it is a + // single mesh with nothing to assemble and no muzzle to find. + { fbx: "Fbx/SM_Wep_Grenade_01", out: "wep_grenade", slot: "synty_weapons" }, ]; /**