Skip to content

Commit 4e450a2

Browse files
authored
Merge pull request #1 from crs48/claude/0003-magnetic-particle-substrate
Magnetic particle substrate: coherent vibrating particles, wake & near-miss ripples
2 parents e32c3b4 + 956dc6e commit 4e450a2

16 files changed

Lines changed: 1571 additions & 3 deletions

docs/explorations/0003_[x]_MAGNETIC_PARTICLE_SUBSTRATE.md

Lines changed: 395 additions & 0 deletions
Large diffs are not rendered by default.

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default tseslint.config(
66
ignores: [
77
"dist/**",
88
"node_modules/**",
9+
".claude/**",
910
".opencode/**",
1011
".playwright-cli/**",
1112
"playwright-report/**",

src/game/impacts.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Ripple impacts: presentational events (near-miss shivers) that the particle
2+
// substrate reads. Like `crashFlashSeconds`, these live on RunState, are
3+
// computed in updateRun, and never touch collision — a cube that is grazed and
4+
// a cube that is hit are decided by the same pure (s, θ) rects; this module only
5+
// decides how the *rendered* world reacts to a graze.
6+
//
7+
// The ripple constants and `impactRadialOffset` below are the TS twin of the
8+
// GLSL in src/render/particles/displacement.glsl.ts. The point skin ripples on
9+
// the GPU; cubes shiver on the CPU (a radial nudge, see obstacles.ts); both read
10+
// these same numbers so the tunnel wall and the cube it surrounds ripple as one.
11+
12+
import { cellCenterS, LANE_ANGLE, TUBE_RADIUS } from "../tube/space";
13+
import { angleForLane, lanesFromMask, type LaneMask } from "./coordinates";
14+
15+
export type RippleImpact = {
16+
readonly s: number;
17+
readonly theta: number;
18+
readonly age: number; // seconds since the graze
19+
readonly strength: number; // [0, 1]
20+
};
21+
22+
export const MAX_IMPACTS = 16;
23+
export const IMPACT_LIFETIME_SECONDS = 0.9;
24+
25+
// Shared ripple shape (see the GLSL twin). Amplitude is the world-space peak
26+
// radial displacement; the rest shape the expanding, decaying ring.
27+
export const IMPACT_AMPLITUDE = 0.9;
28+
export const IMPACT_FREQUENCY = 1.4;
29+
export const IMPACT_SPEED = 7;
30+
export const IMPACT_FALLOFF = 0.06;
31+
32+
// A pass closer than the inner edge would have clipped the cube (player
33+
// half-angle 0.3 + cube half-angle 0.5 = 0.8 lanes); beyond the outer edge the
34+
// pass is too wide to feel. Strength runs 1 at the grazing edge to 0 at the
35+
// outer edge.
36+
const NEAR_MISS_INNER = 0.8 * LANE_ANGLE;
37+
const NEAR_MISS_OUTER = 2.2 * LANE_ANGLE;
38+
39+
const wrapAngle = (angle: number): number => Math.atan2(Math.sin(angle), Math.cos(angle));
40+
41+
const smoothstep = (edge0: number, edge1: number, x: number): number => {
42+
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
43+
return t * t * (3 - 2 * t);
44+
};
45+
46+
// Strength of a near miss at a given angular gap. 0 for a hit (gap ≤ inner) or a
47+
// wide pass (gap ≥ outer); rises monotonically as the gap narrows toward the
48+
// grazing edge.
49+
export const nearMissStrength = (angleGap: number): number => {
50+
const gap = Math.abs(angleGap);
51+
if (gap <= NEAR_MISS_INNER || gap >= NEAR_MISS_OUTER) {
52+
return 0;
53+
}
54+
const t = (NEAR_MISS_OUTER - gap) / (NEAR_MISS_OUTER - NEAR_MISS_INNER);
55+
return t * t * (3 - 2 * t);
56+
};
57+
58+
export type NearMissFrame = {
59+
readonly absoluteCell: number;
60+
readonly obstacleMask: LaneMask;
61+
};
62+
63+
// Emit a ripple the single frame the player passes a cube's centre without
64+
// hitting it. Firing on the centre-crossing makes it exactly once per pass, with
65+
// no growing "already seen" set to carry on RunState.
66+
export const detectNearMisses = (
67+
frames: readonly NearMissFrame[],
68+
previousDistance: number,
69+
currentDistance: number,
70+
angle: number,
71+
): readonly RippleImpact[] => {
72+
const impacts: RippleImpact[] = [];
73+
74+
for (const frame of frames) {
75+
const centerS = cellCenterS(frame.absoluteCell);
76+
const crossedThisFrame = previousDistance < centerS && centerS <= currentDistance;
77+
78+
if (!crossedThisFrame) {
79+
continue;
80+
}
81+
82+
for (const lane of lanesFromMask(frame.obstacleMask)) {
83+
const laneAngle = angleForLane(lane);
84+
const strength = nearMissStrength(wrapAngle(angle - laneAngle));
85+
86+
if (strength > 0) {
87+
impacts.push({ s: centerS, theta: laneAngle, age: 0, strength });
88+
}
89+
}
90+
}
91+
92+
return impacts;
93+
};
94+
95+
// Age the live ripples, drop the expired, append the new, and keep the freshest
96+
// MAX_IMPACTS (the ring buffer the shader reads).
97+
export const advanceImpacts = (
98+
impacts: readonly RippleImpact[],
99+
detected: readonly RippleImpact[],
100+
dtSeconds: number,
101+
): readonly RippleImpact[] => {
102+
const aged = impacts
103+
.map((impact) => ({ ...impact, age: impact.age + dtSeconds }))
104+
.filter((impact) => impact.age < IMPACT_LIFETIME_SECONDS);
105+
const merged = [...aged, ...detected];
106+
return merged.length <= MAX_IMPACTS
107+
? merged
108+
: merged.slice(merged.length - MAX_IMPACTS);
109+
};
110+
111+
// TS twin of the GLSL ripple sum: the world-space radial displacement at a point
112+
// in tube space. Cubes use it to shiver; the point skin computes the same on the
113+
// GPU for the surrounding wall.
114+
export const impactRadialOffset = (
115+
s: number,
116+
theta: number,
117+
impacts: readonly RippleImpact[],
118+
): number => {
119+
let total = 0;
120+
121+
for (const impact of impacts) {
122+
if (impact.strength <= 0) {
123+
continue;
124+
}
125+
const ds = s - impact.s;
126+
const dTheta = wrapAngle(theta - impact.theta);
127+
const dist = Math.hypot(ds, dTheta * TUBE_RADIUS);
128+
const ageNorm = Math.min(1, impact.age / IMPACT_LIFETIME_SECONDS);
129+
const envelope =
130+
smoothstep(0, 0.12, ageNorm) * (1 - smoothstep(0.45, 1, ageNorm));
131+
const ring = Math.sin(dist * IMPACT_FREQUENCY - impact.age * IMPACT_SPEED);
132+
const spatial = Math.exp(-dist * dist * IMPACT_FALLOFF);
133+
total += impact.strength * envelope * ring * spatial;
134+
}
135+
136+
return total * IMPACT_AMPLITUDE;
137+
};

src/game/run.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { resolveCollisionFrame } from "./collision";
66
import { BOOST_MULTIPLIERS } from "./config";
7+
import { advanceImpacts, detectNearMisses, type RippleImpact } from "./impacts";
78
import { maybeUpdateHighScore, scoreFromDistance } from "./scoring";
89
import { advancePlayer, createInitialGameState, type GameState } from "./state";
910
import {
@@ -22,6 +23,8 @@ export type RunState = {
2223
readonly bend: BendParams;
2324
readonly collectedBoosts: ReadonlySet<string>;
2425
readonly crashFlashSeconds: number;
26+
// Presentational only: near-miss ripples the particle substrate renders.
27+
readonly impacts: readonly RippleImpact[];
2528
};
2629

2730
export const createRunState = (highScore: number, seed: number): RunState => ({
@@ -30,6 +33,7 @@ export const createRunState = (highScore: number, seed: number): RunState => ({
3033
bend: createBend(seed),
3134
collectedBoosts: new Set(),
3235
crashFlashSeconds: 0,
36+
impacts: [],
3337
});
3438

3539
type CollisionPass = {
@@ -95,6 +99,13 @@ export const updateRun = (
9599
: Math.max(state.game.highScore, score);
96100
const safeDt = Math.min(Math.max(dtSeconds, 0), 0.05);
97101

102+
const detectedImpacts = detectNearMisses(
103+
framesNearDistance(world, advancedPlayer.distance),
104+
state.game.player.distance,
105+
advancedPlayer.distance,
106+
advancedPlayer.angle,
107+
);
108+
98109
return {
99110
game: {
100111
...state.game,
@@ -107,5 +118,6 @@ export const updateRun = (
107118
crashFlashSeconds: collisionState.crashed
108119
? 0.75
109120
: Math.max(0, state.crashFlashSeconds - safeDt),
121+
impacts: advanceImpacts(state.impacts, detectedImpacts, safeDt),
110122
};
111123
};

src/main.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { createRunState, updateRun, type RunState } from "./game/run";
66
import { readHighScore, scoreFromDistance } from "./game/scoring";
77
import { findSection } from "./game/world";
88
import { createInputController } from "./input/controller";
9-
import { TELEGRAPH_FAR_CELLS } from "./tube/space";
9+
import { CELL_DEPTH, TELEGRAPH_FAR_CELLS } from "./tube/space";
1010
import { updateCameraRig } from "./render/camera";
11+
import { magnetizationCoherence } from "./render/particles/coherence";
1112
import { createDebugOverlay, updateDebugOverlay } from "./render/debugOverlay";
1213
import { createHud, updateHud } from "./render/hud";
1314
import { updateObstacleView } from "./render/obstacles";
15+
import { updateParticleSystem } from "./render/particles/system";
1416
import { createRenderScene } from "./render/scene";
1517
import { updateShipView } from "./render/ship";
1618
import { updateTubeView } from "./render/tubeMesh";
@@ -51,6 +53,9 @@ const hud = createHud(app);
5153
const debugOverlay = createDebugOverlay(app);
5254
const renderScene = createRenderScene(canvasHost, {
5355
preserveDrawingBuffer: import.meta.env.MODE === "test",
56+
// Opt out with ?particles=off to confirm the substrate is a clean layer.
57+
showParticles:
58+
new URLSearchParams(window.location.search).get("particles") !== "off",
5459
});
5560
const input = createInputController(hud.gyro, hud.gyroStatus);
5661

@@ -61,6 +66,7 @@ const newRun = (): RunState => {
6166

6267
let run = newRun();
6368
let lastTimeMs: number | undefined;
69+
let elapsedSeconds = 0;
6470

6571
const restart = (): void => {
6672
run = newRun();
@@ -78,6 +84,15 @@ const renderFrame = (state: RunState, dtSeconds: number): void => {
7884
const player = state.game.player;
7985
const section = findSection(state.world, player.distance);
8086
const speedFactor = BOOST_MULTIPLIERS[player.boostLevel];
87+
const guidance = guidanceAhead(state.world, player.distance, state.collectedBoosts);
88+
const coherence = magnetizationCoherence({
89+
timeSeconds: elapsedSeconds,
90+
boostLevel: player.boostLevel,
91+
obstacleDistance:
92+
guidance.obstacle === undefined
93+
? Number.POSITIVE_INFINITY
94+
: guidance.obstacle.cell * CELL_DEPTH - player.distance,
95+
});
8196

8297
updateTubeView(
8398
renderScene.tube,
@@ -93,6 +108,7 @@ const renderFrame = (state: RunState, dtSeconds: number): void => {
93108
player.distance,
94109
state.bend,
95110
state.collectedBoosts,
111+
state.impacts,
96112
);
97113
updateCameraRig(
98114
renderScene.cameraRig,
@@ -108,6 +124,16 @@ const renderFrame = (state: RunState, dtSeconds: number): void => {
108124
state.bend,
109125
state.crashFlashSeconds,
110126
);
127+
updateParticleSystem(renderScene.particles, {
128+
bend: state.bend,
129+
playerS: player.distance,
130+
playerAngle: player.angle,
131+
speedFactor,
132+
timeSeconds: elapsedSeconds,
133+
pixelRatio: renderScene.renderer.getPixelRatio(),
134+
impacts: state.impacts,
135+
coherence,
136+
});
111137
renderScene.renderer.render(renderScene.scene, renderScene.cameraRig.camera);
112138
updateDebugOverlay(
113139
debugOverlay,
@@ -130,6 +156,7 @@ renderScene.renderer.setAnimationLoop((timeMs: number) => {
130156
const dtSeconds =
131157
lastTimeMs === undefined ? 0 : (timeMs - lastTimeMs) / 1_000;
132158
lastTimeMs = timeMs;
159+
elapsedSeconds = timeMs / 1_000;
133160

134161
run = updateRun(run, input.getSteer(), dtSeconds, window.localStorage);
135162
renderFrame(run, dtSeconds);

src/render/obstacles.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "three";
1010

1111
import {
12+
cellCenterS,
1213
CELL_DEPTH,
1314
CUBE_DEPTH,
1415
CUBE_SIZE,
@@ -18,7 +19,8 @@ import {
1819
} from "../tube/space";
1920
import type { BendParams } from "../tube/centerline";
2021
import { cellTransform } from "../tube/transform";
21-
import { hasLane } from "../game/coordinates";
22+
import { angleForLane, hasLane } from "../game/coordinates";
23+
import { impactRadialOffset, type RippleImpact } from "../game/impacts";
2224
import { boostKey, frameAtDistance, type World } from "../game/world";
2325
import { CUBE_PALETTE } from "./palette";
2426

@@ -32,6 +34,10 @@ export type ObstacleView = {
3234
const MAX_CUBES = VISIBLE_CELLS * LANES;
3335
const MAX_BOOSTS = VISIBLE_CELLS;
3436
const BOOST_THICKNESS = 0.14;
37+
// A near miss nudges the cube radially. Scaled below the wall-ripple amplitude
38+
// so the cube shivers rather than lurches. Collision is unaffected — this is a
39+
// render-only radial inset, exactly like the ship's.
40+
const CUBE_SHIVER_SCALE = 0.45;
3541

3642
const PALETTE_COLORS: readonly Color[] = CUBE_PALETTE.map(
3743
({ r, g, b }) => new Color(r, g, b),
@@ -88,6 +94,7 @@ export const updateObstacleView = (
8894
playerS: number,
8995
bend: BendParams,
9096
collectedBoosts: ReadonlySet<string>,
97+
impacts: readonly RippleImpact[] = [],
9198
): void => {
9299
const baseCell = Math.floor(playerS / CELL_DEPTH);
93100
let cubeCount = 0;
@@ -106,6 +113,9 @@ export const updateObstacleView = (
106113
continue;
107114
}
108115

116+
const shiver =
117+
impactRadialOffset(cellCenterS(absoluteCell), angleForLane(lane), impacts) *
118+
CUBE_SHIVER_SCALE;
109119
placeInstance(
110120
view.cubes,
111121
cubeCount,
@@ -114,7 +124,7 @@ export const updateObstacleView = (
114124
lane,
115125
playerS,
116126
bend,
117-
CUBE_SIZE / 2 + CUBE_SURFACE_GAP,
127+
CUBE_SIZE / 2 + CUBE_SURFACE_GAP + shiver,
118128
);
119129
view.cubes.setColorAt(
120130
cubeCount,

0 commit comments

Comments
 (0)