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
38 changes: 38 additions & 0 deletions apps/game/src/hud.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ControllerStatus } from "./player";
import { DEFAULT_GAME_MODE, GAME_MODES, modeInfo, type GameMode } from "./modes";

/**
* HUD and start gate.
Expand All @@ -10,6 +11,9 @@ import type { ControllerStatus } from "./player";
*/

export interface HudOptions {
/** Preselected mode, and the sink for whichever the player picks. */
mode?: GameMode;
onModeChange?: (mode: GameMode) => void;
readonly renderer: string;
readonly mapName: string;
readonly mapChecksum: string;
Expand Down Expand Up @@ -122,6 +126,40 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud {
),
);

// Mode picker.
//
// Radios rather than buttons, because this is a choice that persists into the
// match rather than an action — and a radio group gives arrow-key navigation
// and a screen-reader announcement for free, which a row of divs would not
// (reduced motion and accessibility are P0).
let selected: GameMode = options.mode ?? DEFAULT_GAME_MODE;
const modes = el("fieldset", "modes");
const legend = el("legend", "modes__legend", "Game mode");
modes.append(legend);

const blurb = el("p", "modes__blurb", modeInfo(selected).blurb);

for (const mode of GAME_MODES) {
const label = el("label", "modes__option");
const input = document.createElement("input");
input.type = "radio";
input.name = "nc7-mode";
input.value = mode.id;
input.checked = mode.id === selected;
input.addEventListener("change", () => {
if (!input.checked) return;
selected = mode.id;
blurb.textContent = mode.blurb;
options.onModeChange?.(mode.id);
});
label.append(input);
label.append(el("span", "modes__name", mode.name));
modes.append(label);
}

modes.append(blurb);
gate.append(modes);

const button = el("button", "gate__button", "Enter the yard");
button.type = "button";
button.addEventListener("click", () => options.onStart());
Expand Down
54 changes: 46 additions & 8 deletions apps/game/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { fireIntervalMs, getWeapon, MULTIPLAYER_LOADOUT } from "@nightcell7/game
import { decideAccess, loadViewer, parseMode } from "./access";
import { modeLabel, renderGate } from "./gate";
import { createHud, renderFault } from "./hud";
import { GAME_MODE, preferredMode, rememberMode } from "./modes";
import { TrainingTargets } from "./targets";
import { requestedVantage } from "./photo";
import { PlayerController } from "./player";
import { Viewmodel } from "./viewmodel";
Expand All @@ -14,6 +16,20 @@ import { createRenderer, DynamicResolution } from "./renderer";
import { buildWorld } from "./world";
import "./style.css";

/**
* `localStorage` where it exists and is reachable.
*
* Reading the property itself throws in a browser with storage disabled — not
* only the methods on it — so the guard has to wrap the access, not the call.
*/
function safeStorage(): Storage | undefined {
try {
return window.localStorage;
} catch {
return undefined;
}
}

