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
23 changes: 18 additions & 5 deletions apps/game/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,25 @@ async function loadModel(
mesh.alwaysSelectAsActiveMesh = false;
}

// Materials that came in from the GLB are now unreferenced.
// Drop only materials nothing is actually using.
//
// This used to dispose every material whose name was not one of our
// generated slots, on the assumption that a GLB's own materials are always
// replaced by the loop above. That holds for the props we generate and is
// catastrophically wrong for a licensed model: every Quaternius material
// (DarkBrown, Grey, Black, Skin, Swat, Swat_Black, Visor) failed the name
// test, all seven were destroyed, and the meshes were left with no material
// at all — which Babylon renders as flat white.
//
// That cost three wrong diagnoses. It looked like an exposure problem, so it
// was "fixed" by scaling albedo and clearing emissive, none of which can
// help a mesh that has no material to scale. Checking actual usage is both
// correct and impossible to get wrong for a model we did not author.
const inUse = new Set(container.meshes.map((mesh) => mesh.material).filter(Boolean));
for (const material of [...container.materials]) {
if (!materials.has(material.name)) {
container.materials.splice(container.materials.indexOf(material), 1);
material.dispose();
}
if (inUse.has(material)) continue;
container.materials.splice(container.materials.indexOf(material), 1);
material.dispose();
}

return container;
Expand Down
29 changes: 20 additions & 9 deletions apps/game/src/opponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
spawnsForTeam,
} from "@nightcell7/multiplayer-sim";
import { placeAnimated, type AssetSet } from "./assets";
import { brightenCharacter } from "./targets";

/**
* Pull a licensed character back into the yard's exposure range.
Expand All @@ -36,7 +35,8 @@ import { brightenCharacter } from "./targets";
function tameForScene(root: TransformNode, scene: Scene): void {
const seen = new Map<Material, Material>();

for (const mesh of root.getChildMeshes() as Mesh[]) {
const meshes = root.getChildMeshes() as Mesh[];
for (const mesh of meshes) {
const source = mesh.material;
if (!source) continue;
let clone = seen.get(source);
Expand Down Expand Up @@ -152,8 +152,8 @@ 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("character");
const friendlyModel = assets.models.get("character");
const enemyModel = assets.models.get("fighter_swat") ?? assets.models.get("character");
const friendlyModel = assets.models.get("fighter_worker") ?? 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 @@ -196,8 +196,20 @@ export class Opponents {
// the same decision on the same tick.
this.controllers.push(new BotController(id, 1000 + i * 37));

// Put them on a real spawn pad. `addPlayer` initialises movement to the
// origin and nothing else places them, so every bot stood on top of the
// central hard point in a single pile — which reads as "they all appear
// where I am" the moment the player walks into the middle.
const player = this.sim.players.get(id);
const spawns = spawnsForTeam(ARDAVAN_YARD, entry.team);
const spawn = spawns[i % Math.max(1, spawns.length)];
if (player && spawn) {
player.movement.position = { ...spawn.position };
player.movement.yaw = spawn.yaw;
}

const placed = placeAnimated(entry.model ?? character, id, {
position: new Vector3(0, -50, 0), // parked until the sim spawns them
position: new Vector3(0, -50, 0), // moved to the spawn on the first sync
rotationY: 0,
});
if (!placed) return;
Expand All @@ -206,7 +218,6 @@ export class Opponents {
// not attached on top of it.
void carbine;

brightenCharacter(placed.root);
tameForScene(placed.root, scene);

this.views.set(id, {
Expand Down Expand Up @@ -312,7 +323,7 @@ export class Opponents {
const view = this.views.get(event.victimId);
if (view && !view.dead) {
view.dead = true;
this.play(view, "death", false);
this.play(view, "Death", false);
this.deadUntil.set(event.victimId, performance.now() + RESPAWN_MS);
}
continue;
Expand Down Expand Up @@ -362,7 +373,7 @@ export class Opponents {
if (!player.alive) {
if (!view.dead) {
view.dead = true;
this.play(view, "death", false);
this.play(view, "Death", false);
}
return;
}
Expand All @@ -384,7 +395,7 @@ export class Opponents {
const speed = Math.hypot(player.movement.velocity.x, player.movement.velocity.z);
// Clip names come from the licensed pack, whose idle holds the weapon up
// — exactly right for a fighter, and something my generated rig lacked.
const wanted = speed > RUN_SPEED ? "run" : speed > IDLE_SPEED ? "walk" : "idle";
const wanted = speed > RUN_SPEED ? "Run" : speed > IDLE_SPEED ? "Walk" : "Idle_Gun";
if (wanted !== view.current) this.play(view, wanted, true);
}

Expand Down
27 changes: 26 additions & 1 deletion apps/game/src/vfx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,31 @@ export class WeaponEffects {
* they connected.
*/
private spawnImpact(at: Vector3, direction: Vector3, now: number, heavy = false): void {
// Recolour for the surface being hit. Concrete throws bright orange
// sparks; a body does not, and firing ricochet colours at a person was
// both wrong and unreadable — the player could not tell a hit on a wall
// from a hit on a man.
const impactNow = this.impacts[this.nextImpact % this.impacts.length];
if (impactNow) {
if (heavy) {
impactNow.sparks.color1 = new Color4(0.62, 0.05, 0.05, 1);
impactNow.sparks.color2 = new Color4(0.34, 0.02, 0.02, 1);
impactNow.sparks.colorDead = new Color4(0.14, 0.01, 0.01, 0);
impactNow.dust.color1 = new Color4(0.42, 0.05, 0.05, 0.55);
impactNow.dust.color2 = new Color4(0.24, 0.03, 0.03, 0.38);
impactNow.dust.colorDead = new Color4(0.1, 0.01, 0.01, 0);
impactNow.light.diffuse = new Color3(0.7, 0.12, 0.1);
} else {
impactNow.sparks.color1 = new Color4(1, 0.86, 0.5, 1);
impactNow.sparks.color2 = new Color4(1, 0.52, 0.16, 1);
impactNow.sparks.colorDead = new Color4(0.5, 0.16, 0.04, 0);
impactNow.dust.color1 = new Color4(0.72, 0.7, 0.66, 0.5);
impactNow.dust.color2 = new Color4(0.5, 0.49, 0.47, 0.35);
impactNow.dust.colorDead = new Color4(0.4, 0.39, 0.38, 0);
impactNow.light.diffuse = new Color3(1, 0.7, 0.35);
}
}

const impact = this.impacts[this.nextImpact % this.impacts.length];
this.nextImpact += 1;
if (!impact) return;
Expand All @@ -415,7 +440,7 @@ export class WeaponEffects {
impact.dust.start();

impact.light.position.copyFrom(spawn);
impact.light.intensity = (40 + Math.random() * 18) * (heavy ? 1.6 : 1.0);
impact.light.intensity = (40 + Math.random() * 18) * (heavy ? 0.5 : 1.0);

// Grow with distance so a hit is as readable across the yard as it is at
// point blank, but clamped so a close impact does not fill the screen.
Expand Down
Loading