Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/game/public/assets/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions apps/game/public/assets/manifest.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
}
Binary file added apps/game/public/assets/models/wep_grenade.glb
Binary file not shown.
1 change: 1 addition & 0 deletions apps/game/public/audio/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Binary file added apps/game/public/audio/explosion_01.mp3
Binary file not shown.
Binary file added apps/game/public/audio/explosion_02.mp3
Binary file not shown.
Binary file added apps/game/public/audio/explosion_03.mp3
Binary file not shown.
2 changes: 2 additions & 0 deletions apps/game/src/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe("generated models", () => {
"wep_rifle",
"wep_smg",
"wep_sniper",
"wep_grenade",
]);

it("only uses material slots the engine can bind", () => {
Expand Down Expand Up @@ -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"];

Expand Down
1 change: 1 addition & 0 deletions apps/game/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const MODELS = [
"wep_rifle",
"wep_smg",
"wep_sniper",
"wep_grenade",
] as const;

export type ModelName = (typeof MODELS)[number];
Expand Down
16 changes: 16 additions & 0 deletions apps/game/src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
25 changes: 23 additions & 2 deletions apps/game/src/hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -29,6 +29,7 @@ const KEYS: ReadonlyArray<readonly [string, string]> = [
["Ctrl / C", "Crouch"],
["Space", "Jump"],
["Mouse", "Look"],
["G", "Throw frag"],
["Esc", "Release cursor"],
];

Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion apps/game/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ async function boot(): Promise<void> {
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();
Expand Down Expand Up @@ -206,7 +213,11 @@ async function boot(): Promise<void> {
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();
});

Expand Down
114 changes: 114 additions & 0 deletions apps/game/src/opponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
TEAM_IDS,
TICK_MS,
type SimEvent,
type SimGrenade,
type SimPlayer,
type Vec3,
spawnsForTeam,
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, number>();
private readonly grenadeViews = new Map<string, GrenadeView>();
private readonly explosions: Explosion[] = [];
private readonly grenadeModel: AssetContainer | null;

constructor(_scene: Scene, assets: AssetSet) {
// Back on the generated character.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
Loading
Loading