/**
* Game entry point.
*
Expand Down Expand Up @@ -129,16 +145,37 @@ async function boot(): Promise<void> {
// The flash lights the yard, not the gun held in front of it.
effects.excludeFromFlash(viewmodel.meshes());

// Live opponents. These run the real MatchSimulation and BotController, so
// they move, aim and shoot under exactly the rules the server enforces.
const opponents = new Opponents(scene, world.assets);
// What is in the yard, decided by the chosen mode.
//
// `Opponents` is built in every mode, including the ones with no bots: it
// owns the `MatchSimulation`, which is where the player's own grenade count,
// cooldown and blast are resolved. An empty roster is a supported
// configuration, not a degenerate one.
const gameMode = preferredMode(window.location.search, safeStorage());
const roster = gameMode === GAME_MODE.DEATHMATCH ? {} : ({ enemies: 0, friendlies: 0 } as const);
const opponents = new Opponents(scene, world.assets, roster);

// Stationary targets, for the range only. They are presentation-only hit
// volumes; nothing here is scored.
const targets = gameMode === GAME_MODE.RANGE ? new TrainingTargets(scene, world.assets) : null;

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

const hud = createHud(ui, {
renderer: kind,
mapName: ARDAVAN_YARD.displayName,
mapChecksum: checksum,
mode: gameMode,
// Remembered immediately rather than on start, so a player who picks a mode
// and then reloads does not silently lose the choice.
//
// Changing it reloads: the yard is dressed at boot, and swapping a roster
// and a set of targets live would be a second, subtly different code path
// for something that happens once before a match.
onModeChange: (next) => {
rememberMode(next, safeStorage());
if (next !== gameMode) window.location.search = `?mode=${next}`;
},
onStart: () => player.requestLock(),
});

Expand Down Expand Up @@ -198,17 +235,18 @@ async function boot(): Promise<void> {
// 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 onTarget = opponents.tryHit(
{ x: eye.x, y: eye.y, z: eye.z },
{ x: aim.x, y: aim.y, z: aim.z },
effects.trace(eye, aim).distance,
);
const reach = effects.trace(eye, aim).distance;
const from = { x: eye.x, y: eye.y, z: eye.z };
const along = { x: aim.x, y: aim.y, z: aim.z };
const onTarget =
opponents.tryHit(from, along, reach) ?? targets?.tryHit(from, along, reach) ?? null;
// A hit on a person gets the heavy burst.
effects.fire(muzzle, eye, aim, onTarget?.point, onTarget !== null);
}
}
effects.update();
opponents.update(deltaMs, status.position, camera.rotation.y);
targets?.update();
// Draw whatever the bots shot at this tick, so incoming fire is visible.
for (const shot of opponents.drainShots()) {
effects.tracerOnly(shot.from, shot.to);
Expand Down
103 changes: 103 additions & 0 deletions apps/game/src/modes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_GAME_MODE,
GAME_MODE,
GAME_MODES,
modeInfo,
preferredMode,
rememberMode,
} from "./modes";

/** A Storage stand-in, plus one that throws the way a blocked store does. */
function memoryStorage(seed: Record<string, string> = {}): Storage {
const map = new Map(Object.entries(seed));
return {
get length() {
return map.size;
},
clear: () => map.clear(),
getItem: (k: string) => map.get(k) ?? null,
key: (i: number) => [...map.keys()][i] ?? null,
removeItem: (k: string) => void map.delete(k),
setItem: (k: string, v: string) => void map.set(k, v),
};
}

function hostileStorage(): Storage {
const fail = () => {
throw new Error("SecurityError: storage is blocked");
};
return {
get length(): number {
return fail();
},
clear: fail,
getItem: fail,
key: fail,
removeItem: fail,
setItem: fail,
} as unknown as Storage;
}

describe("game mode selection", () => {
it("defaults to deathmatch with no hint at all", () => {
expect(preferredMode("", memoryStorage())).toBe(DEFAULT_GAME_MODE);
expect(DEFAULT_GAME_MODE).toBe(GAME_MODE.DEATHMATCH);
});

it("honours an explicit mode in the query string", () => {
expect(preferredMode("?mode=range", memoryStorage())).toBe(GAME_MODE.RANGE);
expect(preferredMode("?mode=roam", memoryStorage())).toBe(GAME_MODE.ROAM);
});

it("falls back to the remembered choice", () => {
const storage = memoryStorage({ "nc7.mode": GAME_MODE.ROAM });
expect(preferredMode("", storage)).toBe(GAME_MODE.ROAM);
});

it("lets the query string beat the remembered choice", () => {
// A shared link has to open what it says, whatever this browser last chose.
const storage = memoryStorage({ "nc7.mode": GAME_MODE.ROAM });
expect(preferredMode("?mode=range", storage)).toBe(GAME_MODE.RANGE);
});

it("ignores an unknown mode rather than booting into nothing", () => {
expect(preferredMode("?mode=battle-royale", memoryStorage())).toBe(DEFAULT_GAME_MODE);
expect(preferredMode("", memoryStorage({ "nc7.mode": "nonsense" }))).toBe(DEFAULT_GAME_MODE);
});

it("ignores the access-mode values, which share the parameter name", () => {
// `access.ts` reads `?mode=` too, for demo/campaign/multiplayer. Those are
// never scene ids, so they must fall through here rather than matching.
for (const access of ["demo", "campaign", "multiplayer", "sandbox"]) {
expect(preferredMode(`?mode=${access}`, memoryStorage())).toBe(DEFAULT_GAME_MODE);
}
});

it("survives storage that throws on every access", () => {
// Private browsing and blocked third-party storage throw on read *and*
// write. Neither is worth failing a boot over.
expect(() => preferredMode("", hostileStorage())).not.toThrow();
expect(preferredMode("", hostileStorage())).toBe(DEFAULT_GAME_MODE);
expect(() => rememberMode(GAME_MODE.RANGE, hostileStorage())).not.toThrow();
});

it("round-trips a remembered mode", () => {
const storage = memoryStorage();
rememberMode(GAME_MODE.RANGE, storage);
expect(preferredMode("", storage)).toBe(GAME_MODE.RANGE);
});

it("works with no storage at all", () => {
expect(preferredMode("", undefined)).toBe(DEFAULT_GAME_MODE);
expect(() => rememberMode(GAME_MODE.ROAM, undefined)).not.toThrow();
});

it("describes every mode it offers", () => {
for (const mode of GAME_MODES) {
expect(mode.name.length, `${mode.id} has no name`).toBeGreaterThan(0);
expect(mode.blurb.length, `${mode.id} has no blurb`).toBeGreaterThan(0);
expect(modeInfo(mode.id)).toBe(mode);
}
});
});
99 changes: 99 additions & 0 deletions apps/game/src/modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Game modes offered on the start gate.
*
* All three are single-player against NPCs. That is deliberate and is not a
* placeholder for "real multiplayer later": PRD §23.1 draws the line at
* *access*, not at content — browsing, the demo and the technical scenes need
* no account, while online multiplayer needs a verified one. Everything here
* sits on the free side of that line, so `/play` stays openable by anyone.
*
* These are also not new competitive modes. The locked V1 decision is one
* multiplayer mode — 6v6 Team Deathmatch — and inventing a second would
* contradict it. `range` and `roam` are the "training" and "greybox" technical
* scenes the PRD already lists under the sandbox; both were built and neither
* had an entry point, so the picker exposes work that already existed rather
* than adding scope.
*/

