Skip to content

Commit 297e6b8

Browse files
refactor: make intent the step's input
The step derived the player's intent from raw input inside every tick, twice, through the camera, so it read the surface, the orientation, and the camera lean: client state a co-op room does not have. The client frame loop moves to src/frame.ts: clampFrameDelta, the tick carry, refreshIntent, the camera lean, and the cosmetics. It derives the intent before each tick, and advanceTick in src/simulation.ts takes it as given. The step only idles the intent while the player is at the stall's counter or the exit's sign. The camera leans once a frame on the display's time. simulation.ts no longer imports camera.ts or input.ts. This changes mouse aim on purpose, as MULTIPLAYER.md predicts: aim now comes from where the player stood when the tick began, not after the tick's move, so with the camera held at a room's edge the aim point moves by up to the player's one-tick step. determinism:diff against 59d0c96 first differs at frame 92 in a bullet's velocity, with intent, facing, and the camera lean beside it and nothing else. Keyboard and stick aim do not go through the camera.
1 parent 10b90da commit 297e6b8

6 files changed

Lines changed: 138 additions & 107 deletions

File tree

‎src/frame.ts‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { modifyFields } from 'foldkit/struct'
2+
3+
import { leanCamera, screenCameraOf } from './camera'
4+
import {
5+
FIXED_STEP_MS,
6+
MAX_FRAME_DELTA_MS,
7+
MS_PER_SECOND,
8+
STEP_EPSILON_MS,
9+
} from './constants'
10+
import { clamp } from './geometry'
11+
import { deriveIntent } from './input'
12+
import { advanceJuice } from './juice'
13+
import { type GameEvent, type Model, ShopState, idleIntent } from './model'
14+
import { advanceParticles } from './particles'
15+
import { advanceCallouts } from './powerups'
16+
import { advanceTick } from './simulation'
17+
import { isRoundActive } from './waves'
18+
19+
// The client's frame: the display's time becomes whole 60 Hz ticks of the
20+
// world, each fed the intent the client derives from its own input and
21+
// camera, and the frame's cosmetics follow from the events of its ticks.
22+
23+
export const clampFrameDelta = (deltaTime: number): number =>
24+
clamp(deltaTime, 0, MAX_FRAME_DELTA_MS)
25+
26+
// NOTE: the client turns raw input into intent: aim goes through the
27+
// camera, which only this device knows. A player at the stall's counter or
28+
// the exit's sign intends nothing.
29+
export const refreshIntent = (model: Model): Model =>
30+
modifyFields(model, {
31+
intent: () =>
32+
ShopState.isAnyOf(['Browsing', 'AtExit'])(model.shop)
33+
? idleIntent
34+
: deriveIntent(model.input, model.player, screenCameraOf(model)),
35+
})
36+
37+
type Ticks = Readonly<{ count: number; carryMs: number }>
38+
39+
// NOTE: a 60 Hz frame on an empty carry is exactly one tick and leaves
40+
// exactly zero, so a 60 Hz display steps as it did before the fixed step.
41+
export const ticksIn = (carryMs: number, count = 0): Ticks =>
42+
carryMs >= FIXED_STEP_MS - STEP_EPSILON_MS
43+
? ticksIn(carryMs - FIXED_STEP_MS, count + 1)
44+
: { count, carryMs: Math.max(0, carryMs) }
45+
46+
type Stepped = Readonly<{
47+
model: Model
48+
events: ReadonlyArray<GameEvent>
49+
worldMs: number
50+
}>
51+
52+
// NOTE: a tick that ends the round ends the frame's ticks.
53+
const runTicks = (stepped: Stepped, count: number): Stepped => {
54+
if (count <= 0 || !isRoundActive(stepped.model.phase)) {
55+
return stepped
56+
}
57+
const ticked = advanceTick(refreshIntent(stepped.model))
58+
return runTicks(
59+
{
60+
model: ticked.model,
61+
events: [...stepped.events, ...ticked.model.frameEvents],
62+
worldMs: stepped.worldMs + ticked.worldMs,
63+
},
64+
count - 1,
65+
)
66+
}
67+
68+
// NOTE: what the frame looks like once its ticks ran: the camera lean,
69+
// particles, the walking dust, callouts, and the juice, all from the
70+
// frame's events and the cosmetic stream, so they never touch the World.
71+
const advanceCosmetics = (
72+
model: Model,
73+
frameMs: number,
74+
worldMs: number,
75+
): Model =>
76+
advanceJuice(
77+
advanceCallouts(
78+
worldMs > 0
79+
? advanceParticles(model, worldMs / MS_PER_SECOND, worldMs)
80+
: model,
81+
worldMs,
82+
),
83+
frameMs,
84+
worldMs,
85+
)
86+
87+
// NOTE: the camera leans toward the aim on the display's own time.
88+
const leanFor = (model: Model, frameMs: number): Model =>
89+
modifyFields(model, {
90+
cameraLean: lean => leanCamera(lean, model.intent.maybeAim, frameMs),
91+
})
92+
93+
// NOTE: a frame runs the whole ticks its time owes and carries the rest,
94+
// then makes its cosmetics. Its `frameEvents` are every tick's events in
95+
// order, so the cosmetics, sounds, stats, and lessons see each one.
96+
export const advanceGame = (model: Model, deltaTime: number): Model => {
97+
const frameMs = clampFrameDelta(deltaTime)
98+
const ticks = ticksIn(model.stepCarryMs + frameMs)
99+
const stepped = runTicks(
100+
{
101+
model: modifyFields(model, {
102+
stepCarryMs: () => ticks.carryMs,
103+
frameEvents: () => [],
104+
}),
105+
events: [],
106+
worldMs: 0,
107+
},
108+
ticks.count,
109+
)
110+
return advanceCosmetics(
111+
leanFor(
112+
modifyFields(stepped.model, { frameEvents: () => stepped.events }),
113+
frameMs,
114+
),
115+
frameMs,
116+
stepped.worldMs,
117+
)
118+
}

