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
33 changes: 33 additions & 0 deletions apps/game/public/assets/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,36 @@ Kept: `Idle_Gun`, `Idle_Gun_Shoot`, `Walk`, `Run`, `Run_Shoot`, `Death`,
These carry their own rig, materials and animations, so they do not follow the
material-slot or `COL_` conventions the generated props use;
`apps/game/src/assets.test.ts` exempts them explicitly.

## Synty POLYGON Military

| File | Source | Licence |
| --- | --- | --- |
| `models/fighter_insurgent.glb` | Synty POLYGON Military — `SK_Chr_Insurgent_Male_01` | Synty Store licence (purchased) |
| `models/fighter_soldier.glb` | Synty POLYGON Military — `SK_Chr_Soldier_Male_01` | Synty Store licence (purchased) |
| `textures/synty_atlas.webp` | Synty POLYGON Military — `PolygonMilitary_Texture_01_A` | Synty Store licence (purchased) |

Purchased from https://syntystore.com. The licence grants perpetual,
royalty-free commercial use in unlimited titles and permits modification; it
does **not** permit reselling the assets as assets. Not copyright ownership,
which only a work-for-hire commission gives.

Source files are **not** committed — the pack is 406 MB unpacked and lives
outside the repo. Only the converted GLBs and the one shared atlas ship.

Converted with `tools/art/blender/import_synty.py`, which does two things the
plain glTF path cannot:

- **Retargets animation.** Synty's SourceFiles carry a bind pose and nothing
else; the walk and run cycles are in the Unity and Unreal packages, which are
engine-locked. The script maps our CC0 clip set onto Synty's Unreal-standard
55-bone skeleton with copy-rotation constraints and bakes the result. Copying
f-curves directly does not work — curves live in each bone's rest space and
the two rigs do not share rest orientations, so the result is a figure with
its arms through its chest.
- **Shares the atlas.** Synty puts every model in the pack on one 4096 texture.
Embedding it per character cost 2.48 MB each; binding it once by material
name costs 357 KB total and takes each character to ~650 KB.

