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
49 changes: 49 additions & 0 deletions apps/game/src/hud.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

/**
* Guards the HUD's one silent coupling: `#ui` sets `pointer-events: none`.
*
* That rule exists so the overlay never eats a click meant for the canvas, and
* it is re-enabled for exactly three selectors — `button`, `a` and
* `[data-interactive]`. Anything interactive built from another element is
* inert unless it opts in, and *nothing fails* when it does not: the control
* renders correctly, highlights nothing and swallows every click. The game-mode
* picker shipped to production in exactly that state, where it read as two
* disabled options.
*
* These read source text rather than a rendered DOM because the suite runs on
* `environment: "node"`. That is a weaker test than clicking the control, and
* it is the one that would actually have caught this.
*/

const SRC = __dirname;
const hud = readFileSync(join(SRC, "hud.ts"), "utf8");
const css = readFileSync(join(SRC, "style.css"), "utf8");

describe("HUD interactivity", () => {
it("keeps the global pointer-events escape hatch it depends on", () => {
// If this selector list ever changes, every control below has to be
// re-checked — which is the point of asserting on it here.
expect(css).toMatch(/#ui button,\s*#ui a,\s*#ui \[data-interactive\]/);
expect(css).toMatch(/pointer-events: auto/);
});

it("marks the mode picker's labels interactive", () => {
// A <label> is neither a button nor an anchor, so it must opt in.
expect(hud).toMatch(/label\.dataset\.interactive/);
});

it("builds the picker from labels wrapping real radios", () => {
// Not divs with click handlers: the native control is what gives arrow-key
// navigation and a screen-reader announcement (accessibility is P0).
expect(hud).toMatch(/input\.type = "radio"/);
expect(hud).toMatch(/modes__option/);
});

it("re-enables pointer events on the mode option in CSS as well", () => {
const rule = css.slice(css.indexOf(".modes__option {"));
expect(rule.slice(0, rule.indexOf("}"))).toMatch(/pointer-events: auto/);
});
});
7 changes: 7 additions & 0 deletions apps/game/src/hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud {

for (const mode of GAME_MODES) {
const label = el("label", "modes__option");
// `#ui` sets `pointer-events: none` so the HUD never eats a click meant for
// the canvas, and re-enables it only for `button`, `a` and
// `[data-interactive]`. A label is none of those, so without this the whole
// picker is inert — it renders, highlights nothing and swallows every
// click, which reads exactly like two disabled options. It shipped that
// way; see the guard in hud.test.ts.
label.dataset.interactive = "";
const input = document.createElement("input");
input.type = "radio";
input.name = "nc7-mode";
Expand Down
13 changes: 12 additions & 1 deletion apps/game/src/opponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
spawnsForTeam,
} from "@nightcell7/multiplayer-sim";
import { placeAll, placeAnimated, type AssetSet } from "./assets";
import { TEAM_PALETTE, brightenCharacter } from "./targets";

/** Enemies on the Directorate side, and friendlies on the player's. */
const ENEMY_COUNT = 4;
Expand Down Expand Up @@ -165,7 +166,7 @@ export class Opponents {
// They fought empty-handed until now, which read as unfinished from any
// distance. Two silhouettes rather than one: the Directorate carries the
// rifle, Nightcell the SMG, so which side a figure is on is legible before
// the team tint on its marker resolves.
// the tint confirms it.
const weaponFor = (team: number) =>
assets.models.get(team === TEAM_IDS.DIRECTORATE ? "wep_rifle" : "wep_smg") ?? null;

Expand Down Expand Up @@ -228,6 +229,16 @@ export class Opponents {
});
if (!placed) return;

// Colour the figure by side.
//
// Without this both teams are the *same model with the same materials*,
// so the only difference between a friendly and an enemy is which weapon
// it holds — invisible from the front, and at any range that matters.
brightenCharacter(
placed.root,
entry.team === TEAM_IDS.NIGHTCELL ? TEAM_PALETTE.friendly : TEAM_PALETTE.enemy,
);

attachWeapon(placed.root, weaponFor(entry.team), id);

this.views.set(id, {
Expand Down
4 changes: 4 additions & 0 deletions apps/game/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,10 @@ body {

.modes__option {
cursor: pointer;
/* Belt and braces with the `data-interactive` attribute the markup sets:
`#ui` disables pointer events wholesale, and a label is not one of the
elements the global rule re-enables. */
pointer-events: auto;
}

.modes__option input {
Expand Down
37 changes: 34 additions & 3 deletions apps/game/src/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,35 @@ export interface TargetHit {
* band read at range. This is a readability decision, not a fidelity one — an
* enemy you cannot resolve is a broken game, not a moody one.
*/
export function brightenCharacter(root: TransformNode): void {
/**
* Team colours.
*
* Two axes, not one, because a single hue on a small band is not readable at
* the ranges this yard plays at. The cloth carries a warm/cool split that reads
* as a silhouette at 40 m, and the band carries the saturated hue that confirms
* it up close. One or the other alone was not enough: the bots previously
* differed only by which weapon they held, which is invisible from the front.
*/
export interface TeamPalette {
/** Webbing and plate — the small, saturated identifier. */
readonly band: Color3;
/** Uniform cloth — the large, desaturated one. */
readonly cloth: Color3;
}

export const TEAM_PALETTE: { readonly friendly: TeamPalette; readonly enemy: TeamPalette } = {
// Nightcell: cool, grey-green, signal cyan. Matches the HUD's own cyan, which
// is already the colour of "yours" everywhere else in the interface.
friendly: { band: new Color3(0.16, 0.62, 0.74), cloth: new Color3(0.5, 0.58, 0.58) },
// Directorate: warm khaki and red, which is the palette the yard's own
// containers and hazard paint already use for "theirs".
enemy: { band: new Color3(0.86, 0.26, 0.2), cloth: new Color3(0.78, 0.68, 0.52) },
};

export function brightenCharacter(root: TransformNode, palette?: TeamPalette): void {
// Local to this call. The cache exists to share one clone across the meshes
// of a single figure; making it module-level would hand the second team the
// first team's colours, because the *source* material is shared by both.
const localised = new Map<Material, Material>();

for (const mesh of root.getChildMeshes() as Mesh[]) {
Expand Down Expand Up @@ -104,7 +132,8 @@ export function brightenCharacter(root: TransformNode): void {
// exposure 2.05 and produced a featureless pale figure. The characters
// stopped needing any lift the moment the scene got brighter; what
// they need is colour.
clone.albedoColor = isTeam ? new Color3(0.86, 0.3, 0.26) : new Color3(0.78, 0.68, 0.52);
const team = palette ?? TEAM_PALETTE.enemy;
clone.albedoColor = isTeam ? team.band : team.cloth;

// Metallic and roughness are left to the ORM texture. Overriding them
// to flat values was the other half of the plastic look: it removed
Expand All @@ -117,7 +146,9 @@ export function brightenCharacter(root: TransformNode): void {
// No self-illumination on cloth — it is lit plenty. Only the team band
// gets a trace, and only enough to survive the bloom threshold rather
// than glow through it.
clone.emissiveColor = isTeam ? new Color3(0.05, 0.008, 0.008) : new Color3(0, 0, 0);
// Scaled from the band colour rather than hard-coded red, so a cyan
// team gets a cyan trace instead of a red one on a blue-grey figure.
clone.emissiveColor = isTeam ? team.band.scale(0.06) : new Color3(0, 0, 0);
}
localised.set(source, clone);
}
Expand Down
Loading