dotframe 0.1.5 → 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.
@@ -0,0 +1,19 @@
1
+ // iOS entry for dotframe build ios: scriptc library mode, so the generated host calls init once with the bundle's
2
+ // game folder (assets under <base>/assets, engine fonts under <base>/dotframe/assets/fonts) and frame every refresh.
3
+ // Library mode has no promises: load assets synchronously with platform.readFile.
4
+ import type { Frame } from "dotframe/src/gpu";
5
+ import { openLibraryPlatform } from "dotframe/src/native/library";
6
+ import { WINDOW } from "./src/game";
7
+ import { createSetup } from "./src/setup";
8
+
9
+ let tick: Frame | null = null;
10
+
11
+ export function init(_base: string): void {
12
+ const platform = openLibraryPlatform(WINDOW);
13
+ tick = createSetup({ link: null, mash: null })(platform);
14
+ }
15
+
16
+ // Returns false to ask the host to quit.
17
+ export function frame(time: number): boolean {
18
+ return tick ? tick(time) : true;
19
+ }
@@ -1,6 +1,7 @@
1
1
  // Native entry for dotframe build macos|windows. The CLI stages it with the engine and compiles it with scriptc.
2
+ // Online play is web only for now (native builds have no WebSocket).
2
3
  import { run } from "dotframe/src/native/run";
3
4
  import { WINDOW } from "./src/game";
4
5
  import { createSetup } from "./src/setup";
5
6
 
6
- await run(WINDOW, createSetup());
7
+ await run(WINDOW, createSetup({ link: null, mash: null }));
@@ -1,5 +1,13 @@
1
+ import { connectRelay } from "dotframe/src/relay-client";
1
2
  import { run } from "dotframe/src/web/run";
2
3
  import { WINDOW } from "./src/game";
3
4
  import { createSetup } from "./src/setup";
4
5
 
5
- await run(WINDOW, createSetup());
6
+ // ?room=<name> plays online through a dotframe relay (?relay=ws://..., default the page's /relay). ?mash=<seed> lets
7
+ // a scripted player press random inputs, which is how `dotframe play --online` drives two browsers.
8
+ const params = new URLSearchParams(location.search);
9
+ const room = params.get("room");
10
+ const relay = params.get("relay") ?? `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/relay`;
11
+ const mash = params.get("mash");
12
+
13
+ await run(WINDOW, createSetup({ link: room ? connectRelay(relay, room) : null, mash: mash === null ? null : Number(mash) }));
@@ -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,27 +1,128 @@
1
- // Shared by main.web.ts and main.native.ts: fixed 60 Hz steps from the keyboard, then one render per frame.
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 { Key } from "dotframe/src/input";
5
- import type { Platform } from "dotframe/src/platform";
6
- import { createGame, keyInput, PLAYERS, render, step, WINDOW } from "./game";
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";
7
+ import { Key, MouseButton } from "dotframe/src/input";
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;
27
+ // A full charge takes one second of holding.
28
+ const FULL_CHARGE = 60;
9
29
 
10
- export function createSetup(): Setup {
11
- return ({ gpu, input }: Platform): Frame => {
12
- const draw = createDraw2D(gpu, WINDOW.width, WINDOW.height);
13
- 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;
14
59
  let simulated = -1;
60
+ // Hold to charge, release to act: the pattern most mouse and touch games need.
61
+ let charge = 0;
62
+ let wasDown = false;
15
63
  return (time: number): boolean => {
16
64
  if (simulated < 0) simulated = time;
17
- // Fixed steps, so every target plays exactly what dotframe sim simulates.
18
65
  for (let n = 0; simulated + STEP <= time && n < 5; n++) {
66
+ const pointer = input.pointer();
67
+ const inside = pointer.x >= 0 && pointer.x <= 1 && pointer.y >= 0 && pointer.y <= 1;
68
+ const down = inside && ((pointer.buttons & MouseButton.Left) !== 0 || input.touches().length > 0);
69
+ let released = false;
70
+ if (down) charge = Math.min(FULL_CHARGE, charge + 1);
71
+ else if (wasDown) {
72
+ released = true;
73
+ // Audio is presentation, never simulation state: the pitch says how charged the release was.
74
+ audio.tone(220 + (charge / FULL_CHARGE) * 660, 0.12, 0.25);
75
+ charge = 0;
76
+ }
77
+ wasDown = down;
19
78
  const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
20
- step(game, keys.slice(0, PLAYERS));
79
+ if (released) keys[0] = keys[0] | POINTER_BIT;
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
+ }
21
115
  simulated += STEP;
22
116
  }
23
117
  draw.begin();
118
+ draw.translate(offsetX, offsetY);
24
119
  render(game, draw);
120
+ if (charge > 0) {
121
+ // Charge meter under the play area.
122
+ draw.setFillStyle("#ffffff");
123
+ draw.fillRect(20, WINDOW.height - 16, (WINDOW.width - 40) * (charge / FULL_CHARGE), 6);
124
+ }
125
+ draw.resetTransform();
25
126
  draw.end({ r: 0, g: 0, b: 0 });
26
127
  return true;
27
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
  }
@@ -4,6 +4,8 @@ import type { Draw2D } from "dotframe/src/draw2d";
4
4
  import type { Input } from "dotframe/src/input";
5
5
 
6
6
  export const WINDOW = { width: 960, height: 540, title: "__NAME__" };
7
+ // The input bit a released mouse or touch press sets for player 1 (fires the action bit); see src/setup.ts.
8
+ export const POINTER_BIT = 8;
7
9
  export const PLAYERS = 2;
8
10
 
9
11
  export interface Game {
@@ -50,8 +52,15 @@ export function state(game: Game): unknown {
50
52
  return game;
51
53
  }
52
54
 
55
+ // Field by field (scriptc compiles neither structuredClone nor Object.assign): netplay saves every frame.
53
56
  export function snapshot(game: Game): Game {
54
- 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;
55
64
  }
56
65
 
57
66
  export function render(game: Game, draw: Draw2D): void {
@@ -11,6 +11,8 @@ const RIGHT = 2;
11
11
  const JUMP = 4;
12
12
  const ATTACK = 8;
13
13
  const GROUND = 440;
14
+ // The input bit a released mouse or touch press sets for player 1 (attacks); see src/setup.ts.
15
+ export const POINTER_BIT = ATTACK;
14
16
  const GRAVITY = 0.8;
15
17
  const SPEED = 5;
16
18
  const JUMP_SPEED = 15;
@@ -112,8 +114,20 @@ export function state(game: Game): unknown {
112
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 })) };
113
115
  }
114
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
+
115
122
  export function snapshot(game: Game): Game {
116
- 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;
117
131
  }
118
132
 
119
133
  const COLORS = ["#ff5a5f", "#3fa7ff"];
@@ -9,6 +9,8 @@ export const PLAYERS = 1;
9
9
  const LEFT = 1;
10
10
  const RIGHT = 2;
11
11
  const JUMP = 4;
12
+ // The input bit a released mouse or touch press sets for player 1 (jumps); see src/setup.ts.
13
+ export const POINTER_BIT = JUMP;
12
14
  const GRAVITY = 0.7;
13
15
  const SPEED = 4.5;
14
16
  const JUMP_SPEED = 13;
@@ -100,8 +102,32 @@ export function state(game: Game): unknown {
100
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 };
101
103
  }
102
104
 
105
+ // Field by field (scriptc compiles neither structuredClone nor Object.assign): netplay saves every frame.
103
106
  export function snapshot(game: Game): Game {
104
- 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;
105
131
  }
106
132
 
107
133
  export function render(game: Game, draw: Draw2D): void {