dotframe 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // The dotframe CLI contract: dotframe sim, snap, replay, and desync drive the game through this module.
2
2
  import { defineSim, type SimPlatform, type SimRun } from "dotframe/src/sim";
3
- import { checksum, createGame, PLAYERS, encode, type Game, randomInput, render, snapshot, state, step, WINDOW } from "./src/game";
3
+ import { checksum, createGame, encode, type Game, PLAYERS, randomInput, render, restore, snapshot, state, step, WINDOW } from "./src/game";
4
4
 
5
5
  export default defineSim({
6
6
  players: PLAYERS,
@@ -23,9 +23,7 @@ export default defineSim({
23
23
  state: (): unknown => state(game),
24
24
  over: (): boolean => game.winner >= 0,
25
25
  save: (): unknown => snapshot(game),
26
- restore: (saved: unknown): void => {
27
- Object.assign(game, snapshot(saved as Game));
28
- },
26
+ restore: (saved: unknown): void => restore(game, saved as Game),
29
27
  inspect: (): unknown => game,
30
28
  render: (draw) => render(game, draw),
31
29
  };
@@ -1,18 +1,61 @@
1
1
  // Shared by main.web.ts and main.native.ts: keyboard and pointer to inputs, fixed 60 Hz steps, one render per frame.
2
+ // Online, the same steps go through dotframe's rollback netplay instead of straight to step().
2
3
  import { createDraw2D } from "dotframe/src/draw2d";
3
- import type { Frame, Setup } from "dotframe/src/gpu";
4
+ import type { AudioPlayer } from "dotframe/src/audio";
5
+ import type { Frame, RenderGpu } from "dotframe/src/gpu";
6
+ import type { Input } from "dotframe/src/input";
4
7
  import { Key, MouseButton } from "dotframe/src/input";
5
- import type { Platform } from "dotframe/src/platform";
6
- import { createGame, keyInput, PLAYERS, POINTER_BIT, render, step, WINDOW } from "./game";
8
+ import { createRollback, type RelayLink, type Rollback } from "dotframe/src/netplay";
9
+ import { checksum, createGame, type Game, keyInput, PLAYERS, POINTER_BIT, randomInput, render, restore, snapshot, step, WINDOW } from "./game";
10
+
11
+ export interface SetupOptions {
12
+ // An open relay link plays online as the slot the relay assigns; null plays locally.
13
+ link: RelayLink | null;
14
+ // A seed makes player 1 a scripted masher (tests); null reads the keyboard and pointer.
15
+ mash: number | null;
16
+ }
17
+
18
+ // What `dotframe play --online` reads from each browser.
19
+ interface Probe {
20
+ frame: number;
21
+ confirmed: number;
22
+ status: string;
23
+ sums: Record<number, number>;
24
+ }
7
25
 
8
26
  const STEP = 1 / 60;
9
27
  // A full charge takes one second of holding.
10
28
  const FULL_CHARGE = 60;
11
29
 
12
- export function createSetup(): Setup {
13
- return ({ gpu, input, audio }: Platform): Frame => {
14
- const draw = createDraw2D(gpu, WINDOW.width, WINDOW.height);
15
- const game = createGame(Math.floor(Math.random() * 1e9));
30
+ // What setup needs from a platform: the async web and native platforms and iOS's library platform all have it.
31
+ export interface SetupPlatform {
32
+ gpu: RenderGpu;
33
+ input: Input;
34
+ audio: AudioPlayer;
35
+ }
36
+
37
+ export function createSetup(options: SetupOptions): (platform: SetupPlatform) => Frame {
38
+ return ({ gpu, input, audio }: SetupPlatform): Frame => {
39
+ // The logical canvas keeps the game's aspect and grows to fill the screen: on a 2.16 phone the game area is
40
+ // centered with extra width around it instead of being stretched.
41
+ const aspect = gpu.aspect();
42
+ const logicalWidth = Math.max(WINDOW.width, Math.round(WINDOW.height * aspect));
43
+ const logicalHeight = Math.max(WINDOW.height, Math.round(WINDOW.width / aspect));
44
+ const offsetX = (logicalWidth - WINDOW.width) / 2;
45
+ const offsetY = (logicalHeight - WINDOW.height) / 2;
46
+ const draw = createDraw2D(gpu, logicalWidth, logicalHeight);
47
+ // Online peers must start from the same state, so the seed is fixed there.
48
+ const game: Game = createGame(options.link ? 1 : Math.floor(Math.random() * 1e9));
49
+ let rollback: Rollback | null = null;
50
+ let mashState = options.mash ?? 0;
51
+ const nextRandom = (): number => {
52
+ mashState = (Math.imul(mashState, 1103515245) + 12345) >>> 0;
53
+ return mashState / 4294967296;
54
+ };
55
+ let mashed = 0;
56
+ let mashInput = 0;
57
+ const probe: Probe = { frame: 0, confirmed: 0, status: options.link ? "connecting" : "local", sums: {} };
58
+ (globalThis as { __dotframe?: Probe }).__dotframe = probe;
16
59
  let simulated = -1;
17
60
  // Hold to charge, release to act: the pattern most mouse and touch games need.
18
61
  let charge = 0;
@@ -34,16 +77,52 @@ export function createSetup(): Setup {
34
77
  wasDown = down;
35
78
  const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
36
79
  if (released) keys[0] = keys[0] | POINTER_BIT;
37
- step(game, keys.slice(0, PLAYERS));
80
+ if (options.mash !== null) {
81
+ // A new random input every 6 frames, like a mashing player.
82
+ if (mashed % 6 === 0) mashInput = randomInput(nextRandom);
83
+ keys[0] = mashInput;
84
+ mashed += 1;
85
+ }
86
+ const link = options.link;
87
+ if (link) {
88
+ probe.status = link.status();
89
+ if (!rollback && link.status() === "paired") {
90
+ rollback = createRollback({
91
+ sim: {
92
+ step: (inputs: number[]): void => step(game, inputs),
93
+ save: (): Game => snapshot(game),
94
+ restore: (saved: Game): void => restore(game, saved),
95
+ checksum: (): number => checksum(game),
96
+ },
97
+ transport: link.transport,
98
+ localPort: link.slot(),
99
+ neutral: 0,
100
+ inputDelay: 2,
101
+ maxRollback: 20,
102
+ });
103
+ }
104
+ // Online, each browser controls its own player with player 1's keys.
105
+ if (rollback) {
106
+ rollback.tick(keys[0]);
107
+ probe.frame = rollback.stats().frame;
108
+ probe.confirmed = rollback.confirmedFrame();
109
+ for (let f = 30; f < probe.confirmed; f += 30) if (probe.sums[f] === undefined) probe.sums[f] = rollback.sumAt(f) ?? 0;
110
+ }
111
+ } else {
112
+ step(game, keys.slice(0, PLAYERS));
113
+ probe.frame += 1;
114
+ }
38
115
  simulated += STEP;
39
116
  }
40
117
  draw.begin();
118
+ draw.translate(offsetX, offsetY);
41
119
  render(game, draw);
42
120
  if (charge > 0) {
43
121
  // Charge meter under the play area.
44
122
  draw.setFillStyle("#ffffff");
45
123
  draw.fillRect(20, WINDOW.height - 16, (WINDOW.width - 40) * (charge / FULL_CHARGE), 6);
46
124
  }
125
+ draw.resetTransform();
47
126
  draw.end({ r: 0, g: 0, b: 0 });
48
127
  return true;
49
128
  };
@@ -10,5 +10,5 @@
10
10
  "skipLibCheck": true
11
11
  },
12
12
  "include": ["*.ts", "src", "scripts"],
13
- "exclude": ["main.native.ts"]
13
+ "exclude": ["main.native.ts", "main.ios.ts"]
14
14
  }
@@ -52,8 +52,15 @@ export function state(game: Game): unknown {
52
52
  return game;
53
53
  }
54
54
 
55
+ // Field by field (scriptc compiles neither structuredClone nor Object.assign): netplay saves every frame.
55
56
  export function snapshot(game: Game): Game {
56
- return structuredClone(game);
57
+ return { frame: game.frame, players: game.players.map((p) => ({ x: p.x, y: p.y })), winner: game.winner };
58
+ }
59
+
60
+ export function restore(game: Game, saved: Game): void {
61
+ game.frame = saved.frame;
62
+ game.players = saved.players.map((p) => ({ x: p.x, y: p.y }));
63
+ game.winner = saved.winner;
57
64
  }
58
65
 
59
66
  export function render(game: Game, draw: Draw2D): void {
@@ -114,8 +114,20 @@ export function state(game: Game): unknown {
114
114
  return { frame: game.frame, winner: game.winner, fighters: game.fighters.map(({ x, y, damage, stocks }) => ({ x: Math.round(x), y: Math.round(y), damage, stocks })) };
115
115
  }
116
116
 
117
+ // Field by field (scriptc compiles neither structuredClone nor Object.assign): netplay saves every frame.
118
+ function copyFighter(f: Fighter): Fighter {
119
+ return { x: f.x, y: f.y, vx: f.vx, vy: f.vy, facing: f.facing, damage: f.damage, stocks: f.stocks, cooldown: f.cooldown, stun: f.stun, held: f.held };
120
+ }
121
+
117
122
  export function snapshot(game: Game): Game {
118
- return structuredClone(game);
123
+ return { frame: game.frame, seed: game.seed, fighters: game.fighters.map(copyFighter), winner: game.winner };
124
+ }
125
+
126
+ export function restore(game: Game, saved: Game): void {
127
+ game.frame = saved.frame;
128
+ game.seed = saved.seed;
129
+ game.fighters = saved.fighters.map(copyFighter);
130
+ game.winner = saved.winner;
119
131
  }
120
132
 
121
133
  const COLORS = ["#ff5a5f", "#3fa7ff"];
@@ -102,8 +102,32 @@ export function state(game: Game): unknown {
102
102
  return { frame: game.frame, x: Math.round(game.x), y: Math.round(game.y), coins: game.coins.filter((c) => c.taken).length, of: game.coins.length, won: game.winner === 0 };
103
103
  }
104
104
 
105
+ // Field by field (scriptc compiles neither structuredClone nor Object.assign): netplay saves every frame.
105
106
  export function snapshot(game: Game): Game {
106
- return structuredClone(game);
107
+ return {
108
+ frame: game.frame,
109
+ x: game.x,
110
+ y: game.y,
111
+ vx: game.vx,
112
+ vy: game.vy,
113
+ onGround: game.onGround,
114
+ held: game.held,
115
+ coins: game.coins.map((c) => ({ x: c.x, y: c.y, taken: c.taken })),
116
+ winner: game.winner,
117
+ };
118
+ }
119
+
120
+ export function restore(game: Game, saved: Game): void {
121
+ const s = snapshot(saved);
122
+ game.frame = s.frame;
123
+ game.x = s.x;
124
+ game.y = s.y;
125
+ game.vx = s.vx;
126
+ game.vy = s.vy;
127
+ game.onGround = s.onGround;
128
+ game.held = s.held;
129
+ game.coins = s.coins;
130
+ game.winner = s.winner;
107
131
  }
108
132
 
109
133
  export function render(game: Game, draw: Draw2D): void {