Skip to content

Commit 08fae5e

Browse files
Merge pull request #7 from Tanner-Scadden/coop-real-world
feat: play the real game together in co-op rooms
2 parents 26dc32a + fea2d81 commit 08fae5e

55 files changed

Lines changed: 4738 additions & 900 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 31 additions & 27 deletions
Large diffs are not rendered by default.

‎MULTIPLAYER.md‎

Lines changed: 38 additions & 10 deletions
Large diffs are not rendered by default.

‎infra/worker/gameRoom.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ import {
2424
type RoomTicket,
2525
SERVER_VERSIONS,
2626
ServerFrame,
27-
StandIn,
28-
type StandInWorld,
2927
decodeClientFrame,
3028
encodeServerFrame,
3129
normalizeRoomCode,
@@ -42,6 +40,11 @@ import {
4240
verifyToken,
4341
} from '@munch/sync-engine'
4442

43+
import {
44+
DEFAULT_ROOM_SETTINGS,
45+
type RoomWorld,
46+
roomWorldFor,
47+
} from '../../src/roomWorld.ts'
4548
import {
4649
INTERNAL_PATHS,
4750
InternalCreate,
@@ -84,13 +87,13 @@ import {
8487
// READY makes it Playing. A public room tells the Matchmaker (matchmaker.ts)
8588
// whenever its lobby changes. See MULTIPLAYER.md, Phase 2 and 3.
8689
//
87-
// The world is the stand-in (dots and walkers) until the game's own
88-
// `stepWorld` lands; it is the one line `World` below.
90+
// The world is the game's own (src/roomWorld.ts): `stepWorld` over the
91+
// lobby's seats, a campaign on Normal until the lobby picks a difficulty.
8992

9093
export type GameRoomBindings = SyncBindings
9194

92-
type GameWorld = StandInWorld
93-
const World = StandIn
95+
type GameWorld = RoomWorld
96+
const World = roomWorldFor(DEFAULT_ROOM_SETTINGS)
9497

9598
const SCOPE_HEADER = 'x-sync-scope'
9699
const GAME_TAG = 'game'

‎packages/netcode/src/Room.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,8 @@ export const ticksDue = (room: Room<unknown>, nowMs: number): number =>
620620

621621
// NOTE: one wake of the room's loop: the ticks the clock owes, one message
622622
// to everyone if a send tick passed, then presence, then the end of the
623-
// game. After an `Ended` out the room is done.
623+
// game, which first sends the last world if it is not out yet, so every
624+
// player sees how the run ended. After an `Ended` out the room is done.
624625
export const advance = <W, Own>(
625626
world: World<W, Own>,
626627
room: Room<W>,
@@ -645,9 +646,13 @@ export const advance = <W, Own>(
645646
const present = checkPresence(world, sent.room, nowMs)
646647
const out = [...sent.out, ...present.out]
647648
if (world.isOver(present.room.world)) {
649+
const last =
650+
present.room.sentTick === present.room.tick
651+
? { room: present.room, out: [] }
652+
: broadcast(world, present.room)
648653
return {
649-
room: present.room,
650-
out: [...out, ...endRoom(world, present.room, 'GameOver')],
654+
room: last.room,
655+
out: [...out, ...last.out, ...endRoom(world, last.room, 'GameOver')],
651656
}
652657
}
653658
if (Array.isReadonlyArrayEmpty(present.room.seats)) {

‎packages/netcode/src/Wire.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { Data, Result } from 'effect'
33
// NOTE: the bytes under the room protocol, after the estimate in
44
// tools/mp-spike/wire.ts: unsigned integers as LEB128 varints, signed ones
55
// zigzagged first, positions and other fractions as int16 fixed point,
6-
// strings and byte runs prefixed with their length. A Reader that runs off
6+
// strings and byte runs prefixed with their length, and any other number
7+
// as a little-endian float64. A Reader that runs off
78
// the end, or meets a malformed varint, throws a WireError, and `readAll`
89
// turns that into a failed Result, so callers never see an exception.
910

@@ -42,6 +43,7 @@ export interface Writer {
4243
readonly i16: (value: number) => void
4344
readonly fixed: (value: number, scale: number) => void
4445
readonly position: (value: number) => void
46+
readonly f64: (value: number) => void
4547
readonly bool: (value: boolean) => void
4648
readonly bytes: (value: Uint8Array) => void
4749
readonly raw: (value: Uint8Array) => void
@@ -106,6 +108,11 @@ export const makeWriter = (capacity = 64): Writer => {
106108
i16,
107109
fixed: (value, scale) => i16(fixedOf(value, scale)),
108110
position: value => i16(fixedOf(value, POSITION_SCALE)),
111+
f64: value => {
112+
const bytes = new Uint8Array(8)
113+
new DataView(bytes.buffer).setFloat64(0, value, true)
114+
raw(bytes)
115+
},
109116
bool: value => u8(value ? 1 : 0),
110117
bytes: value => {
111118
varint(value.length)
@@ -131,6 +138,7 @@ export interface Reader {
131138
readonly i16: () => number
132139
readonly fixed: (scale: number) => number
133140
readonly position: () => number
141+
readonly f64: () => number
134142
readonly bool: () => boolean
135143
readonly bytes: (maxLength: number) => Uint8Array
136144
readonly rest: () => Uint8Array
@@ -197,6 +205,14 @@ export const makeReader = (bytes: Uint8Array): Reader => {
197205
i16,
198206
fixed: scale => i16() / scale,
199207
position: () => i16() / POSITION_SCALE,
208+
f64: () => {
209+
const taken = take(8)
210+
return new DataView(
211+
taken.buffer,
212+
taken.byteOffset,
213+
taken.byteLength,
214+
).getFloat64(0, true)
215+
},
200216
bool: () => u8() !== 0,
201217
bytes: maxLength => take(lengthOf(maxLength)).slice(),
202218
rest: () => take(bytes.length - state.at).slice(),

‎packages/netcode/test/room.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ describe('the room', () => {
226226
)
227227
expect(Room.isEnded(end.out)).toBe(true)
228228
expect(tagsTo(end.out, 'socket-0').at(-1)).toBe('RoomClosed')
229+
expect(tagsTo(end.out, 'socket-0').at(-2)).toBe('Delta')
230+
expect(end.room.sentTick).toBe(end.room.tick)
229231
const ended = end.out.find(item => item._tag === 'Ended')
230232
expect(ended).toMatchObject({ _tag: 'Ended', reason: 'GameOver' })
231233
expect(
@@ -238,6 +240,20 @@ describe('the room', () => {
238240
])
239241
})
240242

243+
test('a run that ends between messages sends its last world first', () => {
244+
const endsAtTen = {
245+
...StandIn,
246+
isOver: (world: StandInWorld) => world.tick >= 10,
247+
}
248+
const started = Room.startRoom(endsAtTen, members([0, 1]), 7, T0)
249+
const atNine = Room.advance(endsAtTen, started.room, T0 + 9 * Room.TICK_MS)
250+
expect(atNine.room.sentTick).toBe(9)
251+
const atTen = Room.advance(endsAtTen, atNine.room, T0 + 10 * Room.TICK_MS)
252+
expect(Room.isEnded(atTen.out)).toBe(true)
253+
expect(tagsTo(atTen.out, 'socket-0')).toEqual(['Delta', 'RoomClosed'])
254+
expect(atTen.room.sentTick).toBe(10)
255+
})
256+
241257
test('Leave frees the seat at once; a kick says so first', () => {
242258
const started = start()
243259
const left = Room.receive(

‎src/audio.ts‎

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -18,72 +18,11 @@ import {
1818
runMusicScheduler,
1919
} from './music'
2020
import { generateMany, nextRandom } from './prng'
21+
import { SoundEffect } from './soundEffect'
2122

2223
// SOUND
2324

24-
export const SoundEffect = Schema.Literals([
25-
'Shot',
26-
'ShotgunBlast',
27-
'SmgShot',
28-
'DryClick',
29-
'MagOut',
30-
'MagIn',
31-
'Swap',
32-
'ZombieHit',
33-
'ZombieDeath',
34-
'RunnerHit',
35-
'RunnerDeath',
36-
'TankHit',
37-
'TankDeath',
38-
'ExploderHit',
39-
'ExploderFuse',
40-
'Boom',
41-
'Stomp',
42-
'PlayerHurt',
43-
'Start',
44-
'ShopBell',
45-
'Purchase',
46-
'PowerUp',
47-
'Denied',
48-
'Payday',
49-
'TurretShot',
50-
'Thunk',
51-
'Crack',
52-
'Crash',
53-
'Pop',
54-
'Groan',
55-
'DinnerBell',
56-
'LootPop',
57-
'GrabHotSauce',
58-
'GrabMeanBeans',
59-
'GrabZoomySocks',
60-
'GrabKebullets',
61-
'GrabBurp',
62-
'GrabHeart',
63-
'PowerDown',
64-
'LimbPop',
65-
'Clang',
66-
'BossRoar',
67-
'BossWindUp',
68-
'BossStomp',
69-
'BossBelch',
70-
'BossDeath',
71-
'BossChargeUp',
72-
'BossRush',
73-
'BossBonk',
74-
'BossSpit',
75-
'BossSplat',
76-
'Heave',
77-
'Whoosh',
78-
'Thud',
79-
'Rumble',
80-
'Burrow',
81-
'Clutch',
82-
'Yank',
83-
'Howl',
84-
'PackBonus',
85-
])
86-
export type SoundEffect = typeof SoundEffect.Type
25+
export { SoundEffect } from './soundEffect'
8726

8827
export const Playback = Schema.Literals(['Running', 'Suspended'])
8928
export type Playback = typeof Playback.Type

‎src/coop/coop.css‎

Lines changed: 21 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -941,112 +941,47 @@
941941
letter-spacing: 0.1em;
942942
}
943943

944-
/* ARENA: the world in a 16:9 frame, names over the players. */
944+
/* ARENA: the game's own scene shows through; co-op adds only LEAVE, the
945+
countdown, Away, and the trophy over it. */
945946

946-
.coop-arena {
947-
position: relative;
948-
display: grid;
949-
place-items: center;
950-
width: 100%;
951-
height: 100%;
952-
}
953-
954-
.coop-arena-frame {
955-
position: relative;
956-
container-type: size;
957-
width: min(100cqw, 100cqh * 16 / 9);
958-
aspect-ratio: 16 / 9;
959-
overflow: hidden;
960-
border-radius: var(--menu-radius-sm);
961-
box-shadow:
962-
0 0 0 3px #10090f,
963-
var(--menu-pop);
964-
}
965-
966-
.coop-arena-canvas {
967-
display: block;
968-
width: 100%;
969-
height: 100%;
970-
image-rendering: pixelated;
971-
touch-action: none;
972-
}
973-
974-
.coop-plates {
975-
position: absolute;
976-
inset: 0;
947+
.coop[data-screen='Arena'] {
948+
background: none;
949+
backdrop-filter: none;
977950
pointer-events: none;
951+
animation: none;
978952
}
979953

980-
.coop-plate {
954+
.coop-arena {
981955
position: absolute;
982-
display: flex;
983-
align-items: center;
984-
gap: 4px;
985-
padding: 1px 6px;
986-
border: 2px solid #10240a;
987-
border-radius: var(--menu-radius-sm);
988-
background: var(--coop-teal);
989-
color: #0a2220;
990-
font: bold clamp(10px, 2.2 * var(--coop-unit), 15px) / 1.2 var(--menu-font);
991-
white-space: nowrap;
992-
translate: -50% calc(-100% - 9cqh);
993-
}
994-
995-
.coop-plate[data-me='Me'] {
996-
background: var(--menu-gold);
997-
color: #241a06;
998-
}
999-
1000-
.coop-plate[data-away='Away'] {
1001-
opacity: 0.6;
1002-
}
1003-
1004-
.coop-plate-score {
1005-
padding: 0 4px;
1006-
border-radius: 3px;
1007-
background: rgba(0, 0, 0, 0.25);
1008-
}
1009-
1010-
.coop-plate-away {
1011-
width: 12px;
1012-
height: 10px;
1013-
background: center / contain no-repeat;
1014-
image-rendering: pixelated;
1015-
filter: brightness(0.2);
956+
inset: 0;
957+
container-type: size;
1016958
}
1017959

1018960
.coop-arena-hud {
1019961
position: absolute;
1020-
top: 8px;
1021-
right: 8px;
1022-
left: 8px;
962+
top: 6px;
963+
right: 6px;
1023964
display: flex;
1024965
align-items: center;
1025-
justify-content: space-between;
1026966
pointer-events: none;
1027967
}
1028968

1029969
.coop-arena-hud > * {
1030970
pointer-events: auto;
1031971
}
1032972

1033-
.coop-timer {
1034-
display: flex;
1035-
align-items: center;
1036-
gap: 6px;
1037-
padding: 6px 12px;
1038-
border: 2px solid var(--menu-gold);
1039-
border-radius: var(--menu-radius-pill);
1040-
background: var(--menu-glass);
1041-
color: var(--menu-gold);
1042-
font: bold clamp(16px, 4 * var(--coop-unit), 26px) / 1 var(--menu-font);
973+
/* In the run LEAVE sits where PAUSE would, the size of the corner buttons. */
974+
.coop-arena-hud .coop-leave {
975+
width: 48px;
976+
min-width: 48px;
977+
height: 48px;
978+
padding: 0;
979+
border-radius: 9999px;
980+
opacity: 0.8;
1043981
}
1044982

1045-
.coop-timer-icon {
1046-
width: 18px;
1047-
height: 18px;
1048-
background: center / contain no-repeat;
1049-
image-rendering: pixelated;
983+
.coop-net {
984+
display: none;
1050985
}
1051986

1052987
.coop-countdown {

0 commit comments

Comments
 (0)