Skip to content

Commit e82bf7d

Browse files
fix: stop zombies standing on the player and land point-blank shots
Zombies had no hard stop at the player, only a bite range. After a bite the player is invulnerable for a second, and in that window a zombie kept steering at the player's center. A Hotfoot, at up to 52 px/s, reached it in a few frames (a Story test measured 0.2 px from center). Crowd separation could also push zombies inward. Bullets spawn at the muzzle, 6 to 9 px out, and their first sweep started there, so a zombie whose center sat inside the player was behind the start of every shot and could not be hit. Now a free zombie's step ends with a hold-off: if its body sinks more than CONTACT_ALLOWANCE_PX (1 px) into the player's, it is moved back out along the line from the player to where it stood before the step, through moveCircle so walls and traps still hold it. Only the zombie moves. It uses each kind's body radius, so big zombies and the boss stay consistent, and it still overlaps, so bites, bombers, and clinging work as before. Shots fired this frame sweep from the player's center instead of the muzzle, so a zombie hugging the player is the first thing they hit, and shotgun pellets and knockback apply as usual.
1 parent 872bd57 commit e82bf7d

4 files changed

Lines changed: 305 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ One entry per day, newest first. Only things a player would notice, a handful of
99
- ✈️ Play on a plane: once you've visited, the game starts and saves with no internet at all, and wakes up faster with no nap at the end of the loading bar. SETTINGS can link your phone and computer to play from one save.
1010
- 📱 Play holding your phone upright: the manor zooms in up close, and your guns sit on the wall right under your thumbs. Turn it sideways any time to see the whole room.
1111
- 🔥 The parlour got cozy: a crackling fire, a comfy sofa, a big rug (something bit it), and smashed-in doors zombies now shuffle through. Rattles' shop got big pictures, chunky price tags, and a skeleton who cheers when you buy and sulks when you can't.
12-
- 🎯 Lost your cursor on the dark floor? It's big and yellow now (SETTINGS switches it back), and Rattles sells a pricey Laser Sight that shows every shot before you take it.
12+
- 🎯 Lost your cursor on the dark floor? It's big and yellow now (SETTINGS switches it back), and Rattles sells a pricey Laser Sight that shows every shot before you take it. Hotfoots can't hide inside you anymore either: point-blank shots always land.
1313

1414
## October 28, 2026: Opening night
1515

‎src/constants.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export const MS_PER_SECOND = 1000
88
export const MAX_FRAME_DELTA_MS = 50
99

1010
export const PLAYER_RADIUS = 4
11+
// NOTE: how far a zombie's body may sink into the player's. It stays above
12+
// zero so a zombie held at the edge still overlaps the player and bites.
13+
export const CONTACT_ALLOWANCE_PX = 1
1114
export const PLAYER_SPEED = 72
1215
export const PLAYER_MAX_HP = 5
1316
export const MUZZLE_DISTANCE = 6

