pi-extensions 0.1.9

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 (135) hide show
  1. package/.ralph/import-cc-codex.md +31 -0
  2. package/.ralph/import-cc-codex.state.json +14 -0
  3. package/.ralph/mario-not-impl.md +69 -0
  4. package/.ralph/mario-not-impl.state.json +14 -0
  5. package/.ralph/mario-not-spec.md +163 -0
  6. package/.ralph/mario-not-spec.state.json +14 -0
  7. package/LICENSE +21 -0
  8. package/README.md +65 -0
  9. package/RELEASING.md +34 -0
  10. package/agent-guidance/CHANGELOG.md +4 -0
  11. package/agent-guidance/README.md +102 -0
  12. package/agent-guidance/agent-guidance.ts +147 -0
  13. package/agent-guidance/package.json +22 -0
  14. package/agent-guidance/setup.sh +75 -0
  15. package/agent-guidance/templates/CLAUDE.md +5 -0
  16. package/agent-guidance/templates/CODEX.md +92 -0
  17. package/agent-guidance/templates/GEMINI.md +5 -0
  18. package/arcade/CHANGELOG.md +4 -0
  19. package/arcade/README.md +85 -0
  20. package/arcade/assets/picman.png +0 -0
  21. package/arcade/assets/ping.png +0 -0
  22. package/arcade/assets/spice-invaders.png +0 -0
  23. package/arcade/assets/tetris.png +0 -0
  24. package/arcade/mario-not/README.md +30 -0
  25. package/arcade/mario-not/boss.js +103 -0
  26. package/arcade/mario-not/camera.js +59 -0
  27. package/arcade/mario-not/collision.js +91 -0
  28. package/arcade/mario-not/colors.js +36 -0
  29. package/arcade/mario-not/constants.js +97 -0
  30. package/arcade/mario-not/core.js +39 -0
  31. package/arcade/mario-not/death.js +77 -0
  32. package/arcade/mario-not/effects.js +84 -0
  33. package/arcade/mario-not/enemies.js +31 -0
  34. package/arcade/mario-not/engine.js +171 -0
  35. package/arcade/mario-not/fireballs.js +98 -0
  36. package/arcade/mario-not/items.js +24 -0
  37. package/arcade/mario-not/levels.js +403 -0
  38. package/arcade/mario-not/logic.js +104 -0
  39. package/arcade/mario-not/mario-not.ts +297 -0
  40. package/arcade/mario-not/player.js +244 -0
  41. package/arcade/mario-not/render.js +257 -0
  42. package/arcade/mario-not/spec.md +548 -0
  43. package/arcade/mario-not/state.js +246 -0
  44. package/arcade/mario-not/tests/e2e.test.js +855 -0
  45. package/arcade/mario-not/tests/engine.test.js +888 -0
  46. package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
  47. package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
  48. package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
  49. package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
  50. package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
  51. package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
  52. package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
  53. package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
  54. package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
  55. package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
  56. package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
  57. package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
  58. package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
  59. package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
  60. package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
  61. package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
  62. package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
  63. package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
  64. package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
  65. package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
  66. package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
  67. package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
  68. package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
  69. package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
  70. package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
  71. package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
  72. package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
  73. package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
  74. package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
  75. package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
  76. package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
  77. package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
  78. package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
  79. package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
  80. package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
  81. package/arcade/mario-not/tiles.js +79 -0
  82. package/arcade/mario-not/tsconfig.json +14 -0
  83. package/arcade/mario-not/types.js +225 -0
  84. package/arcade/package.json +26 -0
  85. package/arcade/picman.ts +328 -0
  86. package/arcade/ping.ts +594 -0
  87. package/arcade/spice-invaders.ts +1104 -0
  88. package/arcade/tetris.ts +662 -0
  89. package/code-actions/CHANGELOG.md +4 -0
  90. package/code-actions/README.md +65 -0
  91. package/code-actions/actions.ts +107 -0
  92. package/code-actions/index.ts +148 -0
  93. package/code-actions/package.json +22 -0
  94. package/code-actions/search.ts +79 -0
  95. package/code-actions/snippets.ts +179 -0
  96. package/code-actions/ui.ts +120 -0
  97. package/files-widget/CHANGELOG.md +90 -0
  98. package/files-widget/DESIGN.md +452 -0
  99. package/files-widget/README.md +122 -0
  100. package/files-widget/TODO.md +141 -0
  101. package/files-widget/browser.ts +922 -0
  102. package/files-widget/comment.ts +5 -0
  103. package/files-widget/constants.ts +18 -0
  104. package/files-widget/demo.svg +1 -0
  105. package/files-widget/file-tree.ts +224 -0
  106. package/files-widget/file-viewer.ts +93 -0
  107. package/files-widget/git.ts +107 -0
  108. package/files-widget/index.ts +140 -0
  109. package/files-widget/input-utils.ts +3 -0
  110. package/files-widget/package.json +22 -0
  111. package/files-widget/types.ts +28 -0
  112. package/files-widget/utils.ts +26 -0
  113. package/files-widget/viewer.ts +424 -0
  114. package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
  115. package/import-cc-codex/spec.md +79 -0
  116. package/package.json +29 -0
  117. package/ralph-wiggum/CHANGELOG.md +7 -0
  118. package/ralph-wiggum/README.md +96 -0
  119. package/ralph-wiggum/SKILL.md +73 -0
  120. package/ralph-wiggum/index.ts +792 -0
  121. package/ralph-wiggum/package.json +25 -0
  122. package/raw-paste/CHANGELOG.md +7 -0
  123. package/raw-paste/README.md +52 -0
  124. package/raw-paste/index.ts +112 -0
  125. package/raw-paste/package.json +22 -0
  126. package/tab-status/CHANGELOG.md +4 -0
  127. package/tab-status/README.md +61 -0
  128. package/tab-status/assets/tab-status.png +0 -0
  129. package/tab-status/package.json +22 -0
  130. package/tab-status/tab-status.ts +179 -0
  131. package/usage-extension/CHANGELOG.md +17 -0
  132. package/usage-extension/README.md +120 -0
  133. package/usage-extension/index.ts +628 -0
  134. package/usage-extension/package.json +22 -0
  135. package/usage-extension/screenshot.png +0 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Mario-Not - a Mario-style TUI platformer. Play with /mario-not
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
6
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+
8
+ const {
9
+ createGame,
10
+ stepGame,
11
+ renderViewport,
12
+ renderHud,
13
+ saveState,
14
+ loadState,
15
+ setPaused,
16
+ makeLevel,
17
+ } = require("./engine.js") as typeof import("./engine.js");
18
+ const { ALL_LEVELS } = require("./levels.js") as typeof import("./levels.js");
19
+ const { COLORS } = require("./colors.js") as { COLORS: Record<string, string> };
20
+ const { DEFAULT_CONFIG, LEVEL_INTRO_TIME } = require("./constants.js") as typeof import("./constants.js");
21
+
22
+ const TICK_MS = 25;
23
+ const VIEWPORT_W = 40;
24
+ const VIEWPORT_H = 15;
25
+ const HUD_LINES = 2;
26
+ const SAVE_TYPE = "mario-not-save";
27
+ const FRAME_INNER_WIDTH = 36;
28
+ const START_X = 1;
29
+ const START_Y = 13;
30
+
31
+ // Level metadata: name and theme color
32
+ const LEVEL_META: { name: string; frameColor: string; textColor: string }[] = [
33
+ { name: "WORLD 1-1", frameColor: COLORS.yellow, textColor: COLORS.cyan },
34
+ { name: "WORLD 1-2", frameColor: COLORS.yellow, textColor: COLORS.cyan },
35
+ { name: "WORLD 1-3", frameColor: COLORS.yellow, textColor: COLORS.cyan },
36
+ { name: "BOWSER'S CASTLE", frameColor: COLORS.red, textColor: COLORS.red },
37
+ ];
38
+
39
+ class MarioNotComponent {
40
+ private readonly tui: any;
41
+ private readonly onClose: () => void;
42
+ private readonly onSave: (state: any) => void;
43
+ private interval: NodeJS.Timeout | null = null;
44
+ private state: any;
45
+ private version = 0;
46
+ private cache = { lines: [] as string[], width: 0, version: -1 };
47
+ private moveDir = 0;
48
+ private runHeld = true;
49
+ private jumpQueued = false;
50
+ private autosaveTimer = 0;
51
+ private levelClearTimer = 0;
52
+ private introTimer = 0;
53
+ private shownHazardAdvice = false;
54
+ private readonly config: any;
55
+
56
+ constructor(tui: any, onClose: () => void, onSave: (state: any) => void, saved?: any) {
57
+ this.tui = tui;
58
+ this.onClose = onClose;
59
+ this.onSave = onSave;
60
+
61
+ // Override default config with game-specific values
62
+ this.config = {
63
+ ...DEFAULT_CONFIG,
64
+ dt: TICK_MS / 1000,
65
+ viewportWidth: VIEWPORT_W,
66
+ walkSpeed: 5.2,
67
+ runSpeed: 9.0,
68
+ groundAccel: 70,
69
+ groundDecel: 56,
70
+ airAccel: 48,
71
+ gravity: 35,
72
+ maxFall: 15,
73
+ jumpVel: 15,
74
+ };
75
+ const restored = saved ? loadState(saved, { config: this.config }) : null;
76
+ if (restored) {
77
+ this.state = restored;
78
+ } else {
79
+ this.state = createGame({ level: makeLevel(ALL_LEVELS[0]), startX: START_X, startY: START_Y, config: this.config, levelIndex: 1 });
80
+ this.state.mode = "level_intro";
81
+ this.introTimer = 0;
82
+ }
83
+
84
+ this.interval = setInterval(() => this.tick(), TICK_MS);
85
+ }
86
+
87
+ private tick(): void {
88
+ // Handle level intro
89
+ if (this.state.mode === "level_intro") {
90
+ this.introTimer += TICK_MS / 1000;
91
+ if (this.introTimer >= LEVEL_INTRO_TIME) {
92
+ this.state.mode = "playing";
93
+ this.introTimer = 0;
94
+ }
95
+ this.version += 1;
96
+ this.tui.requestRender();
97
+ return;
98
+ }
99
+
100
+ const input: any = {};
101
+ if (this.moveDir < 0) input.left = true;
102
+ if (this.moveDir > 0) input.right = true;
103
+ if (this.runHeld) input.run = true;
104
+ if (this.jumpQueued) input.jump = true;
105
+ this.jumpQueued = false;
106
+
107
+ const wasPlaying = this.state.mode === "playing";
108
+ const prevSize = this.state.player.size;
109
+ stepGame(this.state, input);
110
+
111
+ // Show hazard advice on first death/damage in level 4 (castle with lava/fireballs)
112
+ if (!this.shownHazardAdvice && this.state.levelIndex === 4 && wasPlaying) {
113
+ const tookDamage = this.state.mode === "dead" || (prevSize === "big" && this.state.player.size === "small");
114
+ if (tookDamage) {
115
+ this.shownHazardAdvice = true;
116
+ this.state.cue = { text: "TIP: [X] Walk [S/Down/J] Stop", ttl: 3.0, persist: false };
117
+ }
118
+ }
119
+
120
+ if (this.state.mode === "playing") {
121
+ this.autosaveTimer += TICK_MS / 1000;
122
+ if (this.autosaveTimer >= 5) {
123
+ this.onSave(saveState(this.state));
124
+ this.autosaveTimer = 0;
125
+ }
126
+ }
127
+
128
+ // Handle level transition
129
+ if (this.state.mode === "level_clear") {
130
+ this.levelClearTimer += TICK_MS / 1000;
131
+ if (this.levelClearTimer >= 2) {
132
+ const nextLevel = this.state.levelIndex + 1;
133
+ if (nextLevel <= ALL_LEVELS.length) {
134
+ this.goToLevel(nextLevel, true);
135
+ } else {
136
+ this.state.mode = "victory";
137
+ }
138
+ }
139
+ }
140
+
141
+ this.version += 1;
142
+ this.tui.requestRender();
143
+ }
144
+
145
+ private goToLevel(levelNum: number, keepProgress = false): void {
146
+ if (levelNum < 1 || levelNum > ALL_LEVELS.length) return;
147
+ const prev = this.state;
148
+ this.state = createGame({
149
+ level: makeLevel(ALL_LEVELS[levelNum - 1]),
150
+ startX: START_X,
151
+ startY: START_Y,
152
+ config: this.config,
153
+ levelIndex: levelNum,
154
+ });
155
+ if (keepProgress) {
156
+ this.state.score = prev.score;
157
+ this.state.coins = prev.coins;
158
+ this.state.lives = prev.lives;
159
+ this.state.player.size = prev.player.size;
160
+ }
161
+ this.state.mode = "level_intro";
162
+ this.introTimer = 0;
163
+ this.levelClearTimer = 0;
164
+ this.version += 1;
165
+ this.tui.requestRender();
166
+ }
167
+
168
+ private restart(): void {
169
+ this.goToLevel(this.state.levelIndex);
170
+ }
171
+
172
+ handleInput(key: string): boolean {
173
+ if (matchesKey(key, "escape") || key === "q" || key === "Q") {
174
+ this.onSave(saveState(this.state));
175
+ this.onClose();
176
+ } else if (key >= "1" && key <= "9") {
177
+ this.goToLevel(parseInt(key, 10));
178
+ } else if (key === "r" || key === "R") {
179
+ this.restart();
180
+ } else if (key === "p" || key === "P") {
181
+ const paused = this.state.mode === "paused";
182
+ setPaused(this.state, !paused);
183
+ if (this.state.mode === "paused") this.onSave(saveState(this.state));
184
+ this.version += 1;
185
+ this.tui.requestRender();
186
+ } else if (key === "x" || key === "X") {
187
+ this.runHeld = !this.runHeld;
188
+ } else if (matchesKey(key, "up") || key === " " || key === "z" || key === "Z" || key === "k" || key === "K") {
189
+ this.jumpQueued = true;
190
+ } else if (matchesKey(key, "left") || key === "a" || key === "A" || key === "h" || key === "H") {
191
+ this.moveDir = -1;
192
+ } else if (matchesKey(key, "right") || key === "d" || key === "l" || key === "D" || key === "L") {
193
+ this.moveDir = 1;
194
+ } else if (matchesKey(key, "down") || key === "s" || key === "S" || key === "j" || key === "J") {
195
+ this.moveDir = 0;
196
+ }
197
+ return true;
198
+ }
199
+
200
+ render(width: number, height: number): string[] {
201
+ const pad = (line: string) => {
202
+ const truncated = truncateToWidth(line, width);
203
+ const padding = Math.max(0, width - visibleWidth(truncated));
204
+ return truncated + " ".repeat(padding);
205
+ };
206
+
207
+ const minWidth = VIEWPORT_W * 2;
208
+ const minHeight = VIEWPORT_H + HUD_LINES + 2;
209
+ if (width < minWidth || height < minHeight) {
210
+ return [
211
+ "",
212
+ pad("MARIO-NOT"),
213
+ "",
214
+ pad("Terminal too small"),
215
+ pad(`Need ${minWidth} cols, ${minHeight} rows`),
216
+ "",
217
+ pad("[Q] Quit"),
218
+ ];
219
+ }
220
+
221
+ if (this.cache.version === this.version && this.cache.width === width) return this.cache.lines;
222
+
223
+ const lines: string[] = [];
224
+
225
+ // Level intro screen with cool frame
226
+ if (this.state.mode === "level_intro") {
227
+ const levelNum = this.state.levelIndex;
228
+ const levelId = `1-${levelNum}`;
229
+ const meta = LEVEL_META[levelNum - 1] || LEVEL_META[0];
230
+ const { name: levelName, frameColor, textColor } = meta;
231
+ const emptyRow = " ".repeat(FRAME_INNER_WIDTH);
232
+
233
+ const livesDisplay = `${textColor}<> x ${this.state.lives}${COLORS.reset}`;
234
+ const livesLen = 6 + this.state.lives.toString().length;
235
+
236
+ // Center content in frame (accounting for color codes)
237
+ const centerText = (text: string, visLen: number) => {
238
+ const leftPad = Math.floor((FRAME_INNER_WIDTH - visLen) / 2);
239
+ const rightPad = FRAME_INNER_WIDTH - visLen - leftPad;
240
+ return " ".repeat(leftPad) + text + " ".repeat(rightPad);
241
+ };
242
+
243
+ // Build retro frame
244
+ lines.push("");
245
+ lines.push("");
246
+ lines.push(pad(`${frameColor}╔${"═".repeat(FRAME_INNER_WIDTH)}╗${COLORS.reset}`));
247
+ lines.push(pad(`${frameColor}║${COLORS.reset}${emptyRow}${frameColor}║${COLORS.reset}`));
248
+ lines.push(pad(`${frameColor}║${COLORS.reset}${centerText(`${textColor}${levelName}${COLORS.reset}`, levelName.length)}${frameColor}║${COLORS.reset}`));
249
+ lines.push(pad(`${frameColor}║${COLORS.reset}${emptyRow}${frameColor}║${COLORS.reset}`));
250
+ lines.push(pad(`${frameColor}║${COLORS.reset}${centerText(`${textColor}${levelId}${COLORS.reset}`, 3)}${frameColor}║${COLORS.reset}`));
251
+ lines.push(pad(`${frameColor}║${COLORS.reset}${emptyRow}${frameColor}║${COLORS.reset}`));
252
+ lines.push(pad(`${frameColor}║${COLORS.reset}${centerText(livesDisplay, livesLen)}${frameColor}║${COLORS.reset}`));
253
+ lines.push(pad(`${frameColor}║${COLORS.reset}${emptyRow}${frameColor}║${COLORS.reset}`));
254
+ lines.push(pad(`${frameColor}╚${"═".repeat(FRAME_INNER_WIDTH)}╝${COLORS.reset}`));
255
+ lines.push("");
256
+ lines.push(pad(`${COLORS.gray}[1-4] Select level${COLORS.reset}`));
257
+ lines.push("");
258
+
259
+ this.cache = { lines, width, version: this.version };
260
+ return lines;
261
+ }
262
+
263
+ lines.push(...renderHud(this.state, minWidth).split("\n").map(pad));
264
+ lines.push(...renderViewport(this.state, VIEWPORT_W, VIEWPORT_H).split("\n").map(pad));
265
+ lines.push("");
266
+ const toggleHint = this.runHeld ? "[X] Walk" : "[X] Run";
267
+ lines.push(pad(`[Arrows/HJKL] Move [Space/K] Jump ${toggleHint} [P] Pause [R] Restart [1-4] Level [Q] Quit`));
268
+ if (this.state.mode === "game_over") lines.push(pad("GAME OVER - [Q] Quit"));
269
+ if (this.state.mode === "victory") lines.push(pad("YOU WIN! Final Score: " + this.state.score + " - [Q] Quit"));
270
+
271
+ this.cache = { lines, width, version: this.version };
272
+ return lines;
273
+ }
274
+
275
+ dispose(): void {
276
+ if (this.interval) clearInterval(this.interval);
277
+ }
278
+ }
279
+
280
+ export default function (api: ExtensionAPI) {
281
+ api.registerCommand("mario-not", {
282
+ description: "Play Mario-Not (Mario-style platformer)!",
283
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
284
+ if (!ctx.hasUI) {
285
+ ctx.ui.notify("Mario-Not requires interactive mode", "error");
286
+ return;
287
+ }
288
+
289
+ const entries = ctx.sessionManager.getEntries();
290
+ const saved = entries.reverse().find((e) => e.type === "custom" && e.customType === SAVE_TYPE)?.data as any | undefined;
291
+
292
+ await ctx.ui.custom((tui, _theme, _kb, done) =>
293
+ new MarioNotComponent(tui, () => done(undefined), (state) => api.appendEntry(SAVE_TYPE, state), saved)
294
+ );
295
+ },
296
+ });
297
+ }
@@ -0,0 +1,244 @@
1
+ // @ts-check
2
+ "use strict";
3
+
4
+ const { getTile, setTile, isHazardAt } = require("./tiles.js");
5
+ const { spawnParticles, setCue } = require("./effects.js");
6
+ const { enterDeath } = require("./death.js");
7
+ const { moveHorizontal, resolveVertical, overlaps } = require("./collision.js");
8
+ const {
9
+ SCORE_VALUES,
10
+ PLAYER_W,
11
+ PLAYER_H_SMALL,
12
+ PLAYER_H_BIG,
13
+ ITEM_W,
14
+ ITEM_H,
15
+ ITEM_SPEED,
16
+ INVULN_TIME,
17
+ GAME_MODES,
18
+ } = require("./constants.js");
19
+
20
+ /** @typedef {import("./types").GameState} GameState */
21
+ /** @typedef {import("./types").InputState} InputState */
22
+ /** @typedef {import("./types").PlayerState} PlayerState */
23
+
24
+ /** @param {PlayerState} player @returns {number} */
25
+ function getPlayerHeight(player) {
26
+ return player.size === "big" ? PLAYER_H_BIG : PLAYER_H_SMALL;
27
+ }
28
+
29
+ /** @param {GameState} state @param {InputState} [input] @returns {{ prevY: number, height: number }} */
30
+ function stepPlayerMovement(state, input) {
31
+ const cfg = state.config;
32
+ const dt = cfg.dt;
33
+ const player = state.player;
34
+ const prevY = player.y;
35
+
36
+ if (player.invuln > 0) {
37
+ player.invuln = Math.max(0, player.invuln - dt);
38
+ }
39
+
40
+ const moveLeft = !!(input && input.left);
41
+ const moveRight = !!(input && input.right);
42
+ const jump = !!(input && input.jump);
43
+ const run = !!(input && input.run);
44
+ let move = 0;
45
+ if (moveLeft) move -= 1;
46
+ if (moveRight) move += 1;
47
+ if (move !== 0) player.facing = move;
48
+
49
+ const accel = player.onGround ? cfg.groundAccel : cfg.airAccel;
50
+ const maxSpeed = run ? cfg.runSpeed : cfg.walkSpeed;
51
+ if (move !== 0) {
52
+ const target = move * maxSpeed;
53
+ const delta = accel * dt;
54
+ if (player.vx < target) player.vx = Math.min(player.vx + delta, target);
55
+ else if (player.vx > target) player.vx = Math.max(player.vx - delta, target);
56
+ } else if (player.onGround) {
57
+ const delta = cfg.groundDecel * dt;
58
+ if (Math.abs(player.vx) <= delta) player.vx = 0;
59
+ else player.vx -= Math.sign(player.vx) * delta;
60
+ }
61
+
62
+ if (jump && player.onGround) {
63
+ player.vy = -cfg.jumpVel;
64
+ player.onGround = false;
65
+ }
66
+
67
+ player.vy = Math.min(player.vy + cfg.gravity * dt, cfg.maxFall);
68
+
69
+ const height = getPlayerHeight(player);
70
+ const blocked = moveHorizontal(state.level, player, dt, PLAYER_W, height);
71
+ if (blocked) player.vx = 0;
72
+
73
+ /** @param {number} tileX @param {number} tileY */
74
+ const onHeadBump = (tileX, tileY) => {
75
+ handleHeadBump(state, tileX, tileY);
76
+ };
77
+ resolveVertical(state.level, player, dt, onHeadBump, height, PLAYER_W, true);
78
+
79
+ return { prevY, height };
80
+ }
81
+
82
+ /** @param {GameState} state */
83
+ function collectCoin(state) {
84
+ const tileX = Math.floor(state.player.x);
85
+ const tileY = Math.floor(state.player.y);
86
+ if (getTile(state.level, tileX, tileY) === "o") {
87
+ setTile(state.level, tileX, tileY, " ");
88
+ awardCoin(state);
89
+ }
90
+ }
91
+
92
+ /** @param {GameState} state */
93
+ function collectItems(state) {
94
+ const player = state.player;
95
+ const height = getPlayerHeight(player);
96
+
97
+ state.items = state.items.filter((item) => {
98
+ if (!item.alive) return false;
99
+ if (!overlaps(player.x, player.y, PLAYER_W, height, item.x, item.y, ITEM_W, ITEM_H)) {
100
+ return true; // Keep item
101
+ }
102
+ // Collected - apply effect
103
+ if (player.size === "small") {
104
+ player.size = /** @type {"small" | "big"} */ ("big");
105
+ player.invuln = INVULN_TIME;
106
+ setCue(state, "POWER UP", 0.6, false);
107
+ } else {
108
+ awardScore(state, SCORE_VALUES.mushroom);
109
+ }
110
+ spawnParticles(state, player.x, player.y - 0.2, 4);
111
+ return false; // Remove item
112
+ });
113
+ }
114
+
115
+ /** @param {GameState} state @param {number} tileX @param {number} tileY */
116
+ function handleHeadBump(state, tileX, tileY) {
117
+ const tile = getTile(state.level, tileX, tileY);
118
+ if (tile === "?") {
119
+ setTile(state.level, tileX, tileY, "U");
120
+ spawnMushroom(state, tileX, tileY);
121
+ }
122
+ }
123
+
124
+ /** @param {GameState} state @param {number} prevY */
125
+ function resolveEnemyCollisions(state, prevY) {
126
+ const player = state.player;
127
+ const height = getPlayerHeight(player);
128
+ const prevBottom = prevY + height;
129
+ const currBottom = player.y + height;
130
+ const falling = currBottom > prevBottom + 0.0001;
131
+ for (const enemy of state.enemies) {
132
+ if (!enemy.alive) continue;
133
+ if (overlaps(player.x, player.y, PLAYER_W, height, enemy.x, enemy.y, 1, 1)) {
134
+ const stomp = falling && prevBottom <= enemy.y + 0.01 && currBottom >= enemy.y;
135
+ if (stomp) {
136
+ enemy.alive = false;
137
+ player.vy = -state.config.jumpVel * 0.6;
138
+ awardScore(state, SCORE_VALUES.stomp);
139
+ spawnParticles(state, enemy.x, enemy.y - 0.2, 4);
140
+ } else {
141
+ applyPlayerDamage(state);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ /** @param {GameState} state @param {boolean} [forceDeath] */
148
+ function applyPlayerDamage(state, forceDeath) {
149
+ const player = state.player;
150
+ if (state.mode !== GAME_MODES.playing) return;
151
+ if (player.invuln > 0) return;
152
+ if (!forceDeath && player.size === "big") {
153
+ player.size = /** @type {"small" | "big"} */ ("small");
154
+ player.invuln = INVULN_TIME;
155
+ return;
156
+ }
157
+ enterDeath(state);
158
+ }
159
+
160
+ /** @param {GameState} state @param {number} height */
161
+ function checkGoal(state, height) {
162
+ if (state.mode !== GAME_MODES.playing) return;
163
+ const player = state.player;
164
+ const leftX = player.x + 0.001;
165
+ const rightX = player.x + PLAYER_W - 0.001;
166
+ const topY = player.y - (height - 1);
167
+ /** @param {string} tile */
168
+ const isGoal = (tile) => tile === "G" || tile === "F";
169
+
170
+ // Find which position touched the goal
171
+ let touchX = -1;
172
+ let touchY = -1;
173
+ if (isGoal(getTile(state.level, leftX, player.y))) { touchX = Math.floor(leftX); touchY = Math.floor(player.y); }
174
+ else if (isGoal(getTile(state.level, rightX, player.y))) { touchX = Math.floor(rightX); touchY = Math.floor(player.y); }
175
+ else if (isGoal(getTile(state.level, leftX, topY))) { touchX = Math.floor(leftX); touchY = Math.floor(topY); }
176
+ else if (isGoal(getTile(state.level, rightX, topY))) { touchX = Math.floor(rightX); touchY = Math.floor(topY); }
177
+
178
+ if (touchX < 0) return;
179
+
180
+ // Find top and bottom of flagpole
181
+ let poleTop = touchY;
182
+ let poleBottom = touchY;
183
+ while (poleTop > 0 && isGoal(getTile(state.level, touchX, poleTop - 1))) poleTop--;
184
+ while (poleBottom < state.level.height - 1 && isGoal(getTile(state.level, touchX, poleBottom + 1))) poleBottom++;
185
+
186
+ // Calculate flagpole score: higher = more points (100-5000)
187
+ const poleHeight = poleBottom - poleTop + 1;
188
+ const touchHeight = poleBottom - touchY; // 0 at bottom, poleHeight-1 at top
189
+ const heightRatio = poleHeight > 1 ? touchHeight / (poleHeight - 1) : 1;
190
+ const flagScore = Math.floor(100 + heightRatio * 4900);
191
+
192
+ // Time bonus: 10 points per second remaining
193
+ const timeBonus = Math.floor(state.time) * 10;
194
+
195
+ const totalBonus = flagScore + timeBonus;
196
+ state.score += totalBonus;
197
+
198
+ player.vx = 0;
199
+ player.vy = 0;
200
+ setCue(state, `+${totalBonus}`, 0, true);
201
+ state.mode = GAME_MODES.levelClear;
202
+ }
203
+
204
+ /** @param {GameState} state @returns {boolean} */
205
+ function checkHazard(state) {
206
+ return isHazardAt(state.level, state.player.x, state.player.y);
207
+ }
208
+
209
+ /** @param {GameState} state @param {number} tileX @param {number} tileY */
210
+ function spawnMushroom(state, tileX, tileY) {
211
+ const spawnX = tileX;
212
+ const spawnY = tileY - 1;
213
+ state.items.push({
214
+ x: spawnX,
215
+ y: spawnY,
216
+ vx: ITEM_SPEED,
217
+ vy: 0,
218
+ alive: true,
219
+ onGround: false,
220
+ });
221
+ }
222
+
223
+ /** @param {GameState} state */
224
+ function awardCoin(state) {
225
+ state.coins += 1;
226
+ state.score += SCORE_VALUES.coin;
227
+ spawnParticles(state, state.player.x, state.player.y - 0.2, 4);
228
+ }
229
+
230
+ /** @param {GameState} state @param {number} value */
231
+ function awardScore(state, value) {
232
+ state.score += value;
233
+ }
234
+
235
+ module.exports = {
236
+ applyPlayerDamage,
237
+ checkGoal,
238
+ checkHazard,
239
+ collectCoin,
240
+ collectItems,
241
+ getPlayerHeight,
242
+ resolveEnemyCollisions,
243
+ stepPlayerMovement,
244
+ };