@origingame/create-game 0.1.1 → 0.3.0

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.
Files changed (179) hide show
  1. package/README.md +17 -9
  2. package/archetypes/README.md +5 -2
  3. package/archetypes/arena/.origin/setup +14 -0
  4. package/archetypes/arena/LICENSES.md +8 -0
  5. package/archetypes/arena/README.md +67 -0
  6. package/archetypes/arena/assets/audio/coin.ogt +9 -0
  7. package/archetypes/arena/assets/audio/dash.ogt +9 -0
  8. package/archetypes/arena/assets/audio/win.ogt +9 -0
  9. package/archetypes/arena/assets/fx/coin.ogt +20 -0
  10. package/archetypes/arena/assets/looks/arena.ogt +10 -0
  11. package/archetypes/arena/gitattributes +22 -0
  12. package/archetypes/arena/gitignore +8 -0
  13. package/archetypes/arena/host.d.ts +46 -0
  14. package/archetypes/arena/package.json +18 -0
  15. package/archetypes/arena/project.ogt +14 -0
  16. package/archetypes/arena/src/game.ts +314 -0
  17. package/archetypes/arena/src/main.ts +100 -0
  18. package/archetypes/arena/tests/duel.og.ts +32 -0
  19. package/archetypes/arena/tests/solo.og.ts +22 -0
  20. package/archetypes/arena/tsconfig.json +17 -0
  21. package/archetypes/idle/LICENSES.md +1 -1
  22. package/archetypes/idle/README.md +3 -3
  23. package/archetypes/idle/assets/looks/idle.ogt +1 -1
  24. package/archetypes/idle/assets/{skins → styles}/sparkorb/LICENSES.md +4 -4
  25. package/archetypes/idle/assets/{skins/sparkorb/skin.ogt → styles/sparkorb/style.ogt} +47 -47
  26. package/archetypes/idle/package.json +1 -1
  27. package/archetypes/idle/project.ogt +1 -1
  28. package/archetypes/idle/references/metal/card.png +0 -0
  29. package/archetypes/idle/references/metal/orbit.png +0 -0
  30. package/archetypes/idle/references/metal/title.png +0 -0
  31. package/archetypes/idle/src/main.ts +2 -2
  32. package/archetypes/idle/src/ui.ts +11 -11
  33. package/archetypes/idle/tools/make-assets.py +4 -4
  34. package/archetypes/match3/package.json +1 -1
  35. package/archetypes/match3/project.ogt +1 -1
  36. package/archetypes/rhythm/LICENSES.md +1 -1
  37. package/archetypes/rhythm/README.md +3 -3
  38. package/archetypes/rhythm/assets/{skins → styles}/lanebeat/LICENSES.md +4 -4
  39. package/archetypes/rhythm/assets/{skins/lanebeat/skin.ogt → styles/lanebeat/style.ogt} +45 -45
  40. package/archetypes/rhythm/package.json +1 -1
  41. package/archetypes/rhythm/project.ogt +1 -1
  42. package/archetypes/rhythm/references/metal/results.png +0 -0
  43. package/archetypes/rhythm/references/metal/title.png +0 -0
  44. package/archetypes/rhythm/src/main.ts +2 -2
  45. package/archetypes/rhythm/src/ui.ts +11 -11
  46. package/archetypes/roguelike/LICENSES.md +1 -1
  47. package/archetypes/roguelike/README.md +3 -3
  48. package/archetypes/roguelike/assets/{skins → styles}/lampdive/LICENSES.md +4 -4
  49. package/archetypes/roguelike/assets/{skins/lampdive/skin.ogt → styles/lampdive/style.ogt} +41 -41
  50. package/archetypes/roguelike/package.json +1 -1
  51. package/archetypes/roguelike/project.ogt +1 -1
  52. package/archetypes/roguelike/references/metal/over.png +0 -0
  53. package/archetypes/roguelike/references/metal/title.png +0 -0
  54. package/archetypes/roguelike/src/main.ts +2 -2
  55. package/archetypes/roguelike/src/ui.ts +7 -7
  56. package/archetypes/runner/package.json +1 -1
  57. package/archetypes/runner/project.ogt +1 -1
  58. package/archetypes/third-person/LICENSES.md +1 -1
  59. package/archetypes/third-person/README.md +3 -3
  60. package/archetypes/third-person/assets/{skins → styles}/ember/LICENSES.md +2 -2
  61. package/archetypes/third-person/assets/{skins/ember/skin.ogt → styles/ember/style.ogt} +40 -41
  62. package/archetypes/third-person/package.json +1 -1
  63. package/archetypes/third-person/project.ogt +1 -1
  64. package/archetypes/third-person/references/metal/beacon.png +0 -0
  65. package/archetypes/third-person/references/metal/dawn.png +0 -0
  66. package/archetypes/third-person/references/metal/title.png +0 -0
  67. package/archetypes/third-person/references/webgl2/beacon.png +0 -0
  68. package/archetypes/third-person/src/main.ts +2 -2
  69. package/archetypes/third-person/src/ui.ts +10 -10
  70. package/archetypes/topdown/package.json +1 -1
  71. package/archetypes/topdown/project.ogt +1 -1
  72. package/archetypes/tower-defense/LICENSES.md +1 -1
  73. package/archetypes/tower-defense/README.md +5 -5
  74. package/archetypes/tower-defense/assets/{skins → styles}/pathguard/LICENSES.md +4 -4
  75. package/archetypes/tower-defense/assets/styles/pathguard/atlas.png +0 -0
  76. package/archetypes/tower-defense/assets/styles/pathguard/style.ogt +111 -0
  77. package/archetypes/tower-defense/package.json +1 -1
  78. package/archetypes/tower-defense/project.ogt +1 -1
  79. package/archetypes/tower-defense/references/metal/end.png +0 -0
  80. package/archetypes/tower-defense/references/metal/title.png +0 -0
  81. package/archetypes/tower-defense/references/metal/wave.png +0 -0
  82. package/archetypes/tower-defense/src/main.ts +2 -2
  83. package/archetypes/tower-defense/src/ui.ts +19 -17
  84. package/bin/create.ts +80 -20
  85. package/looks/catalogue.json +40 -40
  86. package/package.json +5 -4
  87. package/template/README.md +2 -1
  88. package/template/assets/audio/sting.ogt +10 -0
  89. package/template/assets/timelines/intro.ogt +101 -0
  90. package/template/assets/timelines/narration.srt +11 -0
  91. package/template/host.d.ts +46 -0
  92. package/template/package.json +1 -1
  93. package/template/project.ogt +1 -1
  94. package/template/references/metal/intro-4.png +0 -0
  95. package/template/references/metal/intro-8.png +0 -0
  96. package/template/src/main.ts +1 -1
  97. package/template/tests/frames.ts +134 -0
  98. package/template/tests/intro.og.ts +65 -0
  99. package/template/tests/smoke.og.ts +30 -36
  100. package/template/tsconfig.json +1 -1
  101. package/template-2d/.origin/setup +14 -0
  102. package/template-2d/LICENSES.md +15 -0
  103. package/template-2d/README.md +42 -0
  104. package/template-2d/assets/audio/coin.wav +0 -0
  105. package/template-2d/assets/audio/jump.wav +0 -0
  106. package/template-2d/assets/fonts/OFL.txt +93 -0
  107. package/template-2d/assets/fonts/ui.ttf +0 -0
  108. package/template-2d/assets/fonts/ui.ttf.ogt +11 -0
  109. package/template-2d/assets/looks/origin-game-2d.ogt +12 -0
  110. package/template-2d/assets/maps/level.ogt +112 -0
  111. package/template-2d/assets/rigs/keeper.ogt +292 -0
  112. package/template-2d/assets/rigs/keeper.png +0 -0
  113. package/template-2d/assets/rigs/keeper.png.ogt +7 -0
  114. package/template-2d/assets/sprites/coin.ogt +11 -0
  115. package/template-2d/assets/sprites/coin.png +0 -0
  116. package/template-2d/assets/sprites/coin.png.ogt +7 -0
  117. package/template-2d/assets/sprites/courier.ogt +34 -0
  118. package/template-2d/assets/sprites/courier.png +0 -0
  119. package/template-2d/assets/sprites/courier.png.ogt +7 -0
  120. package/template-2d/assets/strings/en.ogt +11 -0
  121. package/template-2d/assets/tiles/city.ogt +17 -0
  122. package/template-2d/assets/tiles/city.png +0 -0
  123. package/template-2d/assets/tiles/city.png.ogt +7 -0
  124. package/template-2d/gitattributes +22 -0
  125. package/template-2d/gitignore +8 -0
  126. package/template-2d/package.json +18 -0
  127. package/template-2d/project.ogt +21 -0
  128. package/template-2d/src/main.ts +460 -0
  129. package/template-2d/tests/keeper.og.ts +93 -0
  130. package/template-2d/tests/smoke.og.ts +60 -0
  131. package/template-2d/tsconfig.json +17 -0
  132. package/archetypes/tower-defense/assets/skins/pathguard/atlas.png +0 -0
  133. package/archetypes/tower-defense/assets/skins/pathguard/skin.ogt +0 -111
  134. /package/archetypes/idle/assets/{skins → styles}/sparkorb/ChakraPetch-BoldItalic.ttf +0 -0
  135. /package/archetypes/idle/assets/{skins → styles}/sparkorb/ChakraPetch-BoldItalic.ttf.ogt +0 -0
  136. /package/archetypes/idle/assets/{skins → styles}/sparkorb/OFL.txt +0 -0
  137. /package/archetypes/idle/assets/{skins → styles}/sparkorb/Orbitron-Medium.ttf +0 -0
  138. /package/archetypes/idle/assets/{skins → styles}/sparkorb/Orbitron-Medium.ttf.ogt +0 -0
  139. /package/archetypes/idle/assets/{skins → styles}/sparkorb/Rajdhani-Bold.ttf +0 -0
  140. /package/archetypes/idle/assets/{skins → styles}/sparkorb/Rajdhani-Bold.ttf.ogt +0 -0
  141. /package/archetypes/idle/assets/{skins → styles}/sparkorb/atlas.png +0 -0
  142. /package/archetypes/idle/assets/{skins → styles}/sparkorb/atlas.png.ogt +0 -0
  143. /package/archetypes/idle/assets/{skins → styles}/sparkorb/icons.png +0 -0
  144. /package/archetypes/idle/assets/{skins → styles}/sparkorb/icons.png.ogt +0 -0
  145. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/ChakraPetch-Medium.ttf +0 -0
  146. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/ChakraPetch-Medium.ttf.ogt +0 -0
  147. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/JetBrainsMono-Regular.ttf +0 -0
  148. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/JetBrainsMono-Regular.ttf.ogt +0 -0
  149. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/OFL.txt +0 -0
  150. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/Orbitron-Bold.ttf +0 -0
  151. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/Orbitron-Bold.ttf.ogt +0 -0
  152. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/atlas.png +0 -0
  153. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/atlas.png.ogt +0 -0
  154. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/icons.png +0 -0
  155. /package/archetypes/rhythm/assets/{skins → styles}/lanebeat/icons.png.ogt +0 -0
  156. /package/archetypes/roguelike/assets/{skins → styles}/lampdive/DotGothic16-Regular.ttf +0 -0
  157. /package/archetypes/roguelike/assets/{skins → styles}/lampdive/DotGothic16-Regular.ttf.ogt +0 -0
  158. /package/archetypes/roguelike/assets/{skins → styles}/lampdive/OFL.txt +0 -0
  159. /package/archetypes/roguelike/assets/{skins → styles}/lampdive/atlas.png +0 -0
  160. /package/archetypes/roguelike/assets/{skins → styles}/lampdive/atlas.png.ogt +0 -0
  161. /package/archetypes/roguelike/assets/{skins → styles}/lampdive/icons.png +0 -0
  162. /package/archetypes/roguelike/assets/{skins → styles}/lampdive/icons.png.ogt +0 -0
  163. /package/archetypes/third-person/assets/{skins → styles}/ember/Cinzel-Bold.ttf +0 -0
  164. /package/archetypes/third-person/assets/{skins → styles}/ember/Cinzel-Bold.ttf.ogt +0 -0
  165. /package/archetypes/third-person/assets/{skins → styles}/ember/Newsreader-Medium.ttf +0 -0
  166. /package/archetypes/third-person/assets/{skins → styles}/ember/Newsreader-Medium.ttf.ogt +0 -0
  167. /package/archetypes/third-person/assets/{skins → styles}/ember/OFL.txt +0 -0
  168. /package/archetypes/third-person/assets/{skins → styles}/ember/atlas.png +0 -0
  169. /package/archetypes/third-person/assets/{skins → styles}/ember/atlas.png.ogt +0 -0
  170. /package/archetypes/third-person/assets/{skins → styles}/ember/icons.png +0 -0
  171. /package/archetypes/third-person/assets/{skins → styles}/ember/icons.png.ogt +0 -0
  172. /package/archetypes/tower-defense/assets/{skins → styles}/pathguard/Fredoka-SemiBold.ttf +0 -0
  173. /package/archetypes/tower-defense/assets/{skins → styles}/pathguard/Fredoka-SemiBold.ttf.ogt +0 -0
  174. /package/archetypes/tower-defense/assets/{skins → styles}/pathguard/LilitaOne-Regular.ttf +0 -0
  175. /package/archetypes/tower-defense/assets/{skins → styles}/pathguard/LilitaOne-Regular.ttf.ogt +0 -0
  176. /package/archetypes/tower-defense/assets/{skins → styles}/pathguard/OFL.txt +0 -0
  177. /package/archetypes/tower-defense/assets/{skins → styles}/pathguard/atlas.png.ogt +0 -0
  178. /package/archetypes/tower-defense/assets/{skins → styles}/pathguard/icons.png +0 -0
  179. /package/archetypes/tower-defense/assets/{skins → styles}/pathguard/icons.png.ogt +0 -0