‎src/contact.story.test.ts‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { Array, Option } from 'effect'
2+
import { given, model, story } from 'foldkit/story'
3+
import { modifyFields } from 'foldkit/struct'
4+
import { describe, expect, test } from 'vitest'
5+
6+
import { CONTACT_ALLOWANCE_PX, PLAYER_RADIUS } from './constants'
7+
import { update } from './main'
8+
import {
9+
Arsenal,
10+
Frenzy,
11+
GameEvent,
12+
Invulnerability,
13+
type Model,
14+
Phase,
15+
type PlannedSpawn,
16+
type Zombie,
17+
ZombieAction,
18+
initialModel,
19+
} from './model'
20+
import { holdFire, ticks, zombieOf } from './testing'
21+
import { WEAPONS } from './weapons'
22+
import { ZOMBIE_KINDS, bodyRadiusOf } from './zombies'
23+
24+
const FRAME_MS = 16
25+
const LONG_FRAME_MS = 50
26+
const PLAYER_X = 150
27+
const PLAYER_Y = 104
28+
const EPSILON = 1e-6
29+
30+
const DISTANT_SPAWN: PlannedSpawn = {
31+
atMs: 1e9,
32+
kind: 'Shambler',
33+
door: 'North',
34+
offset: 0,
35+
speedScale: 1,
36+
hpScale: 1,
37+
pack: 0,
38+
formation: 'Squad',
39+
}
40+
41+
const fightingBase: Model = modifyFields(initialModel, {
42+
maybeSurface: () => Option.some({ left: 0, top: 0, width: 800, height: 400 }),
43+
phase: () => Phase.Wave({ wave: 1, elapsedMs: 0, spawns: [DISTANT_SPAWN] }),
44+
})
45+
46+
const STEADFAST = Invulnerability.Invulnerable({
47+
remainingMs: 1e9,
48+
durationMs: 1e9,
49+
})
50+
51+
const sceneOf = (
52+
zombies: ReadonlyArray<Zombie>,
53+
{ isSteadfast = false }: Readonly<{ isSteadfast?: boolean }> = {},
54+
): Model =>
55+
modifyFields(fightingBase, {
56+
zombies: () => zombies,
57+
player: player =>
58+
modifyFields(player, {
59+
x: () => PLAYER_X,
60+
y: () => PLAYER_Y,
61+
facing: () => ({ x: 1, y: 0 }),
62+
hp: hp => hp + 50,
63+
invulnerability: invulnerability =>
64+
isSteadfast ? STEADFAST : invulnerability,
65+
}),
66+
})
67+
68+
const contactOf = (zombie: Zombie): number =>
69+
bodyRadiusOf(zombie) + PLAYER_RADIUS - CONTACT_ALLOWANCE_PX
70+
71+
const gapOf = (current: Model, zombie: Zombie): number =>
72+
Math.hypot(zombie.x - current.player.x, zombie.y - current.player.y) -
73+
contactOf(zombie)
74+
75+
// NOTE: the smallest gap any free zombie left to its contact distance over
76+
// every frame. Below zero means a zombie stood deeper inside the player than
77+
// the allowance.
78+
const closestGap = (frames: ReadonlyArray<Model>): number =>
79+
Array.reduce(frames, Infinity, (closest, frame) =>
80+
Array.reduce(frame.zombies, closest, (inner, zombie) =>
81+
Math.min(inner, gapOf(frame, zombie)),
82+
),
83+
)
84+
85+
const playFrames = (
86+
start: Model,
87+
count: number,
88+
deltaTime: number,
89+
before: ReadonlyArray<typeof holdFire> = [],
90+
): ReadonlyArray<Model> => {
91+
const frames: Array<Model> = []
92+
story(
93+
update,
94+
given(start),
95+
...before,
96+
...Array.flatMap(ticks(count, deltaTime), frame => [
97+
frame,
98+
model((current: Model) => {
99+
frames.push(current)
100+
}),
101+
]),
102+
)
103+
return frames
104+
}
105+
106+
const runnerMaxSpeed = ZOMBIE_KINDS.Runner.speedMax
107+
108+
describe('zombies stop at the player', () => {
109+
test('a Hotfoot charging at full speed stops at the player instead of standing on them', () => {
110+
const runner = zombieOf('Runner', 1, PLAYER_X - 60, PLAYER_Y, {
111+
speed: runnerMaxSpeed,
112+
})
113+
const frames = playFrames(sceneOf([runner]), 150, FRAME_MS)
114+
115+
expect(
116+
Array.some(frames, frame =>
117+
Array.some(frame.zombies, zombie => gapOf(frame, zombie) < 1),
118+
),
119+
).toBe(true)
120+
expect(closestGap(frames)).toBeGreaterThanOrEqual(-EPSILON)
121+
})
122+
123+
test('a frenzied Hotfoot on long frames never ends a frame past the allowance', () => {
124+
const runner = zombieOf('Runner', 1, PLAYER_X - 40, PLAYER_Y + 3, {
125+
speed: runnerMaxSpeed,
126+
frenzy: Frenzy.Frenzied({ pack: 1, apartMs: 0 }),
127+
})
128+
const frames = playFrames(
129+
sceneOf([runner], { isSteadfast: true }),
130+
60,
131+
LONG_FRAME_MS,
132+
)
133+
134+
expect(closestGap(frames)).toBeGreaterThanOrEqual(-EPSILON)
135+
})
136+
137+
test('a crowd pushing from behind does not stack onto the player', () => {
138+
const crowd = Array.makeBy(8, index =>
139+
zombieOf(
140+
index % 2 === 0 ? 'Runner' : 'Shambler',
141+
index + 1,
142+
PLAYER_X - 20 - (index % 4) * 7,
143+
PLAYER_Y - 6 + Math.floor(index / 4) * 12,
144+
{
145+
speed: index % 2 === 0 ? runnerMaxSpeed : 20,
146+
},
147+
),
148+
)
149+
const frames = playFrames(
150+
sceneOf(crowd, { isSteadfast: true }),
151+
200,
152+
FRAME_MS,
153+
)
154+
155+
expect(closestGap(frames)).toBeGreaterThanOrEqual(-EPSILON)
156+
})
157+
158+
test('a bite still lands and still bumps the Hotfoot back', () => {
159+
const runner = zombieOf('Runner', 1, PLAYER_X - 30, PLAYER_Y, {
160+
speed: runnerMaxSpeed,
161+
})
162+
const frames = playFrames(sceneOf([runner]), 60, FRAME_MS)
163+
const bitten = Array.findFirstIndex(frames, frame =>
164+
Array.some(frame.frameEvents, GameEvent.guards.PlayerHurt),
165+
)
166+
167+
expect(Option.isSome(bitten)).toBe(true)
168+
const biteFrame = Option.getOrElse(bitten, () => 0)
169+
const knockback = frames[biteFrame]?.zombies[0]?.knockback.x ?? 0
170+
expect(knockback).toBeLessThan(0)
171+
})
172+
})
173+
174+
describe('point-blank shots', () => {
175+
test('a pistol shot kills a zombie standing on the player', () => {
176+
const dazed = modifyFields(
177+
zombieOf('Shambler', 1, PLAYER_X - 1, PLAYER_Y, { hp: 1 }),
178+
{
179+
action: () => ZombieAction.Dazed({ remainingMs: 1e9, durationMs: 1e9 }),
180+
},
181+
)
182+
const frames = playFrames(
183+
sceneOf([dazed], { isSteadfast: true }),
184+
10,
185+
FRAME_MS,
186+
[holdFire],
187+
)
188+
189+
expect(
190+
Array.some(frames, frame =>
191+
Array.some(frame.frameEvents, GameEvent.guards.ZombieKilled),
192+
),
193+
).toBe(true)
194+
})
195+
196+
test('every Barn Buster pellet hits a zombie on the player and knocks it away', () => {
197+
const dazed = modifyFields(
198+
zombieOf('Shambler', 1, PLAYER_X, PLAYER_Y, { hp: 50 }),
199+
{
200+
action: () => ZombieAction.Dazed({ remainingMs: 1e9, durationMs: 1e9 }),
201+
},
202+
)
203+
const start = modifyFields(sceneOf([dazed], { isSteadfast: true }), {
204+
player: player =>
205+
modifyFields(player, {
206+
arsenal: () =>
207+
Arsenal.HoldingSpare({
208+
spare: { kind: 'Shotgun', rounds: 16, loaded: 6, tier: 0 },
209+
}),
210+
}),
211+
})
212+
const frames = playFrames(start, 1, FRAME_MS, [holdFire])
213+
const shot = frames[0]?.zombies[0]
214+
215+
expect(shot?.hp).toBe(50 - WEAPONS.Shotgun.pellets * WEAPONS.Shotgun.damage)
216+
expect(shot?.knockback.x ?? 0).toBeGreaterThan(0)
217+
})
218+
})

