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
6 changes: 3 additions & 3 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": "f24a07a4ca2a223aed5561a9ab08ce87dea1f262",
"commit": "41950b6cf09a903f257d65c8804b2289e65981c2",
"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 Down Expand Up @@ -67,9 +67,9 @@
"ui_hover.mp3"
],
"bytes": {
"models": 1235920,
"models": 1232844,
"textures": 1971598,
"audio": 326680,
"total": 3534198
"total": 3531122
}
}
Binary file modified apps/game/public/assets/models/character.glb
Binary file not shown.
18 changes: 17 additions & 1 deletion apps/game/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PlayerController } from "./player";
import { Viewmodel } from "./viewmodel";
import { GameAudio } from "./audio";
import { WeaponEffects } from "./vfx";
import { TrainingTargets } from "./targets";
import { createRenderer, DynamicResolution } from "./renderer";
import { buildWorld } from "./world";
import "./style.css";
Expand Down Expand Up @@ -128,6 +129,9 @@ async function boot(): Promise<void> {
// The flash lights the yard, not the gun held in front of it.
effects.excludeFromFlash(viewmodel.meshes());

// Something to shoot at. PRD §40 wants "one enemy" before anything else.
const targets = new TrainingTargets(scene, world.assets);

const player = new PlayerController(scene, camera, canvas, ARDAVAN_YARD, spawn);

const hud = createHud(ui, {
Expand Down Expand Up @@ -178,10 +182,22 @@ async function boot(): Promise<void> {
// off the crosshair at close range.
const eye = camera.globalPosition;
const aim = camera.getDirection(Vector3.Forward());
effects.fire(viewmodel.muzzlePosition() ?? eye, eye, aim);
const muzzle = viewmodel.muzzlePosition() ?? eye;

// A target only counts if it is in front of whatever the round would
// otherwise hit, so one standing behind a container cannot be shot
// through it.
const world_ = effects.trace(eye, aim);
const onTarget = targets.tryHit(
{ x: eye.x, y: eye.y, z: eye.z },
{ x: aim.x, y: aim.y, z: aim.z },
world_.distance,
);
effects.fire(muzzle, eye, aim, onTarget?.point);
}
}
effects.update();
targets.update();
hud.update(status, engine.getFps());
scene.render();
});
Expand Down
149 changes: 149 additions & 0 deletions apps/game/src/targets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Vector3, type Scene, type TransformNode } from "@babylonjs/core";
import { rayAabb, type Aabb, type Vec3 } from "@nightcell7/multiplayer-sim";
import { placeAll, type AssetSet } from "./assets";

/**
* Training targets.
*
* PRD §40: "a grey room, one enemy, and one rifle must already feel good."
* There was no enemy. `character.glb` was built and then had no consumer — the
* multiplayer entry point that would render remote players does not exist yet —
* so single-player was an empty yard with a working gun and nothing to point it
* at.
*
* These are deliberately *training targets*, not AI opponents: they stand,
* they take hits, they fall, they reset. There is no animation system and no
* bot pathing on the client, and a figure sliding around the yard frozen in a
* standing pose would look worse than one that honestly stands still.
*
* Hit detection here is **presentation only**, exactly like the weapon effects.
* In a real match the server owns hit registration through `resolveHitscan`.
* This exists so the single-player sandbox has feedback; it awards nothing and
* is not reported anywhere.
*/

/** Where targets stand. All verified clear of the collision volumes. */
const POSITIONS: ReadonlyArray<readonly [number, number]> = [
[-4, 9],
[10, 18],
[-12, 24],
[14, 31],
[-18, 35],
];

/** Matches the simulated player capsule, so shooting one feels honest. */
const HALF_WIDTH = 0.3;
const HEIGHT = 1.8;

/** How long a target lies down before standing back up, ms. */
const DOWN_MS = 3200;
/** How long the fall takes, ms. */
const FALL_MS = 420;

interface Target {
root: TransformNode;
readonly origin: Vector3;
readonly box: Aabb;
/** Timestamp the target was hit, or 0 while standing. */
downAt: number;
}

export interface TargetHit {
readonly point: Vec3;
readonly distance: number;
}

