Skip to content

Commit b68525b

Browse files
feat: seat 1 to 4 players in the World with co-op's rules
The World held one player, so a co-op room had nothing to run but the stand-in. Phase 1 of MULTIPLAYER.md needs players keyed by slot, each with their own hearts, guns, boosts, wallet, traps, and shop. The World gains a party: Solo is single player exactly as before, and a Crew keeps one seat (the lead) in the World's own per-player fields and the rest as mates. Every per-player system runs on each seat in turn by swapping it in (src/party.ts), so the systems still read world.player. Zombies hunt the nearest standing player, with one flow field from every player's tile; the boss, Big Leaguers, and Ankle Biters fight the player nearest them; blasts and landings hurt every player they reach; pickups go to whoever touches them. Bullets carry their shooter in co-op, and a kill pays and is credited to the shooter. stepWorld applies each slot's intent, and SeatAction and applySeatAction carry a player's presses (swap, reload, traps, buys, the exit, leaving the counter, READY) to their own seat. The decided co-op rules land in the step, only for a Crew: the wave clear bonus and kills with no single killer are split evenly; a manor door is paid by the one player at it; each player places one Plank Pile and one Grumpy Gnome; at zero hearts a player goes down, crawls, cannot fire, and bleeds out in 10 s unless a teammate standing still beside them for 2 s revives them with 2 hearts; the fallen return at the next break; the run ends when everyone is down; friendly fire only on Nightmare; waves grow by 1.6, 2.2, and 2.8 and health by 1.1 to 1.3 for 2 to 4 players; the break and the World never pause for a player at the stall or for hit-stop, and the break ends once everyone here has pressed READY; away players stand frozen and can be hurt. Single player is unchanged: determinism:diff against origin/main finds no difference over 30,000 campaign frames (to wave 10) and 7,200 endless frames with mixed steps. The gameplay hash skips the party while it is Solo. src/party.story.test.ts covers each rule with two and four players.
1 parent 35ee4fe commit b68525b

24 files changed

Lines changed: 2196 additions & 144 deletions