‎src/simulation.ts‎

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import {
3030
BULLET_MAX,
3131
BULLET_RADIUS,
32+
CONTACT_ALLOWANCE_PX,
3233
DRY_FIRE_DELAY_MS,
3334
KNOCKBACK_REST_SPEED,
3435
MAX_FRAME_DELTA_MS,
@@ -695,6 +696,42 @@ const tickZombieState = (state: ZombieState, deltaMs: number): ZombieState =>
695696
ZombieState.Dying({ remainingMs: remainingMs - deltaMs, direction }),
696697
})
697698

699+
const OUTWARD: Vector = { x: 1, y: 0 }
700+
701+
// NOTE: a free zombie stops at the player's body, at most
702+
// CONTACT_ALLOWANCE_PX deep, so it bites from the edge instead of standing on
703+
// the player where a shot cannot find it. Only the zombie moves, out along
704+
// the line from the player to where it stood before this step, so one fast
705+
// enough to cross the player's center lands back on the side it came from.
706+
// It moves through moveCircle, so walls and traps still hold it.
707+
const holdOffPlayer = (
708+
zombie: Zombie,
709+
moved: Vector,
710+
player: Vector,
711+
obstacles: Obstacles,
712+
): Vector => {
713+
const reach = bodyRadiusOf(zombie) + PLAYER_RADIUS - CONTACT_ALLOWANCE_PX
714+
const deltaX = moved.x - player.x
715+
const deltaY = moved.y - player.y
716+
if (!isFree(zombie) || deltaX * deltaX + deltaY * deltaY >= reach * reach) {
717+
return moved
718+
}
719+
720+
const away = pipe(
721+
directionBetween(player, zombie),
722+
Option.orElse(() => directionBetween(player, moved)),
723+
Option.getOrElse(() => OUTWARD),
724+
)
725+
const edge = addVectors(player, scaleVector(away, reach))
726+
return moveCircle(
727+
obstacles.level,
728+
moved,
729+
{ x: edge.x - moved.x, y: edge.y - moved.y },
730+
radiusOf(zombie),
731+
obstacles.blockers,
732+
)
733+
}
734+
698735
const advanceZombie = (
699736
zombie: Zombie,
700737
separation: Vector,
@@ -718,12 +755,17 @@ const advanceZombie = (
718755
),
719756
scaleVector(separation, SEPARATION_SPEED),
720757
)
721-
const moved = moveCircle(
722-
obstacles.level,
758+
const moved = holdOffPlayer(
723759
zombie,
724-
scaleVector(velocity, seconds),
725-
radiusOf(zombie),
726-
obstacles.blockers,
760+
moveCircle(
761+
obstacles.level,
762+
zombie,
763+
scaleVector(velocity, seconds),
764+
radiusOf(zombie),
765+
obstacles.blockers,
766+
),
767+
target,
768+
obstacles,
727769
)
728770
return modifyFields(zombie, {
729771
x: () => moved.x,
@@ -884,19 +926,34 @@ const shieldedTarget = (bullet: Bullet, zombie: Zombie): number =>
884926
)
885927
: zombie.id
886928

929+
// NOTE: the player's shots fired this frame. Their sweep starts at the
930+
// player's center, not the muzzle, so a zombie hugging the player, closer than
931+
// the muzzle, is still the first thing a point-blank shot hits.
932+
type FreshShots = Readonly<{ firstId: number; endId: number; from: Vector }>
933+
934+
const sweepStart = (
935+
bullet: Bullet,
936+
seconds: number,
937+
freshShots: FreshShots,
938+
): Vector =>
939+
bullet.id >= freshShots.firstId && bullet.id < freshShots.endId
940+
? freshShots.from
941+
: {
942+
x: bullet.x - bullet.velocityX * seconds,
943+
y: bullet.y - bullet.velocityY * seconds,
944+
}
945+
887946
const findBulletHits = (
888947
bullets: ReadonlyArray<Bullet>,
889948
zombies: ReadonlyArray<Zombie>,
890949
boss: Boss,
891950
seconds: number,
951+
freshShots: FreshShots,
892952
): ReadonlyArray<BulletHit> => {
893953
const targets = Array.filter(zombies, isGrounded)
894954
return Array.getSomes(
895955
Array.map(bullets, bullet => {
896-
const from = {
897-
x: bullet.x - bullet.velocityX * seconds,
898-
y: bullet.y - bullet.velocityY * seconds,
899-
}
956+
const from = sweepStart(bullet, seconds, freshShots)
900957
return pipe(
901958
targetsFor(bullet, targets),
902959
Array.map(zombie => maybeContact(from, bullet, zombie)),
@@ -994,8 +1051,22 @@ const skewer =
9941051
},
9951052
)
9961053

997-
const resolveBulletHits = (model: Model, seconds: number): Model => {
998-
const hits = findBulletHits(model.bullets, model.zombies, model.boss, seconds)
1054+
const resolveBulletHits = (
1055+
model: Model,
1056+
seconds: number,
1057+
firstFreshId: number,
1058+
): Model => {
1059+
const hits = findBulletHits(
1060+
model.bullets,
1061+
model.zombies,
1062+
model.boss,
1063+
seconds,
1064+
{
1065+
firstId: firstFreshId,
1066+
endId: model.nextEntityId,
1067+
from: model.player,
1068+
},
1069+
)
9991070
if (Array.isReadonlyArrayEmpty(hits)) {
10001071
return model
10011072
}
@@ -1115,8 +1186,6 @@ const resolvePlayerContact = (model: Model): Model =>
11151186

11161187
// WEAR
11171188

1118-
const OUTWARD: Vector = { x: 1, y: 0 }
1119-
11201189
const shedDirection = (zombie: Zombie, player: Player): Vector =>
11211190
pipe(
11221191
normalize(zombie.knockback),
@@ -1226,7 +1295,7 @@ const advanceWorld = (model: Model, deltaMs: number): Model => {
12261295

12271296
return pipe(
12281297
recordFootfalls(advanced, deltaMs),
1229-
footed => resolveBulletHits(footed, seconds),
1298+
footed => resolveBulletHits(footed, seconds, routed.nextEntityId),
12301299
trapped => advanceTraps(trapped, seconds, deltaMs),
12311300
resolvePlayerContact,
12321301
contacted => recoverActions(contacted, deltaMs),

0 commit comments

Comments
 (0)