export class TrainingTargets {
private readonly targets: Target[] = [];

constructor(scene: Scene, assets: AssetSet) {
const container = assets.models.get("character");
if (!container) throw new Error("character model not loaded");

const roots = placeAll(
container,
"target",
POSITIONS.map(([x, z]) => ({
position: new Vector3(x, 0, z),
// Facing south, toward the Nightcell spawn a player enters from.
rotationY: 0,
})),
);

roots.forEach((root, index) => {
const spot = POSITIONS[index];
if (!spot) return;
const [x, z] = spot;
this.targets.push({
root,
origin: new Vector3(x, 0, z),
box: {
min: { x: x - HALF_WIDTH, y: 0, z: z - HALF_WIDTH },
max: { x: x + HALF_WIDTH, y: HEIGHT, z: z + HALF_WIDTH },
},
downAt: 0,
});
});

void scene;
}

/**
* Nearest target along the ray, or null.
*
* `maxDistance` should be the distance to the world geometry behind, so a
* target standing behind a container cannot be shot through it.
*/
tryHit(origin: Vec3, direction: Vec3, maxDistance: number): TargetHit | null {
let nearest: Target | null = null;
let nearestHit: TargetHit | null = null;

for (const target of this.targets) {
if (target.downAt) continue; // already down
const hit = rayAabb(origin, direction, target.box, maxDistance);
if (!hit) continue;
if (!nearestHit || hit.distance < nearestHit.distance) {
nearestHit = { point: hit.point, distance: hit.distance };
nearest = target;
}
}

if (nearest) nearest.downAt = performance.now();
return nearestHit;
}

/** Animate falls and stand targets back up. Call once per frame. */
update(): void {
const now = performance.now();

for (const target of this.targets) {
if (!target.downAt || target.downAt < 0) continue;

const elapsed = now - target.downAt;
if (elapsed >= DOWN_MS) {
target.downAt = 0;
target.root.rotation.x = 0;
target.root.position.copyFrom(target.origin);
continue;
}

// Topple backwards about the feet over FALL_MS, then lie still.
const t = Math.min(1, elapsed / FALL_MS);
// Ease out, so it drops fast and settles rather than rotating linearly.
const eased = 1 - (1 - t) * (1 - t);
target.root.rotation.x = eased * (Math.PI / 2);
// Compensate the pivot: rotating about the origin would sink the body
// into the ground, since the model's origin is at its feet.
target.root.position.set(
target.origin.x,
target.origin.y + eased * 0.28,
target.origin.z - eased * 0.55,
);
}
}

dispose(): void {
for (const target of this.targets) target.root.dispose();
this.targets.length = 0;
}
}
24 changes: 16 additions & 8 deletions apps/game/src/vfx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,16 @@ export class WeaponEffects {
* crosshair at close range
* @param direction normalised aim direction
*/
fire(origin: Vector3, eye: Vector3, direction: Vector3): void {
/** Where a round fired from `eye` would land on world geometry. */
trace(eye: Vector3, direction: Vector3): ShotTrace {
return traceShot(
{ x: eye.x, y: eye.y, z: eye.z },
{ x: direction.x, y: direction.y, z: direction.z },
this.map,
);
}

fire(origin: Vector3, eye: Vector3, direction: Vector3, override?: Vec3): void {
const now = performance.now();

// ---- muzzle flash
Expand All @@ -350,15 +359,14 @@ export class WeaponEffects {
this.flashUntil = now + FLASH_LIFE;

// ---- where does it land? Same raycast and same map the server uses.
const trace = traceShot(
{ x: eye.x, y: eye.y, z: eye.z },
{ x: direction.x, y: direction.y, z: direction.z },
this.map,
);
const end = toVector(trace.point);
// `override` is a closer hit — a training target standing in front of the
// geometry — so the round stops there instead of passing through it.
const trace = this.trace(eye, direction);
const landed = override ?? trace.point;
const end = toVector(landed);

this.spawnTracer(origin, end, now);
if (trace.hit) this.spawnImpact(end, direction, now);
if (override || trace.hit) this.spawnImpact(end, direction, now);
}

private spawnTracer(from: Vector3, to: Vector3, now: number): void {
Expand Down
Loading
Loading