Kept clips: `Idle_Gun`, `Idle_Gun_Shoot`, `Walk`, `Run`, `Run_Shoot`, `Death`,
`HitRecieve`.
15 changes: 8 additions & 7 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": "e132eb8caf8583e204fe8a2292219ebce39c6955",
"commit": "af4fbdcb5b822384fb6f8c3f84e98141465453aa",
"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 @@ -11,8 +11,8 @@
"character.glb",
"container.glb",
"deck.glb",
"fighter_adventurer.glb",
"fighter_swat.glb",
"fighter_insurgent.glb",
"fighter_soldier.glb",
"hardpoint.glb",
"lamp_mast.glb",
"pipe_rack.glb",
Expand Down Expand Up @@ -42,7 +42,8 @@
"rust_orm.webp",
"steel_albedo.webp",
"steel_normal.webp",
"steel_orm.webp"
"steel_orm.webp",
"synty_atlas.webp"
],
"audio": [
"ambience_yard.mp3",
Expand Down Expand Up @@ -77,9 +78,9 @@
"throngva/the-wolf-called-want.mp3"
],
"bytes": {
"models": 4206012,
"textures": 1971598,
"models": 2724980,
"textures": 2328964,
"audio": 323166,
"total": 6500776
"total": 5377110
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed apps/game/public/assets/models/fighter_swat.glb
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/game/src/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe("generated models", () => {
* apply. Their provenance is a licence recorded in PROVENANCE.md rather than
* a generator script.
*/
const LICENSED = new Set(["fighter_swat", "fighter_adventurer"]);
const LICENSED = new Set(["fighter_insurgent", "fighter_soldier"]);

it("only uses material slots the engine can bind", () => {
// A slot the engine does not know about is not an error at load time: the
Expand Down
21 changes: 19 additions & 2 deletions apps/game/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export const MODELS = [
// Licensed CC0 characters (Quaternius). These carry their own rig,
// animations and materials, so they bypass the material-slot convention the
// generated props follow — see apps/game/public/assets/PROVENANCE.md.
"fighter_swat",
"fighter_adventurer",
"fighter_insurgent",
"fighter_soldier",
] as const;

export type ModelName = (typeof MODELS)[number];
Expand Down Expand Up @@ -131,6 +131,23 @@ export function createMaterials(scene: Scene): Map<string, PBRMaterial | Standar
materials.set(name, material);
}

// Synty characters share one 4096 atlas across every model in the pack, so
// it is bound once here rather than embedded in each GLB.
const synty = new PBRMaterial("synty_atlas", scene);
synty.albedoTexture = loadTexture(scene, "synty_atlas.webp", true);
synty.metallic = 0;
synty.roughness = 0.9;
// `albedoColor` multiplies the atlas. Synty authors for neutral lighting at
// roughly 0.5 mid-tone; this yard runs hemispheric 4.05 at exposure 2.05, so
// the unscaled atlas clips past the 0.62 bloom threshold and the characters
// render white. 0.36 lands them near 0.18 effective, which is where the
// yard's own concrete and steel sit.
synty.albedoColor = new Color3(0.36, 0.36, 0.36);
// Authored for neutral lighting; this yard runs a hot ambient, so the
// contribution is trimmed the same way the weapon viewmodel's is.
synty.environmentIntensity = 0.35;
materials.set("synty_atlas", synty);

// Lamp lenses are the one unlit surface: they are a light source, and
// shading them would make the fitting darker than the pool of light it casts.
const lens = new StandardMaterial("lamp_glass", scene);
Expand Down
97 changes: 5 additions & 92 deletions apps/game/src/opponents.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
import {
Color3,
PBRMaterial,
Vector3,
type AnimationGroup,
type Material,
type Mesh,
type Scene,
type TransformNode,
} from "@babylonjs/core";
import { Vector3, type AnimationGroup, type Scene, type TransformNode } from "@babylonjs/core";
import {
ARDAVAN_YARD,
rayAabb,
Expand All @@ -22,82 +13,6 @@ import {
} from "@nightcell7/multiplayer-sim";
import { placeAnimated, type AssetSet } from "./assets";

/**
* Pull a licensed character back into the yard's exposure range.
*
* The pack's materials are authored for a neutrally lit scene. This yard runs
* ambient at 4.05 and exposure at 2.05 — raised when the environment read as
* too dark — so light-toned character materials clip straight to white and the
* figures render as glowing blobs. Same trap as the weapon viewmodel: the fix
* belongs on the character's own material instances, not on the scene, because
* the scene's exposure is correct for the environment.
*/
function tameForScene(root: TransformNode, scene: Scene): void {
const seen = new Map<Material, Material>();

const meshes = root.getChildMeshes() as Mesh[];
for (const mesh of meshes) {
const source = mesh.material;
if (!source) continue;
let clone = seen.get(source);
if (!clone) {
clone = source.clone(`scene_${source.name}`) ?? source;

// Kill emissive without gating on the material class. The yard runs a
// GlowLayer for the sodium lamps and it blooms *any* emissive surface;
// if the imported material is not the class we expect, an
// `instanceof` guard skips this silently and the character renders as a
// glowing white blob with a halo.
const anyMaterial = clone as unknown as { emissiveColor?: Color3; emissiveTexture?: unknown };
if (anyMaterial.emissiveColor) anyMaterial.emissiveColor = new Color3(0, 0, 0);
anyMaterial.emissiveTexture = null;

if (clone instanceof PBRMaterial) {
// 0.18, not 0.42.
//
// Worked back from the actual budget rather than guessed: the yard's
// hemispheric light is intensity 4.05 with a diffuse colour averaging
// ~0.45, so ~1.8 effective. At exposure 2.05, staying under the 0.62
// bloom threshold needs an albedo near 0.17 — which is also where the
// yard's own concrete and steel sit. The pack authors its materials
// around 0.8 for a neutrally lit scene, so they need scaling by
// roughly a fifth, not a half.
clone.albedoColor = clone.albedoColor.scale(0.18);
clone.environmentIntensity = 0.3;
clone.enableSpecularAntiAliasing = true;
}
seen.set(source, clone);
}
mesh.material = clone;

// Belt and braces: even with emissive cleared, keep characters out of the
// glow layer entirely so a future material change cannot reintroduce this.
for (const layer of scene.effectLayers) {
const glow = layer as unknown as { addExcludedMesh?: (m: Mesh) => void };
glow.addExcludedMesh?.(mesh);
}
}
}

/**
* Live opponents for the single-player sandbox.
*
* These are not scripted dummies. They run the real `MatchSimulation` from
* `@nightcell7/multiplayer-sim` with the real `BotController`, which produces
* `InputFrame`s and hands them to the same movement, fire-cadence, ammunition
* and hitscan code the authoritative server runs. A bot here cannot do anything
* a client could not, and its behaviour is the behaviour you will meet in a
* match — that is the whole reason to reuse the simulation rather than write
* "sandbox AI" that would immediately drift from it.
*
* The simulation is **local and presentational**. Nothing it decides is
* reported anywhere, nothing is scored, and in a real match the server owns all
* of it. The one deliberate cheat is the local player: rather than replaying
* their input through the sim and getting a second, diverging body, their real
* camera position is written straight into their sim player each tick. The
* bots therefore chase where the player actually is.
*/

/** Enemies on the Directorate side, and friendlies on the player's. */
const ENEMY_COUNT = 4;
const FRIENDLY_COUNT = 3;
Expand Down Expand Up @@ -135,7 +50,7 @@ export class Opponents {
private readonly shots: BotShot[] = [];
private readonly deadUntil = new Map<string, number>();

constructor(scene: Scene, assets: AssetSet) {
constructor(_scene: Scene, assets: AssetSet) {
// One model per faction.
//
// Nightcell are irregulars: olive drab, boots, a pack — someone fighting
Expand All @@ -154,9 +69,9 @@ export class Opponents {
//
// The generated character renders correctly, so the bots use it until the
// licensed ones are diagnosed. Swapping back is a one-line change:
// assets.models.get("fighter_swat") / ("fighter_worker")
const enemyModel = assets.models.get("fighter_swat") ?? assets.models.get("character");
const friendlyModel = assets.models.get("fighter_adventurer") ?? assets.models.get("character");
// assets.models.get("fighter_soldier") / ("fighter_worker")
const enemyModel = assets.models.get("fighter_soldier") ?? assets.models.get("character");
const friendlyModel = assets.models.get("fighter_insurgent") ?? assets.models.get("character");
const character = enemyModel;
const carbine = assets.models.get("carbine");
if (!character) throw new Error("no character model loaded");
Expand Down Expand Up @@ -221,8 +136,6 @@ export class Opponents {
// not attached on top of it.
void carbine;

tameForScene(placed.root, scene);

this.views.set(id, {
id,
root: placed.root,
Expand Down
Loading
Loading