@@ -0,0 +1,460 @@
1
+ // The 2D starter's entry: its default export is og.game(...). `og run .`
2
+ // runs it headless with the probe; `og dev .` reloads it on every save;
3
+ // `og build .` bundles it for the play page.
4
+ //
5
+ // A courier runs and jumps the rooftops of one level: a hand-written
6
+ // tilemap (assets/maps/level.ogt) whose roofs collide and whose ledges are
7
+ // one-way on their own collider layer, an og.PlatformerController moved by
8
+ // the arrows, WASD, Space or a pad, the courier's flipbook clips chosen by
9
+ // what the controller did, coins to take, a pit that costs a life, a goal
10
+ // at the far end, a camera that follows, and the UI kit's platformer HUD.
11
+ // Everything the starter shows is in og-game §8 (sprites, flipbooks,
12
+ // tilemaps), §13 (the controller) and og-feel §12 (its tunables).
13
+ //
14
+ // Beside the spawn a lamp keeper stands on a ramp: a 2D skeletal rig
15
+ // (assets/rigs/keeper.ogt, og.Skeleton2D) whose cape is a weighted mesh the
16
+ // idle clip deforms, whose feet two-bone IK planted on the slope
17
+ // (og.IkTarget2D, the Plant system), and whose lantern lights the sprite
18
+ // layers through og.Lighting2D (og-game §8; og-anim's 2D skeletons). Sent
19
+ // along the ramp (`keeper:<x>`), it plays its walk clip there, each foot
20
+ // planted while it carries the keeper and lifted while it swings, and
21
+ // breathes again when it arrives (the Walk and Plant systems).
22
+ //
23
+ // The frame's look is data: GameDesc.look names a file under assets/looks/
24
+ // that extends a preset of the style catalogue, applied before start runs.
25
+
26
+ import { og, defineComponent, defineResource, defineSystem, bool, f32, u32, SHAPE_BOX } from "@origingame/engine";
27
+ import type { FlipbookInfo, Result, Rig2DInfo, TileObject, World } from "@origingame/engine";
28
+
29
+ /** The courier's clips, in the sheet's file order (og-game §8): `start` checks them. */
30
+ const CLIP = { idle: 0, run: 1, jump: 2, fall: 3 } as const;
31
+ /** The camera's height in world units: 14 cells of 16 px, the 16bit-pixel look's 224-pixel frame. */
32
+ const VIEW_H = 14;
33
+ /** The ledges' collider layer (the map's `colliderLayers 2`), one-way for the courier. */
34
+ const LEDGES = 2;
35
+ /** The courier's box (og.Collider): its feet are its position. */
36
+ const BOX = { w: 0.9, h: 1.9 } as const;
37
+ /** Below this the courier fell into the pit. */
38
+ const FALL_Y = -3;
39
+ const LIVES = 3;
40
+ /** The keeper's ramp: a plank from (x0, y0) on the roof rising at `slope` (tan 12°) to x1. */
41
+ const RAMP = { x0: 4.5, y0: 2, x1: 7.6, slope: 0.21256, sinHalf: 0.104528, cosHalf: 0.994522 } as const;
42
+ /** The ramp's top at x. */
43
+ const rampY = (x: number): number => RAMP.y0 + RAMP.slope * (x - RAMP.x0);
44
+ /** The keeper's ankles and its root above the ramp (its legs reach 1.1 from a hip 1.1 up: a slight bend), the lantern from its feet, in world units. */
45
+ const ANKLE = 0.12;
46
+ const LIFT = 0.08;
47
+ const LANTERN = { x: 0.25, y: 1.0 } as const;
48
+ /** The keeper's gait: the walk clip's 0.8 s cycle carries it a stride of 0.4 m (a step per foot), each foot lifted at most `lift` mid-swing. */
49
+ const WALK = { speed: 0.5, stride: 0.4, lift: 0.08 } as const;
50
+
51
+ /** The courier. */
52
+ export const Hero = defineComponent("Hero", { spawnX: f32(0, { unit: "m" }), spawnY: f32(0, { unit: "m" }) });
53
+
54
+ /** A coin of the map's `coin` objects; taken once. */
55
+ export const Coin = defineComponent("Coin", { taken: bool() });
56
+
57
+ /** One of the keeper's feet: its IK target stays on the ramp `dx` ahead of the keeper (behind when negative), `lag` of a gait cycle after the front foot. */
58
+ export const Plant = defineComponent("Plant", { dx: f32(0, { unit: "m" }), lag: f32(0, { min: 0, max: 1 }) });
59
+
60
+ /** A voice: the sound bit (Sound) it plays when Game.sfx asks for it. */
61
+ export const Voice = defineComponent("Voice", { bit: u32() });
62
+
63
+ const Sound = { Jump: 1, Coin: 2 } as const;
64
+
65
+ /** The run: what the HUD shows and the systems share. */
66
+ export const Game = defineResource("Game", {
67
+ coins: u32(),
68
+ total: u32(),
69
+ lives: u32(LIVES),
70
+ won: bool(),
71
+ /** The last life was lost: Pickups starts the level again. */
72
+ restart: bool(),
73
+ goalX: f32(0, { unit: "m" }),
74
+ heroX: f32(0, { unit: "m" }),
75
+ heroY: f32(0, { unit: "m" }),
76
+ /** Sounds asked for this tick (Sound bits), played by Voices. */
77
+ sfx: u32(),
78
+ /** The pause menu is open: the courier and time hold. */
79
+ paused: bool(),
80
+ /** The HUD's touch controls this tick (a phone): the run and the held jump. */
81
+ touchX: f32(0, { min: -1, max: 1 }),
82
+ touchJump: bool(),
83
+ /** The keeper's breaths: og.Skeleton2DEvent "inhale" (the idle clip's event at 0). */
84
+ inhales: u32(),
85
+ /** The keeper's footfalls: og.Skeleton2DEvent "step" (the walk clip's, one per foot). */
86
+ steps: u32(),
87
+ });
88
+
89
+ /** Where the keeper walks (`keeper:<x>`), how far it has walked since it set off (its gait's phase), and its clips. */
90
+ export const KeeperWalk = defineResource("KeeperWalk", {
91
+ goal: f32(6, { unit: "m" }),
92
+ walked: f32(0, { unit: "m" }),
93
+ walking: bool(),
94
+ /** -1 facing left (towards the spawn), 1 right. */
95
+ facing: f32(-1),
96
+ idle: u32(),
97
+ walk: u32(),
98
+ });
99
+
100
+ function must<T>(r: Result<T>): T {
101
+ if (!r.ok) throw new Error(r.reason);
102
+ return r.value;
103
+ }
104
+
105
+ /**
106
+ * Input to the controller, on the press's tick (og-feel §1): the run, the
107
+ * held jump (released early, the jump is lower), Down to drop through a
108
+ * ledge. The jump's sound starts on the press while the controller can
109
+ * jump (og-feel §12). A fall into the pit costs a life and puts the
110
+ * courier back at the spawn; the last life starts the level again.
111
+ */
112
+ export const Control = defineSystem(
113
+ "Control",
114
+ { reads: [Hero, og.Input, og.Gamepad], writes: [Game, og.PlatformerController, og.Transform], after: ["Hud"] },
115
+ (q, _time, world) => {
116
+ const g = must(world.resource(Game));
117
+ const input = must(world.resource(og.Input));
118
+ const pad = must(world.resource(og.Gamepad));
119
+ const stick = pad.axis("leftX", 0);
120
+ const run = Math.max(-1, Math.min(1, input.axis("ArrowLeft", "ArrowRight") + input.axis("KeyA", "KeyD") + (Math.abs(stick) > 0.25 ? stick : 0) + g.touchX));
121
+ const jump = input.down("Space") || input.down("ArrowUp") || input.down("KeyW") || pad.down("a", 0) || g.touchJump;
122
+ const pressed = input.pressed("Space") || input.pressed("ArrowUp") || input.pressed("KeyW") || pad.pressed("a", 0);
123
+ const down = input.down("ArrowDown") || input.down("KeyS") || pad.down("down", 0);
124
+ for (const a of q.archetypes()) {
125
+ const hero = a.column(Hero);
126
+ const pc = a.column(og.PlatformerController);
127
+ const p = a.column(og.Transform).position;
128
+ for (let i = 0; i < a.count; i++) {
129
+ const playing = !g.won && !g.paused;
130
+ pc.moveX[i] = playing ? run : 0;
131
+ pc.jump[i] = playing && jump ? 1 : 0;
132
+ pc.down[i] = playing && down ? 1 : 0;
133
+ if (playing && pressed && (pc.grounded[i] === 1 || pc.coyote[i]! > 0)) g.sfx |= Sound.Jump;
134
+ if (p[i * 3 + 1]! < FALL_Y) {
135
+ g.lives -= 1;
136
+ // The last life: Pickups puts every coin back.
137
+ if (g.lives === 0) g.restart = true;
138
+ p[i * 3] = hero.spawnX[i]!;
139
+ p[i * 3 + 1] = hero.spawnY[i]!;
140
+ pc.velocity[i * 3] = pc.velocity[i * 3 + 1] = 0;
141
+ }
142
+ g.heroX = p[i * 3]!;
143
+ g.heroY = p[i * 3 + 1]!;
144
+ }
145
+ }
146
+ },
147
+ );
148
+
149
+ /** A coin the courier's box touches is taken; reaching the goal clears the level; the last life lost puts every coin back. */
150
+ export const Pickups = defineSystem("Pickups", { writes: [Game, Coin, og.Sprite], reads: [og.Transform], after: ["Control"] }, (q, _time, world) => {
151
+ const g = must(world.resource(Game));
152
+ const restart = g.restart;
153
+ if (restart) {
154
+ // The last life was lost: every coin back, the lives full.
155
+ g.restart = false;
156
+ g.coins = 0;
157
+ g.lives = LIVES;
158
+ }
159
+ for (const a of q.archetypes()) {
160
+ const coin = a.column(Coin);
161
+ const sprite = a.column(og.Sprite);
162
+ const p = a.column(og.Transform).position;
163
+ for (let i = 0; i < a.count; i++) {
164
+ if (restart) {
165
+ coin.taken[i] = 0;
166
+ sprite.visible[i] = 1;
167
+ }
168
+ if (coin.taken[i] === 1) continue;
169
+ const dx = Math.abs(p[i * 3]! - g.heroX);
170
+ const dy = p[i * 3 + 1]! - g.heroY;
171
+ if (dx < BOX.w / 2 + 0.35 && dy > -0.35 && dy < BOX.h + 0.35) {
172
+ coin.taken[i] = 1;
173
+ sprite.visible[i] = 0;
174
+ g.coins += 1;
175
+ g.sfx |= Sound.Coin;
176
+ }
177
+ }
178
+ }
179
+ if (!g.won && g.heroX >= g.goalX) {
180
+ g.won = true;
181
+ g.sfx |= Sound.Coin;
182
+ }
183
+ });
184
+
185
+ /** The courier's clip from what its controller did, and its facing. */
186
+ export const Animate = defineSystem("Animate", { reads: [Hero, og.PlatformerController], writes: [og.Flipbook, og.Sprite], after: ["Control"] }, (q) => {
187
+ for (const a of q.archetypes()) {
188
+ const pc = a.column(og.PlatformerController);
189
+ const fb = a.column(og.Flipbook);
190
+ const sprite = a.column(og.Sprite);
191
+ for (let i = 0; i < a.count; i++) {
192
+ const vx = pc.velocity[i * 3]!;
193
+ const vy = pc.velocity[i * 3 + 1]!;
194
+ const clip = pc.grounded[i] !== 1 ? (vy > 0 ? CLIP.jump : CLIP.fall) : Math.abs(vx) > 0.2 ? CLIP.run : CLIP.idle;
195
+ if (fb.clip[i] !== clip) fb.clip[i] = clip;
196
+ sprite.flipX[i] = pc.facing[i]! < 0 ? 1 : 0;
197
+ }
198
+ }
199
+ });
200
+
201
+ /**
202
+ * Walks the keeper towards KeeperWalk.goal along the ramp at the gait's
203
+ * speed (by scaled time, as the clip plays: paused, both hold), facing the
204
+ * way it goes, with the walk clip; on arrival it faces the spawn again with
205
+ * the idle clip (which starts over, breathing in).
206
+ */
207
+ export const Walk = defineSystem("Walk", { writes: [KeeperWalk, og.Skeleton2D, og.Transform], after: ["Control"] }, (_q, time, world) => {
208
+ const keeper = world.find("keeper");
209
+ if (keeper === null) return;
210
+ const w = must(world.resource(KeeperWalk));
211
+ const x = must(world.get(keeper, og.Transform)).position.x;
212
+ const left = w.goal - x;
213
+ if (left === 0 && !w.walking) return;
214
+ const step = WALK.speed * time.scaledDt;
215
+ const arrived = Math.abs(left) <= step;
216
+ const nx = arrived ? w.goal : x + Math.sign(left) * step;
217
+ w.walked = arrived ? 0 : (w.walking ? w.walked : 0) + Math.abs(nx - x);
218
+ w.walking = !arrived;
219
+ w.facing = arrived ? -1 : Math.sign(left);
220
+ must(world.set(keeper, og.Transform, { position: { x: nx, y: rampY(nx) + LIFT, z: 0 } }));
221
+ const s = must(world.get(keeper, og.Skeleton2D));
222
+ const clip = arrived ? w.idle : w.walk;
223
+ const flipX = w.facing < 0;
224
+ // Only what changed: a new clip starts at 0 (the idle clip's breath, the walk's first step).
225
+ if (s.clip !== clip || s.flipX !== flipX) must(world.set(keeper, og.Skeleton2D, { clip, flipX }));
226
+ });
227
+
228
+ /**
229
+ * Puts the keeper's feet on the ramp (og.IkTarget2D: the legs' two-bone IK
230
+ * reaches each target, the knees bend as the hip bobs). Standing, each foot
231
+ * is `dx` from the keeper; walking, the front foot's gait phase is the
232
+ * distance walked over the stride and the back foot's half a cycle later:
233
+ * for half a cycle a foot stays where it is on the plank while the keeper
234
+ * passes over it, for the other half it swings a stride forward, lifted on
235
+ * a parabola. Keeps the lantern's light at its hand, and counts the
236
+ * clips' breaths and footfalls.
237
+ */
238
+ export const Plant2D = defineSystem("Plant", { reads: [Plant, KeeperWalk, og.Skeleton2DEvent], writes: [Game, og.IkTarget2D, og.Transform], after: ["Walk"] }, (q, _time, world) => {
239
+ const keeper = world.find("keeper");
240
+ if (keeper === null) return;
241
+ const at = must(world.get(keeper, og.Transform)).position;
242
+ const w = must(world.resource(KeeperWalk));
243
+ for (const a of q.archetypes()) {
244
+ const plant = a.column(Plant);
245
+ const target = a.column(og.IkTarget2D).target;
246
+ for (let i = 0; i < a.count; i++) {
247
+ let ahead = plant.dx[i]!;
248
+ let lift = 0;
249
+ if (w.walking) {
250
+ const phase = w.walked / WALK.stride + plant.lag[i]!;
251
+ const p = phase - Math.floor(phase);
252
+ if (p < 0.5) {
253
+ // Planted: it slides back under the keeper as fast as the keeper walks.
254
+ ahead += WALK.stride * (0.25 - p);
255
+ } else {
256
+ const s = (p - 0.5) * 2;
257
+ ahead += WALK.stride * (s - 0.5) * 0.5;
258
+ lift = WALK.lift * 4 * s * (1 - s);
259
+ }
260
+ }
261
+ const x = at.x + w.facing * ahead;
262
+ target[i * 2] = x;
263
+ target[i * 2 + 1] = rampY(x) + ANKLE + lift;
264
+ }
265
+ }
266
+ const lantern = world.find("lantern");
267
+ if (lantern !== null) must(world.set(lantern, og.Transform, { position: { x: at.x - w.facing * LANTERN.x, y: at.y + LANTERN.y, z: 0 } }));
268
+ const g = must(world.resource(Game));
269
+ for (const e of world.events(og.Skeleton2DEvent)) {
270
+ if (e.name === "inhale") g.inhales += 1;
271
+ else if (e.name === "step") g.steps += 1;
272
+ }
273
+ });
274
+
275
+ /** Plays each sound asked for once: a voice still playing is stopped this tick and played again the next. */
276
+ export const Voices = defineSystem("Voices", { reads: [Voice], writes: [Game, og.AudioSource], after: ["Pickups"] }, (q, _time, world) => {
277
+ const g = must(world.resource(Game));
278
+ if (g.sfx === 0) return;
279
+ let left = g.sfx;
280
+ for (const a of q.archetypes()) {
281
+ const voice = a.column(Voice);
282
+ const source = a.column(og.AudioSource);
283
+ for (let i = 0; i < a.count; i++) {
284
+ const bit = voice.bit[i]!;
285
+ if ((g.sfx & bit) === 0) continue;
286
+ if (source.playing[i]) source.playing[i] = 0;
287
+ else {
288
+ source.playing[i] = 1;
289
+ left &= ~bit;
290
+ }
291
+ }
292
+ }
293
+ g.sfx = left;
294
+ });
295
+
296
+ /**
297
+ * The UI kit's platformer HUD (og-ui §6): lives, coins, the level's name,
298
+ * the pause button and, on a phone, the touch controls (taken by Control
299
+ * this tick: Hud runs first). Pause (the button, Escape or the pad's Start)
300
+ * opens the kit's pause menu and holds time until Resume.
301
+ */
302
+ export const Hud = defineSystem("Hud", { writes: [Game] }, (_q, _time, world) => {
303
+ const g = must(world.resource(Game));
304
+ const hud = og.ui.platformerHud("Hud", {
305
+ lives: g.lives,
306
+ maxLives: LIVES,
307
+ coins: g.coins,
308
+ score: g.coins * 100,
309
+ level: og.i18n.t(world, g.won ? "hud.clear" : "hud.level"),
310
+ timeLeft: null,
311
+ // A d-pad and Jump where the pointer is a finger (og-ui §5).
312
+ touch: og.ui.frameViewport().coarsePointer,
313
+ });
314
+ g.touchX = g.paused ? 0 : hud.moveX;
315
+ g.touchJump = !g.paused && hud.held.includes("jump");
316
+ const menu = og.ui.pauseMenu("Pause", { open: g.paused, items: [{ id: "resume", label: og.i18n.t(world, "pause.resume") }] });
317
+ if (g.paused && menu.selected === "resume") {
318
+ g.paused = false;
319
+ must(og.time.setScale(world, 1));
320
+ } else if (!g.paused && hud.paused) {
321
+ g.paused = true;
322
+ must(og.time.setScale(world, 0));
323
+ }
324
+ });
325
+
326
+ /**
327
+ * The lamp keeper: a plank ramp, the rig on it facing the spawn, a foot
328
+ * target per leg, and its lantern's light; og.Lighting2D lights layers 1
329
+ * and 2 (the keeper, the courier, the coins), the city stays as drawn.
330
+ */
331
+ function spawnKeeper(world: World, rig: Rig2DInfo): void {
332
+ const idle = og.anim.clip(rig, "idle");
333
+ const walk = og.anim.clip(rig, "walk");
334
+ if (idle < 0 || walk < 0) throw new Error("rigs/keeper.ogt needs clips idle and walk");
335
+ const mid = (RAMP.x0 + RAMP.x1) / 2;
336
+ const plank = must(world.spawn({ name: "ramp", position: { x: mid, y: rampY(mid) - 0.2, z: 0 }, quaternion: { x: 0, y: 0, z: RAMP.sinHalf, w: RAMP.cosHalf } }));
337
+ // The plank is a region of the keeper's texture (pixels 24..64 x 32..40 of 64 x 64).
338
+ must(world.insert(plank, og.Sprite, { texture: rig.texture, width: RAMP.x1 - RAMP.x0, height: 0.4, uvMin: { x: 0.375, y: 0.5 }, uvMax: { x: 1, y: 0.625 }, layer: 1 }));
339
+ const x = 6;
340
+ const keeper = must(world.spawn({ name: "keeper", position: { x, y: rampY(x) + LIFT, z: 0 } }));
341
+ must(world.insert(keeper, og.Skeleton2D, { rig: rig.rig, clip: idle, flipX: true, layer: 1 }));
342
+ const w = must(world.resource(KeeperWalk));
343
+ Object.assign(w, { goal: x, walked: 0, walking: false, facing: -1, idle, walk });
344
+ // Facing left, the front foot is the left one; the back foot steps half a cycle later.
345
+ for (const [name, ahead, lag] of [["leg-f", 0.15, 0], ["leg-b", -0.15, 0.5]] as const) {
346
+ const constraint = rig.constraints.indexOf(name);
347
+ if (constraint < 0) throw new Error(`rigs/keeper.ogt has no ik ${name}`);
348
+ const fx = x - ahead;
349
+ const foot = must(world.spawn({ name: `keeper-${name}` }));
350
+ must(world.insert(foot, og.IkTarget2D, { skeleton: keeper, constraint, target: { x: fx, y: rampY(fx) + ANKLE }, tolerance: 0.02 }));
351
+ must(world.insert(foot, Plant, { dx: ahead, lag }));
352
+ }
353
+ const light = must(world.spawn({ name: "lantern", position: { x: x + LANTERN.x, y: rampY(x) + LIFT + LANTERN.y, z: 0 } }));
354
+ must(world.insert(light, og.Light2D, { color: { r: 1, g: 0.72, b: 0.35 }, intensity: 1.6, radius: 4, castShadows: false }));
355
+ const lighting = must(world.resource(og.Lighting2D));
356
+ lighting.enabled = true;
357
+ lighting.ambient = { r: 0.62, g: 0.64, b: 0.78 };
358
+ lighting.minLayer = 1;
359
+ lighting.maxLayer = 2;
360
+ }
361
+
362
+ function checkClips(info: FlipbookInfo): void {
363
+ for (const [name, index] of Object.entries(CLIP)) {
364
+ const at = og.anim.clip(info, name);
365
+ if (at !== index) throw new Error(`sprites/courier.ogt: clip ${name} is ${at}, the code expects ${index}`);
366
+ }
367
+ }
368
+
369
+ /** A map object's point in world units: the map's top-left corner is at (0, height). */
370
+ function at(o: TileObject, height: number): { x: number; y: number } {
371
+ return { x: o.position.x, y: height + o.position.y };
372
+ }
373
+
374
+ export default og.game({
375
+ // The art direction as data (assets/looks/origin-game-2d.ogt; og-taste §12).
376
+ look: "looks/origin-game-2d.ogt",
377
+ tick: 60,
378
+ seed: 1,
379
+ resources: [Game, KeeperWalk],
380
+ systems: [Hud, Control, Pickups, Animate, Walk, Plant2D, Voices],
381
+ start(world) {
382
+ const map = must(og.load.tilemap(world, "maps/level.ogt"));
383
+ const courier = must(og.load.flipbook(world, "sprites/courier.ogt"));
384
+ checkClips(courier);
385
+ const coin = must(og.load.flipbook(world, "sprites/coin.ogt"));
386
+ spawnKeeper(world, must(og.load.rig2d(world, "rigs/keeper.ogt")));
387
+ const height = map.height * map.tileHeight;
388
+
389
+ const camera = must(world.resource(og.Camera));
390
+ camera.orthographic = true;
391
+ camera.height = VIEW_H;
392
+ camera.position = { x: 8, y: height / 2, z: 50 };
393
+ camera.background = { r: 0.0042, g: 0.0048, b: 0.018 };
394
+
395
+ // The map draws from its top-left corner and gives its collision layers static colliders.
396
+ const level = must(world.spawn({ name: "level", position: { x: 0, y: height, z: 0 } }));
397
+ must(world.insert(level, og.Tilemap, { map: map.map, layer: -10 }));
398
+
399
+ const g = must(world.resource(Game));
400
+ for (const o of map.objects) {
401
+ const p = at(o, height);
402
+ if (o.kind === "spawn") {
403
+ const hero = must(world.spawn({ name: "hero", position: { x: p.x, y: p.y, z: 0 } }));
404
+ must(world.insert(hero, Hero, { spawnX: p.x, spawnY: p.y }));
405
+ must(world.insert(hero, og.Sprite, { texture: courier.texture, width: 2, height: 2.5, anchor: { x: 0.5, y: 0 }, layer: 2 }));
406
+ must(world.insert(hero, og.Flipbook, { sheet: courier.sheet, clip: CLIP.idle }));
407
+ must(world.insert(hero, og.Collider, { shape: SHAPE_BOX, size: { x: BOX.w, y: BOX.h, z: 1 }, offset: { x: 0, y: BOX.h / 2, z: 0 } }));
408
+ // og-feel §12's platformer tunables; the ledges' layer is one-way.
409
+ must(world.insert(hero, og.PlatformerController, { maxSpeed: 8, jumpHeight: 3.6, timeToApex: 0.4, oneWayLayers: LEDGES, wallJumps: false }));
410
+ must(world.insert(hero, og.Follow2D, { offset: { x: 0, y: 1.5 }, deadZone: { x: 1, y: 0.8 }, smoothTime: 0.12, lookAhead: 0.2, lookAheadMax: { x: 2, y: 1 } }));
411
+ } else if (o.kind === "coin") {
412
+ const e = must(world.spawn({ name: o.name, position: { x: p.x, y: p.y, z: 0 } }));
413
+ must(world.insert(e, Coin));
414
+ must(world.insert(e, og.Sprite, { texture: coin.texture, width: 0.7, height: 0.7, layer: 1 }));
415
+ must(world.insert(e, og.Flipbook, { sheet: coin.sheet, clip: 0 }));
416
+ g.total += 1;
417
+ } else if (o.kind === "goal") {
418
+ g.goalX = p.x;
419
+ }
420
+ }
421
+
422
+ for (const [path, bit] of [["audio/jump.wav", Sound.Jump], ["audio/coin.wav", Sound.Coin]] as const) {
423
+ const sound = og.load.sound(world, path);
424
+ if (!sound.ok) continue;
425
+ const v = must(world.spawn({ name: `voice-${bit}` }));
426
+ must(world.insert(v, Voice, { bit }));
427
+ must(world.insert(v, og.AudioSource, { sound: sound.value, playing: false, volume: 0.5 }));
428
+ }
429
+
430
+ // Every string the HUD shows (og-i18n §1), and its font.
431
+ must(og.i18n.load(world, "strings/en.ogt"));
432
+ const font = og.load.font(world, "fonts/ui.ttf");
433
+ if (font.ok) og.ui.configure(world, { font: font.value });
434
+ },
435
+ commands: {
436
+ /** `warp:<x>:<y>`: put the courier's feet there. */
437
+ warp: (world, args) => {
438
+ const hero = world.find("hero");
439
+ const x = Number(args[0]);
440
+ const y = Number(args[1]);
441
+ if (hero === null || !Number.isFinite(x) || !Number.isFinite(y)) throw new Error("write warp:<x>:<y>");
442
+ must(world.set(hero, og.Transform, { position: { x, y, z: 0 } }));
443
+ },
444
+ /** `keeper:<x>`: send the keeper walking along the ramp to x (the Walk system moves it; its feet step on the plank). */
445
+ keeper: (world, args) => {
446
+ const x = Math.max(RAMP.x0 + 0.4, Math.min(RAMP.x1 - 0.4, Number(args[0])));
447
+ if (world.find("keeper") === null || !Number.isFinite(x)) throw new Error("write keeper:<x>");
448
+ must(world.resource(KeeperWalk)).goal = x;
449
+ },
450
+ },
451
+ commandHelp: {
452
+ warp: { usage: "<x>:<y>", doc: "Put the courier's feet at a world point." },
453
+ keeper: { usage: "<x>", doc: "Send the lamp keeper walking along its ramp to x." },
454
+ },
455
+ facts: (world) => {
456
+ const g = world.resource(Game);
457
+ if (!g.ok) return {};
458
+ return { coins: g.value.coins, total: g.value.total, lives: g.value.lives, won: g.value.won, inhales: g.value.inhales, steps: g.value.steps, x: g.value.heroX, y: g.value.heroY };
459
+ },
460
+ });
@@ -0,0 +1,93 @@
1
+ // The lamp keeper's playtest (og-game §8, 2D skeletons): the rig plays its
2
+ // idle clip (its event counted as breaths) with both legs' IK targets on
3
+ // the ramp and reached; `keeper:7` sends it walking up the ramp with its
4
+ // walk clip, facing the way it goes, one foot planted on the plank while
5
+ // the other swings above it; it arrives, faces the spawn and breathes again
6
+ // with both feet planted, higher.
7
+ import { playtest } from "@origingame/engine/playtest";
8
+
9
+ interface Got<T> {
10
+ value: T;
11
+ }
12
+
13
+ type Fields = Record<string, number | boolean | string | number[]>;
14
+
15
+ interface EntityDump {
16
+ components: Record<string, Fields>;
17
+ }
18
+
19
+ /** The ramp's top at x (src/main.ts's RAMP), plus the ankle's height. */
20
+ const ankleY = (x: number): number => 2 + 0.21256 * (x - 4.5) + 0.12;
21
+ /** The plank's ends and the highest a swinging foot lifts (src/main.ts's RAMP and WALK). */
22
+ const PLANK = { x0: 4.5, x1: 7.6 } as const;
23
+ const LIFT = 0.08;
24
+ /** The rig's clips, in file order. */
25
+ const IDLE = 0;
26
+ const WALK = 1;
27
+
28
+ export default playtest({
29
+ name: "The lamp keeper breathes on its ramp with both feet planted, walks up the slope stepping on the plank, and stands planted again",
30
+ commands:
31
+ "1.0:screenshot:keeper.png;1.01:dump_entity:keeper:keeper.json;1.02:dump_entity:keeper-leg-f:front.json;1.03:dump_entity:keeper-leg-b:back.json;" +
32
+ "2.1:get:Game:game.json;2.2:keeper:7;" +
33
+ "2.6:screenshot:walk.png;2.61:dump_entity:keeper:walk.json;2.62:dump_entity:keeper-leg-f:walk-front.json;2.63:dump_entity:keeper-leg-b:walk-back.json;" +
34
+ "2.9:dump_entity:keeper:walk-later.json;2.91:dump_entity:keeper-leg-f:walk-front-later.json;2.92:dump_entity:keeper-leg-b:walk-back-later.json;" +
35
+ "4.6:dump_entity:keeper:keeper-up.json;4.61:dump_entity:keeper-leg-f:front-up.json;4.62:dump_entity:keeper-leg-b:back-up.json;4.7:get:Game:game-end.json;4.8:quit",
36
+ check(run, t) {
37
+ t.ok(run.quit, "the script reached quit");
38
+ for (const png of ["keeper.png", "walk.png"]) {
39
+ t.frame(png, () => {
40
+ const frame = run.png(png);
41
+ t.ok(frame.width > 0 && frame.height > 0, `a ${frame.width}x${frame.height} frame was captured`);
42
+ });
43
+ }
44
+ const skeleton = (file: string) => run.json<EntityDump>(file).components["og.Skeleton2D"]!;
45
+ const at = (file: string) => (run.json<EntityDump>(file).components["og.Transform"]!["position"] as number[])[0]!;
46
+ const foot = (file: string) => run.json<EntityDump>(file).components["og.IkTarget2D"]!;
47
+
48
+ // Standing: the idle clip, both feet on the ramp under the keeper.
49
+ const idle = skeleton("keeper.json");
50
+ t.ok(idle["clip"] === IDLE && (idle["time"] as number) > 0.9 && (idle["time"] as number) < 1.1, `the idle clip plays (clip ${idle["clip"]}, time ${idle["time"]})`);
51
+ for (const [file, label, dx, x0] of [["front.json", "front", -0.15, 6], ["back.json", "back", 0.15, 6], ["front-up.json", "front after the walk", -0.15, 7], ["back-up.json", "back after the walk", 0.15, 7]] as const) {
52
+ const ik = foot(file);
53
+ const target = ik["target"] as number[];
54
+ const x = x0 + dx;
55
+ t.ok(Math.abs(target[0]! - x) < 1e-4 && Math.abs(target[1]! - ankleY(x)) < 1e-4, `the ${label} foot's target is on the ramp (${target.join(", ")})`);
56
+ t.ok(ik["reached"] === true, `the ${label} foot reached it (error ${ik["error"]})`);
57
+ }
58
+
59
+ // Walking: the walk clip, facing up the ramp, the keeper between where it was and where it goes.
60
+ let planted = 0;
61
+ for (const [keeper, front, back] of [["walk.json", "walk-front.json", "walk-back.json"], ["walk-later.json", "walk-front-later.json", "walk-back-later.json"]] as const) {
62
+ const s = skeleton(keeper);
63
+ const x = at(keeper);
64
+ t.ok(s["clip"] === WALK && s["flipX"] === false, `walking, the keeper plays its walk clip facing right (clip ${s["clip"]}, flipX ${s["flipX"]})`);
65
+ t.ok(x > 6 && x < 7, `walking, it is part of the way along the ramp (x ${x})`);
66
+ for (const [file, label] of [[front, "front"], [back, "back"]] as const) {
67
+ const ik = foot(file);
68
+ const [fx, fy] = ik["target"] as [number, number];
69
+ const lift = fy - ankleY(fx);
70
+ t.ok(fx > PLANK.x0 && fx < PLANK.x1 && lift > -1e-4 && lift <= LIFT + 1e-4, `walking, the ${label} foot is on the plank or above it (x ${fx}, ${lift.toFixed(4)} up)`);
71
+ t.ok(ik["reached"] === true, `walking, the ${label} foot reached its target (error ${ik["error"]})`);
72
+ if (Math.abs(lift) < 1e-4) planted += 1;
73
+ }
74
+ }
75
+ t.ok(planted >= 2, `while it walks a foot stays planted on the plank (${planted} of 4 targets on it)`);
76
+ // The back foot carried the keeper from 2.6 s to 2.9 s: it did not move.
77
+ const before = foot("walk-back.json")["target"] as number[];
78
+ const later = foot("walk-back-later.json")["target"] as number[];
79
+ t.ok(Math.abs(before[0]! - later[0]!) < 1e-4 && Math.abs(before[1]! - later[1]!) < 1e-4, `the planted foot stays put while the keeper passes over it (${before.join(", ")} then ${later.join(", ")})`);
80
+
81
+ // Arrived: idle again, facing the spawn, at x 7.
82
+ const up = skeleton("keeper-up.json");
83
+ t.ok(up["clip"] === IDLE && up["flipX"] === true && Math.abs(at("keeper-up.json") - 7) < 1e-4, `it arrived at 7 and breathes facing the spawn (clip ${up["clip"]}, x ${at("keeper-up.json")})`);
84
+
85
+ // A loop of 2 s from tick 0 to 2.1 s passed its event at 0 and at 2; the walk
86
+ // (2 s at 0.5 m/s, a step every 0.4 s) stepped 5 times, and the idle clip
87
+ // started over on arrival with a breath.
88
+ const game = run.json<Got<{ inhales: number }>>("game.json").value;
89
+ t.ok(game.inhales === 2, `og.Skeleton2DEvent counted two breaths before the walk (${game.inhales})`);
90
+ const end = run.json<Got<{ inhales: number; steps: number }>>("game-end.json").value;
91
+ t.ok(end.steps === 5 && end.inhales === 3, `the walk clip's events counted 5 steps, then a third breath (${end.steps}, ${end.inhales})`);
92
+ },
93
+ });
@@ -0,0 +1,60 @@
1
+ // The starter's playtest, run by `og test` through `og run --headless
2
+ // --commands`: the courier runs right over two coins, jumps up through the
3
+ // one-way ledge above it and lands on top, Escape opens the pause menu and
4
+ // holds time, Escape again resumes, and a fall into the pit costs a life
5
+ // and puts the courier back at the spawn.
6
+ import { playtest } from "@origingame/engine/playtest";
7
+
8
+ interface Got<T> {
9
+ value: T;
10
+ }
11
+
12
+ interface Game {
13
+ coins: number;
14
+ lives: number;
15
+ paused: boolean;
16
+ heroX: number;
17
+ heroY: number;
18
+ }
19
+
20
+ interface EntityDump {
21
+ components: Record<string, Record<string, number | boolean | string | number[]>>;
22
+ }
23
+
24
+ interface UiDump {
25
+ elements: Array<{ id: string; text: string; width: number; height: number }>;
26
+ }
27
+
28
+ export default playtest({
29
+ name: "The courier runs over two coins, jumps up through a one-way ledge onto it, pauses and resumes, and the pit costs a life",
30
+ commands:
31
+ "0.5:key_down:ArrowRight;2.0:key_up:ArrowRight;" +
32
+ "2.3:key_down:Space;2.9:key_up:Space;" +
33
+ "3.5:screenshot:ledge.png;3.55:dump_entity:hero:hero.json;3.6:get:Game:ledge.json;3.65:dump_ui:ui.json;" +
34
+ "3.7:key_down:Escape;3.75:key_up:Escape;4.2:get:Game:paused.json;" +
35
+ "4.3:key_down:Escape;4.35:key_up:Escape;4.5:get:Game:resumed.json;" +
36
+ "4.6:warp:21:-1;5.0:get:Game:fell.json;5.1:quit",
37
+ check(run, t) {
38
+ t.ok(run.quit, "the script reached quit");
39
+ t.frame("ledge.png", () => {
40
+ const frame = run.png("ledge.png");
41
+ t.ok(frame.width > 0 && frame.height > 0, `a ${frame.width}x${frame.height} frame was captured`);
42
+ });
43
+ const ledge = run.json<Got<Game>>("ledge.json").value;
44
+ t.ok(ledge.coins === 2, `the run took the two coins on the roof (${ledge.coins})`);
45
+ // The ledge (cells 11-15 of row 9) has its top at y 5: the courier passed up through it and stands on it.
46
+ t.ok(ledge.heroX > 11 && ledge.heroX < 16, `the courier is under the ledge's span (x ${ledge.heroX.toFixed(2)})`);
47
+ t.ok(Math.abs(ledge.heroY - 5) < 0.05, `and stands on the one-way ledge (y ${ledge.heroY.toFixed(3)})`);
48
+ const hero = run.json<EntityDump>("hero.json");
49
+ t.ok(hero.components["og.PlatformerController"]!["grounded"] === true, "the controller is grounded on the ledge");
50
+ const ui = run.json<UiDump>("ui.json");
51
+ const count = ui.elements.find((e) => e.id === "Count");
52
+ t.ok(count !== undefined && count.text === "2", `the platformer HUD counts the coins ("${count?.text}")`);
53
+ const paused = run.json<Got<Game>>("paused.json").value;
54
+ t.ok(paused.paused, "Escape opened the pause menu");
55
+ t.ok(!run.json<Got<Game>>("resumed.json").value.paused, "Escape again resumed");
56
+ const fell = run.json<Got<Game>>("fell.json").value;
57
+ t.ok(fell.lives === 2, `the pit cost a life (${fell.lives} left)`);
58
+ t.ok(Math.abs(fell.heroX - 2.5) < 0.5 && fell.heroY < 2.5, `the courier is back at the spawn (${fell.heroX.toFixed(2)}, ${fell.heroY.toFixed(2)})`);
59
+ },
60
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "types": [],
8
+ "strict": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "isolatedModules": true,
12
+ "verbatimModuleSyntax": true,
13
+ "allowImportingTsExtensions": true,
14
+ "noEmit": true
15
+ },
16
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
17
+ }