‎src/chucker.ts‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type Level, hasLineOfSight, levelOf } from './arena'
55
import { woundPlayer } from './combat'
66
import { MS_PER_SECOND, PLAYER_RADIUS, ZOMBIE_HIT_PULSE_MS } from './constants'
77
import { directionBetween, normalize } from './geometry'
8+
import { facingNearest, forEachSeatWhere, isStanding } from './party'
89
import type { World } from './world'
910
import {
1011
GameEvent,
@@ -412,17 +413,25 @@ const landings = (
412413

413414
const OUTWARD: Vector = { x: 1, y: 0 }
414415

415-
const land = (model: World, landing: Landing): World => {
416-
const { player } = model
417-
const landed = modifyFields(model, {
418-
frameEvents: Array.append(
419-
GameEvent.ZombieLanded({
420-
x: landing.at.x,
421-
y: landing.at.y,
422-
radius: CHUCKER_RULES.landRadius,
423-
}),
424-
),
425-
})
416+
// NOTE: a thrown zombie lands on whoever stands under it: every standing
417+
// player in co-op.
418+
const land = (model: World, landing: Landing): World =>
419+
forEachSeatWhere(
420+
modifyFields(model, {
421+
frameEvents: Array.append(
422+
GameEvent.ZombieLanded({
423+
x: landing.at.x,
424+
y: landing.at.y,
425+
radius: CHUCKER_RULES.landRadius,
426+
}),
427+
),
428+
}),
429+
isStanding,
430+
landed => landOn(landed, landing),
431+
)
432+
433+
const landOn = (landed: World, landing: Landing): World => {
434+
const { player } = landed
426435
const isHit =
427436
Invulnerability.isAnyOf(['Vulnerable'])(player.invulnerability) &&
428437
distanceBetween(landing.at, player) <=
@@ -647,7 +656,9 @@ export const advanceChuckers = (model: World, deltaMs: number): World => {
647656
Option.match(findZombie(current.zombies, chucker.id), {
648657
onNone: () => current,
649658
onSome: latest =>
650-
stepShield(stepHeft(current, latest, deltaMs), latest, deltaMs),
659+
facingNearest(current, latest, fight =>
660+
stepShield(stepHeft(fight, latest, deltaMs), latest, deltaMs),
661+
),
651662
}),
652663
)
653664
const thrownAt = landings(heaved.zombies, deltaMs)
@@ -664,13 +675,14 @@ export const advanceChuckers = (model: World, deltaMs: number): World => {
664675
(current, chucker) =>
665676
Option.match(findZombie(current.zombies, chucker.id), {
666677
onNone: () => current,
667-
onSome: latest => {
668-
const shielded = tryShield(current, latest)
669-
return Option.match(findZombie(shielded.zombies, latest.id), {
670-
onNone: () => shielded,
671-
onSome: after => tryGrab(shielded, after),
672-
})
673-
},
678+
onSome: latest =>
679+
facingNearest(current, latest, fight => {
680+
const shielded = tryShield(fight, latest)
681+
return Option.match(findZombie(shielded.zombies, latest.id), {
682+
onNone: () => shielded,
683+
onSome: after => tryGrab(shielded, after),
684+
})
685+
}),
674686
}),
675687
)
676688
}

‎src/combat.ts‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
ZOMBIE_HIT_PULSE_MS,
1212
} from './constants'
1313
import { circlesOverlap, directionBetween, scaleVector } from './geometry'
14-
import { earn } from './shop'
14+
import { forEachSeatWhere, isStanding, payShared } from './party'
1515
import { trapBlastTarget } from './traps'
1616
import type { World } from './world'
1717
import {
@@ -236,25 +236,48 @@ const detonate = (model: World, bomberId: number): World => {
236236
onNone: () => modifyFields(model, { zombies: () => rest }),
237237
onSome: blast => {
238238
const zombieOutcome = blastAll(blast, rest, ZOMBIE_BLAST_TARGET)
239-
const playerOutcome = blastOne(blast, PLAYER_BLAST_TARGET)(model.player)
240239
const trapOutcome = blastAll(
241240
blast,
242241
model.traps,
243242
trapBlastTarget(levelOf(model)),
244243
)
245-
return modifyFields(model, {
246-
zombies: () => zombieOutcome.targets,
247-
player: () => playerOutcome.target,
248-
traps: () => trapOutcome.targets,
249-
wallet: wallet => earn(wallet, bountyOf(zombieOutcome.events)),
250-
frameEvents: frameEvents => [
251-
...frameEvents,
252-
GameEvent.Exploded({ x: blast.x, y: blast.y, radius: blast.radius }),
253-
...playerOutcome.events,
254-
...zombieOutcome.events,
255-
...trapOutcome.events,
256-
],
257-
})
244+
const scorched = forEachSeatWhere(
245+
modifyFields(model, {
246+
frameEvents: Array.append(
247+
GameEvent.Exploded({
248+
x: blast.x,
249+
y: blast.y,
250+
radius: blast.radius,
251+
}),
252+
),
253+
}),
254+
isStanding,
255+
seatWorld => {
256+
const playerOutcome = blastOne(
257+
blast,
258+
PLAYER_BLAST_TARGET,
259+
)(seatWorld.player)
260+
return modifyFields(seatWorld, {
261+
player: () => playerOutcome.target,
262+
frameEvents: frameEvents => [
263+
...frameEvents,
264+
...playerOutcome.events,
265+
],
266+
})
267+
},
268+
)
269+
return payShared(
270+
modifyFields(scorched, {
271+
zombies: () => zombieOutcome.targets,
272+
traps: () => trapOutcome.targets,
273+
frameEvents: frameEvents => [
274+
...frameEvents,
275+
...zombieOutcome.events,
276+
...trapOutcome.events,
277+
],
278+
}),
279+
bountyOf(zombieOutcome.events),
280+
)
258281
},
259282
})
260283
}

‎src/crawler.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { modifyFields } from 'foldkit/struct'
44
import { type Level, distanceToBox, isSolidAt } from './arena'
55
import { PLAYER_RADIUS } from './constants'
66
import { circlesOverlap } from './geometry'
7+
import { nearestOf, targetsOf } from './party'
78
import { type Seeded, between, nextRandom } from './prng'
89
import { STALL_BOX } from './shop'
910
import { trapBlockers } from './traps'
@@ -251,11 +252,12 @@ const cling = (zombie: Zombie, player: Vector, deltaMs: number): Step => {
251252
}
252253

253254
const stepCrawler =
254-
(player: Vector, deltaMs: number) =>
255+
(targetFor: (zombie: Zombie) => Vector, deltaMs: number) =>
255256
(zombie: Zombie): Step => {
256257
if (zombie.kind !== CRAWLER_KIND || !isWalking(zombie)) {
257258
return stay(zombie)
258259
}
260+
const player = targetFor(zombie)
259261
const surfaced = surface(zombie, player, deltaMs)
260262
const clung = cling(surfaced.zombie, player, deltaMs)
261263
return {
@@ -272,7 +274,11 @@ export const advanceCrawlers = (model: World, deltaMs: number): World => {
272274
return model
273275
}
274276

275-
const steps = Array.map(model.zombies, stepCrawler(model.player, deltaMs))
277+
const targets = targetsOf(model)
278+
const steps = Array.map(
279+
model.zombies,
280+
stepCrawler(zombie => nearestOf(targets, zombie), deltaMs),
281+
)
276282
return modifyFields(model, {
277283
zombies: () => Array.map(steps, ({ zombie }) => zombie),
278284
frameEvents: frameEvents => [
@@ -293,6 +299,28 @@ export const ankleSlowAt = (zombies: ReadonlyArray<Zombie>): number =>
293299
? CRAWLER_RULES.clingSlow
294300
: 1
295301

302+
const ANKLE_REACH_PX = 4
303+
304+
// NOTE: in co-op an Ankle Biter slows only the player whose ankles it holds.
305+
export const ankleSlowNear = (
306+
zombies: ReadonlyArray<Zombie>,
307+
player: Vector,
308+
): number =>
309+
Array.some(
310+
zombies,
311+
zombie =>
312+
isWalking(zombie) &&
313+
ZombieAction.isAnyOf(['Clinging'])(zombie.action) &&
314+
circlesOverlap(
315+
zombie,
316+
bodyRadiusOf(zombie) + ANKLE_REACH_PX,
317+
player,
318+
PLAYER_RADIUS,
319+
),
320+
)
321+
? CRAWLER_RULES.clingSlow
322+
: 1
323+
296324
export const surfacingProgress = (zombie: Zombie): number =>
297325
ZombieAction.isAnyOf(['Burrowed'])(zombie.action)
298326
? 1 - zombie.action.remainingMs / Math.max(1, zombie.action.durationMs)

‎src/difficulty.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Array } from 'effect'
1+
import { Array, Option } from 'effect'
22
import { modifyFields } from 'foldkit/struct'
33

44
import type { Model } from './model'
5+
import { COOP_RULES } from './party'
56
import {
67
type Difficulty,
78
type DifficultyBests,
@@ -85,6 +86,46 @@ export const toughenSpawns = (
8586
)
8687
}
8788

89+
// NOTE: co-op's crowd (decided 3): every planned zombie comes with copies
90+
// spread evenly through the wave, so the count scales by the player count's
91+
// waveCountScale, each a little after the one it copies, and every one is
92+
// tougher by its healthScale. One player keeps the wave exactly as rolled.
93+
const CROWD_GAP_MS = 450
94+
const CROWD_OFFSET_PX = 5
95+
96+
const scaleAt = (scales: ReadonlyArray<number>, players: number): number =>
97+
Option.getOrElse(
98+
Option.orElse(Array.get(scales, players - 1), () => Array.last(scales)),
99+
() => 1,
100+
)
101+
102+
export const crowdSpawns = (
103+
players: number,
104+
spawns: ReadonlyArray<PlannedSpawn>,
105+
): ReadonlyArray<PlannedSpawn> => {
106+
if (players <= 1) {
107+
return spawns
108+
}
109+
const countScale = scaleAt(COOP_RULES.waveCountScale, players)
110+
const healthScale = scaleAt(COOP_RULES.healthScale, players)
111+
return Array.flatMap(spawns, (spawn, index) => {
112+
const copies =
113+
Math.floor((index + 1) * countScale) - Math.floor(index * countScale)
114+
const tougher = modifyFields(spawn, {
115+
hpScale: scale => scale * healthScale,
116+
})
117+
return Array.makeBy(copies, copy =>
118+
copy === 0
119+
? tougher
120+
: modifyFields(tougher, {
121+
atMs: atMs => atMs + copy * CROWD_GAP_MS,
122+
offset: offset =>
123+
offset + (copy % 2 === 1 ? 1 : -1) * CROWD_OFFSET_PX,
124+
}),
125+
)
126+
})
127+
}
128+
88129
export const difficultySpeedScale = (difficulty: Difficulty): number =>
89130
DIFFICULTIES[difficulty].speedScale
90131

‎src/frenzy.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Array, Option, Result, pipe } from 'effect'
22
import { modifyFields } from 'foldkit/struct'
33

4-
import { earn } from './shop'
4+
import { payShared } from './party'
55
import type { World } from './world'
66
import {
77
Frenzy,
@@ -209,12 +209,12 @@ export const payWipedPacks = (before: World, after: World): World => {
209209
entry => (entry.left <= 0 ? Result.succeed(entry) : Result.fail(entry)),
210210
)
211211
const bonus = Array.reduce(wiped, 0, (sum, entry) => sum + entry.bonus)
212-
return modifyFields(after, {
212+
const tallied = modifyFields(after, {
213213
frenzyPacks: () => standing,
214-
wallet: wallet => (bonus > 0 ? earn(wallet, bonus) : wallet),
215214
frameEvents: frameEvents => [
216215
...frameEvents,
217216
...Array.getSomes(Array.map(wiped, wipeEvent(kills))),
218217
],
219218
})
219+
return bonus > 0 ? payShared(tallied, bonus) : tallied
220220
}

‎src/juice.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ const eventTrauma = (event: GameEvent): number =>
195195
PackWiped: () => 0,
196196
OvenBelched: () => SHAKE_TRAUMA_BURP,
197197
ZombieSlipped: () => 0,
198+
PlayerDowned: () => SHAKE_TRAUMA_HURT,
199+
PlayerRevived: () => 0,
200+
FriendlyFire: () => SHAKE_TRAUMA_HURT,
198201
})
199202

200203
const shakeAxis = (roll: number, trauma: number): number => {
@@ -345,6 +348,9 @@ const eventPopups = (
345348
generateMany(state, 1, popup('Wipe', bonus, x, y - PAYDAY_POPUP_LIFT_PX)),
346349
OvenBelched: () => noPopups(state),
347350
ZombieSlipped: () => noPopups(state),
351+
PlayerDowned: () => noPopups(state),
352+
PlayerRevived: () => noPopups(state),
353+
FriendlyFire: () => noPopups(state),
348354
})
349355

350356
const agePopups = (

‎src/kitchen.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { ZOMBIE_BLAST_TARGET, bountyOf } from './combat'
1717
import { BULLET_STEP_PX } from './constants'
1818
import { KITCHEN_EXIT, boxAround } from './exits'
19-
import { earn } from './shop'
19+
import { payShared } from './party'
2020
import type { World } from './world'
2121
import {
2222
GameEvent,
@@ -186,11 +186,13 @@ const scorch = (model: World, oven: Box): World => {
186186
const events = Array.flatMap(outcomes, ({ events }) => events)
187187
return Array.isReadonlyArrayEmpty(events)
188188
? model
189-
: modifyFields(model, {
190-
zombies: () => Array.map(outcomes, ({ target }) => target),
191-
wallet: wallet => earn(wallet, bountyOf(events)),
192-
frameEvents: frameEvents => [...frameEvents, ...events],
193-
})
189+
: payShared(
190+
modifyFields(model, {
191+
zombies: () => Array.map(outcomes, ({ target }) => target),
192+
frameEvents: frameEvents => [...frameEvents, ...events],
193+
}),
194+
bountyOf(events),
195+
)
194196
}
195197

196198
type Heat = Readonly<{ oven: Oven; ticksDue: number }>

‎src/model.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
OrientationLock,
5151
PageVisibility,
5252
Particle,
53+
Party,
5354
Phase,
5455
PickupKind,
5556
PlayerBests,
@@ -178,6 +179,9 @@ export const Model = Schema.Struct({
178179
shakeSetting: ShakeSetting,
179180
rotationSetting: RotationSetting,
180181
orientationLock: OrientationLock,
182+
// NOTE: players sharing this keyboard, from a dev-only `?local=N` link
183+
// (MULTIPLAYER.md, "What Phase 1 built"). 1 in every shipped build.
184+
localPlayers: Schema.Number,
181185
shareStats: ShareStatsSetting,
182186
cursorSetting: CursorSetting,
183187
maybeVisitId: Schema.Option(Schema.String),
@@ -329,6 +333,8 @@ export const initialModel: Model = {
329333
shakeSetting: 'On',
330334
rotationSetting: 'Landscape',
331335
orientationLock: 'Unsupported',
336+
localPlayers: 1,
337+
party: Party.Solo(),
332338
shareStats: 'On',
333339
cursorSetting: 'Bold',
334340
maybeVisitId: Option.none(),

‎src/pacing.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,15 +444,15 @@ export const delayCampedPacks = (
444444
routed: ReadonlyArray<PlannedSpawn>,
445445
clockMs: number,
446446
worldMs: number,
447-
player: Vector,
447+
players: ReadonlyArray<Vector>,
448448
): ReadonlyArray<PlannedSpawn> => {
449449
const campedPacks = pipe(
450450
routed,
451451
Array.filter(
452452
spawn =>
453453
isDue(clockMs)(spawn) &&
454454
isThroughDoor(spawn) &&
455-
isCampingDoor(level, player, spawn.door),
455+
Array.some(players, player => isCampingDoor(level, player, spawn.door)),
456456
),
457457
Array.map(({ pack }) => pack),
458458
Array.dedupe,

0 commit comments

Comments
 (0)