‎src/main.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ import { LinkFlow } from './playerSync'
8080
import { forgetTutorial } from './save'
8181
import { isUsingServiceWorker } from './serviceWorker'
8282
import { isAtCounter, isBrowsing } from './shop'
83-
import { advanceGame, clampFrameDelta, refreshIntent } from './simulation'
83+
import { advanceGame, clampFrameDelta, refreshIntent } from './frame'
8484
import { tallyFrame } from './stats'
8585
import { PlayerStore } from './store'
8686
import { foldCharacter } from './update/character'

‎src/simulation.ts‎

Lines changed: 16 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
puddleSlowAt,
1818
woundScaleOf,
1919
} from './boss'
20-
import { leanCamera, screenCameraOf } from './camera'
2120
import { advanceChuckers, maybeChuckerHeading, maybeShieldFor } from './chucker'
2221
import {
2322
hitEvent,
@@ -33,12 +32,10 @@ import {
3332
DRY_FIRE_DELAY_MS,
3433
FIXED_STEP_MS,
3534
KNOCKBACK_REST_SPEED,
36-
MAX_FRAME_DELTA_MS,
3735
MS_PER_SECOND,
3836
PLAYER_KNOCKBACK_DECAY_PER_SECOND,
3937
PLAYER_RADIUS,
4038
SEPARATION_SPEED,
41-
STEP_EPSILON_MS,
4239
ZOMBIE_BUMP_SPEED,
4340
ZOMBIE_KNOCKBACK_DECAY_PER_SECOND,
4441
} from './constants'
@@ -56,8 +53,7 @@ import {
5653
scaleVector,
5754
vectorLength,
5855
} from './geometry'
59-
import { deriveIntent } from './input'
60-
import { advanceJuice, stopForHits, thawTick } from './juice'
56+
import { stopForHits, thawTick } from './juice'
6157
import { advanceKitchen, soupSkid } from './kitchen'
6258
import {
6359
Arsenal,
@@ -80,7 +76,6 @@ import {
8076
idleIntent,
8177
} from './model'
8278
import { advanceIntensity } from './pacing'
83-
import { advanceParticles } from './particles'
8479
import {
8580
type Clearance,
8681
maybeNearestBait,
@@ -90,7 +85,6 @@ import {
9085
steerToward,
9186
} from './pathing'
9287
import {
93-
advanceCallouts,
9488
advancePowerUps,
9589
bulletGlaze,
9690
bulletPierce,
@@ -111,7 +105,7 @@ import {
111105
tollsOf,
112106
trapBlockers,
113107
} from './traps'
114-
import { advanceRound, endWhenDefeated, isRoundActive } from './waves'
108+
import { advanceRound, endWhenDefeated } from './waves'
115109
import {
116110
SIDEARM,
117111
type WeaponStats,
@@ -137,13 +131,12 @@ import {
137131

138132
// INTENT
139133

140-
export const refreshIntent = (model: Model): Model =>
141-
modifyFields(model, {
142-
intent: () =>
143-
ShopState.isAnyOf(['Browsing', 'AtExit'])(model.shop)
144-
? idleIntent
145-
: deriveIntent(model.input, model.player, screenCameraOf(model)),
146-
})
134+
// NOTE: the player's intent is the step's input. A player at the stall's
135+
// counter or the exit's sign does nothing else, whatever they hold.
136+
const gateIntent = (model: Model): Model =>
137+
ShopState.isAnyOf(['Browsing', 'AtExit'])(model.shop)
138+
? modifyFields(model, { intent: () => idleIntent })
139+
: model
147140

148141
// TIMERS
149142

@@ -1261,16 +1254,13 @@ const feelIntensity = (before: Model, after: Model, deltaMs: number): Model =>
12611254

12621255
// FRAME
12631256

1264-
export const clampFrameDelta = (deltaTime: number): number =>
1265-
clamp(deltaTime, 0, MAX_FRAME_DELTA_MS)
1266-
12671257
const advanceWorld = (model: Model, deltaMs: number): Model => {
12681258
const seconds = deltaMs / MS_PER_SECOND
12691259

12701260
const level = levelOf(model)
12711261
const blockers = trapBlockers(level, model.traps)
12721262
const tolls = tollsOf(level, model.traps)
1273-
const moved = refreshIntent(
1263+
const moved = gateIntent(
12741264
modifyFields(model, {
12751265
player: player =>
12761266
movePlayer(
@@ -1288,7 +1278,7 @@ const advanceWorld = (model: Model, deltaMs: number): Model => {
12881278
frameEvents: () => [],
12891279
}),
12901280
)
1291-
const routed = refreshIntent(
1281+
const routed = gateIntent(
12921282
refreshShopPresence(
12931283
modifyFields(moved, {
12941284
flowField: flowField =>
@@ -1326,94 +1316,17 @@ const advanceWorld = (model: Model, deltaMs: number): Model => {
13261316
)
13271317
}
13281318

1329-
// NOTE: one 60 Hz tick: hit-stop decides whether the world steps, then
1330-
// the camera lean follows and the tick's events decide the next hit-stop.
1331-
type Tick = Readonly<{ model: Model; worldMs: number }>
1319+
// NOTE: one 60 Hz tick of the world from the player's intent: hit-stop
1320+
// decides whether the world steps, then the tick's events decide the next
1321+
// hit-stop.
1322+
export type Tick = Readonly<{ model: Model; worldMs: number }>
13321323

1333-
const advanceTick = (model: Model): Tick => {
1324+
export const advanceTick = (model: Model): Tick => {
13341325
const thaw = thawTick(model.hitStop, FIXED_STEP_MS)
13351326
const thawed = modifyFields(model, {
13361327
hitStop: () => thaw.hitStop,
13371328
frameEvents: () => [],
13381329
})
13391330
const stepped = thaw.worldMs > 0 ? advanceWorld(thawed, thaw.worldMs) : thawed
1340-
const leaned = modifyFields(stepped, {
1341-
cameraLean: lean =>
1342-
leanCamera(lean, stepped.intent.maybeAim, FIXED_STEP_MS),
1343-
})
1344-
return { model: stopForHits(leaned, thaw.worldMs), worldMs: thaw.worldMs }
1345-
}
1346-
1347-
type Ticks = Readonly<{ count: number; carryMs: number }>
1348-
1349-
// NOTE: a 60 Hz frame on an empty carry is exactly one tick and leaves
1350-
// exactly zero, so a 60 Hz display steps as it did before the fixed step.
1351-
export const ticksIn = (carryMs: number, count = 0): Ticks =>
1352-
carryMs >= FIXED_STEP_MS - STEP_EPSILON_MS
1353-
? ticksIn(carryMs - FIXED_STEP_MS, count + 1)
1354-
: { count, carryMs: Math.max(0, carryMs) }
1355-
1356-
type Stepped = Readonly<{
1357-
model: Model
1358-
events: ReadonlyArray<GameEvent>
1359-
worldMs: number
1360-
}>
1361-
1362-
// NOTE: a tick that ends the round ends the frame's ticks.
1363-
const runTicks = (stepped: Stepped, count: number): Stepped => {
1364-
if (count <= 0 || !isRoundActive(stepped.model.phase)) {
1365-
return stepped
1366-
}
1367-
const ticked = advanceTick(stepped.model)
1368-
return runTicks(
1369-
{
1370-
model: ticked.model,
1371-
events: [...stepped.events, ...ticked.model.frameEvents],
1372-
worldMs: stepped.worldMs + ticked.worldMs,
1373-
},
1374-
count - 1,
1375-
)
1376-
}
1377-
1378-
// NOTE: what the frame looks like once its ticks ran: particles, the
1379-
// walking dust, callouts, and the juice, all from the frame's events and
1380-
// the cosmetic stream, so they never touch the World.
1381-
const advanceCosmetics = (
1382-
model: Model,
1383-
frameMs: number,
1384-
worldMs: number,
1385-
): Model =>
1386-
advanceJuice(
1387-
advanceCallouts(
1388-
worldMs > 0
1389-
? advanceParticles(model, worldMs / MS_PER_SECOND, worldMs)
1390-
: model,
1391-
worldMs,
1392-
),
1393-
frameMs,
1394-
worldMs,
1395-
)
1396-
1397-
// NOTE: a frame runs the whole ticks its time owes and carries the rest,
1398-
// then makes its cosmetics. Its `frameEvents` are every tick's events in
1399-
// order, so the cosmetics, sounds, stats, and lessons see each one.
1400-
export const advanceGame = (model: Model, deltaTime: number): Model => {
1401-
const frameMs = clampFrameDelta(deltaTime)
1402-
const ticks = ticksIn(model.stepCarryMs + frameMs)
1403-
const stepped = runTicks(
1404-
{
1405-
model: modifyFields(model, {
1406-
stepCarryMs: () => ticks.carryMs,
1407-
frameEvents: () => [],
1408-
}),
1409-
events: [],
1410-
worldMs: 0,
1411-
},
1412-
ticks.count,
1413-
)
1414-
return advanceCosmetics(
1415-
modifyFields(stepped.model, { frameEvents: () => stepped.events }),
1416-
frameMs,
1417-
stepped.worldMs,
1418-
)
1331+
return { model: stopForHits(stepped, thaw.worldMs), worldMs: thaw.worldMs }
14191332
}

‎src/update/exit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { releaseAll } from '../input'
1111
import { Message } from '../message'
1212
import { type ExitId, type Model, ShopState } from '../model'
1313
import { burstCoins, smashBoards } from '../particles'
14-
import { refreshIntent } from '../simulation'
14+
import { refreshIntent } from '../frame'
1515
import { playSounds, uiCue } from './sound'
1616
import { type UpdateReturn, type UpdateStep, withInput } from './step'
1717

‎src/update/shop.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
shopItem,
2929
stockTag,
3030
} from '../shop'
31-
import { refreshIntent } from '../simulation'
31+
import { refreshIntent } from '../frame'
3232
import { closeExit } from './exit'
3333
import { type SoundCue, playSounds, uiCue } from './sound'
3434
import { type UpdateReturn, type UpdateStep, withInput } from './step'

‎src/update/step.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { modifyFields } from 'foldkit/struct'
44
import type { AppServices } from '../commands'
55
import type { Message } from '../message'
66
import type { Input, Model } from '../model'
7-
import { refreshIntent } from '../simulation'
7+
import { refreshIntent } from '../frame'
88

99
export type UpdateReturn = Update.Return<Model, Message, AppServices>
1010
export type UpdateStep = Update.Step<Model, Message, AppServices>

0 commit comments

Comments
 (0)