export const GAME_MODE = {
/** The existing sandbox: live bots on both teams, scored. */
DEATHMATCH: "deathmatch",
/** Stationary targets. Nothing moves, nothing shoots back. */
RANGE: "range",
/** The empty yard. */
ROAM: "roam",
} as const;

export type GameMode = (typeof GAME_MODE)[keyof typeof GAME_MODE];

export interface GameModeInfo {
readonly id: GameMode;
readonly name: string;
/** One line on the gate. Says what the player will actually meet. */
readonly blurb: string;
}

export const GAME_MODES: readonly GameModeInfo[] = [
{
id: GAME_MODE.DEATHMATCH,
name: "Team Deathmatch",
blurb: "Four Directorate against you and three Nightcell. They flank, shoot back and throw.",
},
{
id: GAME_MODE.RANGE,
name: "Firing Range",
blurb: "Stationary targets. Nothing returns fire — for learning the rifle and the grenade arc.",
},
{
id: GAME_MODE.ROAM,
name: "Free Roam",
blurb: "The yard, empty. Movement and level geometry with nothing shooting at you.",
},
];

export const DEFAULT_GAME_MODE: GameMode = GAME_MODE.DEATHMATCH;

/** Storage key for the last mode chosen, so the gate reopens where you left it. */
const STORAGE_KEY = "nc7.mode";

function isGameMode(value: string | null): value is GameMode {
return GAME_MODES.some((mode) => mode.id === value);
}

/**
* Which mode to preselect.
*
* `?mode=` is checked first so a link can open a specific scene — useful for
* a bug report or a capture — and the remembered choice second. Note this is a
* *different* `mode` parameter from `access.ts`'s: that one selects who may
* play (demo, campaign, multiplayer), this one selects what is in the yard.
* They never collide because the access values are not mode ids, and an
* unrecognised value falls through to the default either way.
*/
export function preferredMode(search: string, storage?: Storage): GameMode {
const requested = new URLSearchParams(search).get("mode");
if (isGameMode(requested)) return requested;

try {
const remembered = storage?.getItem(STORAGE_KEY) ?? null;
if (isGameMode(remembered)) return remembered;
} catch {
// Private browsing and blocked storage both throw on access rather than
// returning null. A forgotten preference is not worth failing a boot over.
}

return DEFAULT_GAME_MODE;
}

/** Remember the chosen mode. Failure here is never worth interrupting play. */
export function rememberMode(mode: GameMode, storage?: Storage): void {
try {
storage?.setItem(STORAGE_KEY, mode);
} catch {
// As above.
}
}

export function modeInfo(mode: GameMode): GameModeInfo {
return GAME_MODES.find((entry) => entry.id === mode) ?? GAME_MODES[0]!;
}
Loading
Loading