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.
- package/.ralph/import-cc-codex.md +31 -0
- package/.ralph/import-cc-codex.state.json +14 -0
- package/.ralph/mario-not-impl.md +69 -0
- package/.ralph/mario-not-impl.state.json +14 -0
- package/.ralph/mario-not-spec.md +163 -0
- package/.ralph/mario-not-spec.state.json +14 -0
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/RELEASING.md +34 -0
- package/agent-guidance/CHANGELOG.md +4 -0
- package/agent-guidance/README.md +102 -0
- package/agent-guidance/agent-guidance.ts +147 -0
- package/agent-guidance/package.json +22 -0
- package/agent-guidance/setup.sh +75 -0
- package/agent-guidance/templates/CLAUDE.md +5 -0
- package/agent-guidance/templates/CODEX.md +92 -0
- package/agent-guidance/templates/GEMINI.md +5 -0
- package/arcade/CHANGELOG.md +4 -0
- package/arcade/README.md +85 -0
- package/arcade/assets/picman.png +0 -0
- package/arcade/assets/ping.png +0 -0
- package/arcade/assets/spice-invaders.png +0 -0
- package/arcade/assets/tetris.png +0 -0
- package/arcade/mario-not/README.md +30 -0
- package/arcade/mario-not/boss.js +103 -0
- package/arcade/mario-not/camera.js +59 -0
- package/arcade/mario-not/collision.js +91 -0
- package/arcade/mario-not/colors.js +36 -0
- package/arcade/mario-not/constants.js +97 -0
- package/arcade/mario-not/core.js +39 -0
- package/arcade/mario-not/death.js +77 -0
- package/arcade/mario-not/effects.js +84 -0
- package/arcade/mario-not/enemies.js +31 -0
- package/arcade/mario-not/engine.js +171 -0
- package/arcade/mario-not/fireballs.js +98 -0
- package/arcade/mario-not/items.js +24 -0
- package/arcade/mario-not/levels.js +403 -0
- package/arcade/mario-not/logic.js +104 -0
- package/arcade/mario-not/mario-not.ts +297 -0
- package/arcade/mario-not/player.js +244 -0
- package/arcade/mario-not/render.js +257 -0
- package/arcade/mario-not/spec.md +548 -0
- package/arcade/mario-not/state.js +246 -0
- package/arcade/mario-not/tests/e2e.test.js +855 -0
- package/arcade/mario-not/tests/engine.test.js +888 -0
- package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
- package/arcade/mario-not/tiles.js +79 -0
- package/arcade/mario-not/tsconfig.json +14 -0
- package/arcade/mario-not/types.js +225 -0
- package/arcade/package.json +26 -0
- package/arcade/picman.ts +328 -0
- package/arcade/ping.ts +594 -0
- package/arcade/spice-invaders.ts +1104 -0
- package/arcade/tetris.ts +662 -0
- package/code-actions/CHANGELOG.md +4 -0
- package/code-actions/README.md +65 -0
- package/code-actions/actions.ts +107 -0
- package/code-actions/index.ts +148 -0
- package/code-actions/package.json +22 -0
- package/code-actions/search.ts +79 -0
- package/code-actions/snippets.ts +179 -0
- package/code-actions/ui.ts +120 -0
- package/files-widget/CHANGELOG.md +90 -0
- package/files-widget/DESIGN.md +452 -0
- package/files-widget/README.md +122 -0
- package/files-widget/TODO.md +141 -0
- package/files-widget/browser.ts +922 -0
- package/files-widget/comment.ts +5 -0
- package/files-widget/constants.ts +18 -0
- package/files-widget/demo.svg +1 -0
- package/files-widget/file-tree.ts +224 -0
- package/files-widget/file-viewer.ts +93 -0
- package/files-widget/git.ts +107 -0
- package/files-widget/index.ts +140 -0
- package/files-widget/input-utils.ts +3 -0
- package/files-widget/package.json +22 -0
- package/files-widget/types.ts +28 -0
- package/files-widget/utils.ts +26 -0
- package/files-widget/viewer.ts +424 -0
- package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
- package/import-cc-codex/spec.md +79 -0
- package/package.json +29 -0
- package/ralph-wiggum/CHANGELOG.md +7 -0
- package/ralph-wiggum/README.md +96 -0
- package/ralph-wiggum/SKILL.md +73 -0
- package/ralph-wiggum/index.ts +792 -0
- package/ralph-wiggum/package.json +25 -0
- package/raw-paste/CHANGELOG.md +7 -0
- package/raw-paste/README.md +52 -0
- package/raw-paste/index.ts +112 -0
- package/raw-paste/package.json +22 -0
- package/tab-status/CHANGELOG.md +4 -0
- package/tab-status/README.md +61 -0
- package/tab-status/assets/tab-status.png +0 -0
- package/tab-status/package.json +22 -0
- package/tab-status/tab-status.ts +179 -0
- package/usage-extension/CHANGELOG.md +17 -0
- package/usage-extension/README.md +120 -0
- package/usage-extension/index.ts +628 -0
- package/usage-extension/package.json +22 -0
- package/usage-extension/screenshot.png +0 -0
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sPIce Invaders game extension - play with /spice-invaders
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
|
|
8
|
+
const GAME_WIDTH = 24;
|
|
9
|
+
const GAME_HEIGHT = 16;
|
|
10
|
+
const PLAYER_Y = GAME_HEIGHT - 1;
|
|
11
|
+
const TICK_MS = 100;
|
|
12
|
+
|
|
13
|
+
const INVADER_ROWS = 3;
|
|
14
|
+
const INVADER_COLS = 6;
|
|
15
|
+
const INVADER_START_X = 1;
|
|
16
|
+
const INVADER_START_Y = 1;
|
|
17
|
+
const INVADER_SPACING_X = 2;
|
|
18
|
+
const INVADER_SPACING_Y = 1;
|
|
19
|
+
const INITIAL_INVADER_COUNT = INVADER_ROWS * INVADER_COLS;
|
|
20
|
+
|
|
21
|
+
const INITIAL_LIVES = 3;
|
|
22
|
+
const BASE_INVADER_DELAY = 8;
|
|
23
|
+
const PLAYER_SHOT_DELAY = 1;
|
|
24
|
+
const MAX_PLAYER_BULLETS = 3;
|
|
25
|
+
const PLAYER_MOVE_STEP = 1;
|
|
26
|
+
const PLAYER_MOVE_HOLD_TICKS = 3;
|
|
27
|
+
const INVADER_FIRE_DELAY = 6;
|
|
28
|
+
const INVADER_BULLET_STEP_TICKS = 2;
|
|
29
|
+
const MAX_INVADER_BULLETS = 2;
|
|
30
|
+
const INVADER_SCORE = 10;
|
|
31
|
+
const INVADER_ROW_SCORES = [30, 20, 10];
|
|
32
|
+
const UFO_SCORE = 50;
|
|
33
|
+
const UFO_BASE_COOLDOWN = 70;
|
|
34
|
+
const READY_TICKS = 20;
|
|
35
|
+
|
|
36
|
+
const BOSS_WIDTH = 6;
|
|
37
|
+
const BOSS_HEIGHT = 2;
|
|
38
|
+
const BOSS_HP = 20;
|
|
39
|
+
const BOSS_MOVE_DELAY = 6;
|
|
40
|
+
const BOSS_BULLET_STEP_TICKS = 4;
|
|
41
|
+
const BOSS_MAX_BULLETS = 4;
|
|
42
|
+
const BOSS_FIRE_CHANCE_BONUS = 0.2;
|
|
43
|
+
const BOSS_SCORE = 200;
|
|
44
|
+
const BOSS_Y = 1;
|
|
45
|
+
const CHEAT_CODE = "clawd";
|
|
46
|
+
const CHEAT_BUFFER_TICKS = 12;
|
|
47
|
+
const BOSS_ENRAGE_RATIO = 0.5;
|
|
48
|
+
const BOSS_ENRAGE_TICKS = 16;
|
|
49
|
+
|
|
50
|
+
const CELL_WIDTH = 2;
|
|
51
|
+
const MIN_RENDER_CELLS = 10;
|
|
52
|
+
|
|
53
|
+
const SPACE_INVADERS_SAVE_TYPE = "spice-invaders-save";
|
|
54
|
+
|
|
55
|
+
const UFO_Y = 0;
|
|
56
|
+
|
|
57
|
+
const BOSS_FRAMES = [
|
|
58
|
+
[
|
|
59
|
+
["<\\", "()", "==", "==", "()", "/>"],
|
|
60
|
+
["()", "\\/", "/\\", "/\\", "\\/", "()"],
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
["<\\", "()", "~~", "~~", "()", "/>"],
|
|
64
|
+
["()", "\\/", "/\\", "/\\", "\\/", "()"],
|
|
65
|
+
],
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
type Direction = -1 | 1;
|
|
69
|
+
type MoveDir = Direction | 0;
|
|
70
|
+
type Point = { x: number; y: number };
|
|
71
|
+
|
|
72
|
+
type BulletSource = "player" | "invader";
|
|
73
|
+
|
|
74
|
+
interface Bullet {
|
|
75
|
+
x: number;
|
|
76
|
+
y: number;
|
|
77
|
+
from: BulletSource;
|
|
78
|
+
unblockable?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface UfoState {
|
|
82
|
+
x: number;
|
|
83
|
+
dir: Direction;
|
|
84
|
+
active: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface BossState {
|
|
88
|
+
active: boolean;
|
|
89
|
+
x: number;
|
|
90
|
+
y: number;
|
|
91
|
+
dir: Direction;
|
|
92
|
+
hp: number;
|
|
93
|
+
maxHp: number;
|
|
94
|
+
frame: 0 | 1;
|
|
95
|
+
moveCounter: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ScatterInvader {
|
|
99
|
+
x: number;
|
|
100
|
+
y: number;
|
|
101
|
+
vx: Direction;
|
|
102
|
+
vy: Direction;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type BossIntroPhase = "scatter" | "descend" | null;
|
|
106
|
+
|
|
107
|
+
interface GameState {
|
|
108
|
+
invaders: Point[];
|
|
109
|
+
invaderDir: Direction;
|
|
110
|
+
invaderFrame: 0 | 1;
|
|
111
|
+
invaderMoveDelay: number;
|
|
112
|
+
invaderMoveCounter: number;
|
|
113
|
+
invaderOffsetY: number;
|
|
114
|
+
playerX: number;
|
|
115
|
+
playerBullets: Bullet[];
|
|
116
|
+
invaderBullets: Bullet[];
|
|
117
|
+
playerCooldown: number;
|
|
118
|
+
invaderCooldown: number;
|
|
119
|
+
playerMoveDir: MoveDir;
|
|
120
|
+
playerMoveHold: number;
|
|
121
|
+
bulletTick: number;
|
|
122
|
+
wavePauseTicks: number;
|
|
123
|
+
pendingWave: boolean;
|
|
124
|
+
score: number;
|
|
125
|
+
highScore: number;
|
|
126
|
+
lives: number;
|
|
127
|
+
level: number;
|
|
128
|
+
gameOver: boolean;
|
|
129
|
+
ufo: UfoState;
|
|
130
|
+
ufoCooldown: number;
|
|
131
|
+
boss: BossState;
|
|
132
|
+
bossEnrageTicks: number;
|
|
133
|
+
bossEnrageBlink: boolean;
|
|
134
|
+
bossIntroPhase: BossIntroPhase;
|
|
135
|
+
scatterInvaders: ScatterInvader[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const createInvaders = (): Point[] => {
|
|
139
|
+
const invaders: Point[] = [];
|
|
140
|
+
for (let row = 0; row < INVADER_ROWS; row++) {
|
|
141
|
+
for (let col = 0; col < INVADER_COLS; col++) {
|
|
142
|
+
const x = INVADER_START_X + col * (1 + INVADER_SPACING_X);
|
|
143
|
+
const y = INVADER_START_Y + row * (1 + INVADER_SPACING_Y);
|
|
144
|
+
invaders.push({ x, y });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return invaders;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const createBossState = (level = 1): BossState => {
|
|
151
|
+
const maxHp = BOSS_HP * Math.max(1, level);
|
|
152
|
+
return {
|
|
153
|
+
active: false,
|
|
154
|
+
x: 0,
|
|
155
|
+
y: BOSS_Y,
|
|
156
|
+
dir: 1,
|
|
157
|
+
hp: maxHp,
|
|
158
|
+
maxHp,
|
|
159
|
+
frame: 0,
|
|
160
|
+
moveCounter: 0,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const bossEnrageHp = (boss: BossState): number => Math.max(1, Math.ceil(boss.maxHp * BOSS_ENRAGE_RATIO));
|
|
165
|
+
|
|
166
|
+
const bossIsEnraged = (boss: BossState): boolean => boss.active && boss.hp <= bossEnrageHp(boss);
|
|
167
|
+
|
|
168
|
+
const bossMoveDelayFor = (boss: BossState, level: number): number => {
|
|
169
|
+
const levelBoost = Math.max(1, level);
|
|
170
|
+
const base = Math.max(1, Math.floor(BOSS_MOVE_DELAY / levelBoost));
|
|
171
|
+
return bossIsEnraged(boss) ? Math.max(1, Math.floor(base / 1.5)) : base;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const invaderDelayFor = (level: number, remaining: number): number => {
|
|
175
|
+
const cleared = Math.max(0, INITIAL_INVADER_COUNT - remaining);
|
|
176
|
+
const speedUp = Math.floor(cleared / 4);
|
|
177
|
+
const levelBoost = Math.floor((level - 1) / 2);
|
|
178
|
+
return Math.max(2, BASE_INVADER_DELAY - speedUp - levelBoost);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const invaderFireDelayFor = (level: number, remaining: number): number => {
|
|
182
|
+
const cleared = Math.max(0, INITIAL_INVADER_COUNT - remaining);
|
|
183
|
+
const speedUp = Math.floor(cleared / 6);
|
|
184
|
+
const levelBoost = Math.floor((level - 1) / 3);
|
|
185
|
+
return Math.max(2, INVADER_FIRE_DELAY - speedUp - levelBoost);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const invaderFireChanceFor = (remaining: number): number => {
|
|
189
|
+
const ratio = 1 - remaining / INITIAL_INVADER_COUNT;
|
|
190
|
+
return Math.min(0.75, 0.35 + ratio * 0.35);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const bossFireDelayFor = (level: number, boss: BossState): number => {
|
|
194
|
+
const base = invaderFireDelayFor(level, 1);
|
|
195
|
+
return bossIsEnraged(boss) ? Math.max(1, Math.floor(base / 1.5)) : base;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const bossFireChanceFor = (boss: BossState): number => {
|
|
199
|
+
const base = Math.min(0.9, invaderFireChanceFor(1) + BOSS_FIRE_CHANCE_BONUS);
|
|
200
|
+
return bossIsEnraged(boss) ? Math.min(0.95, base * 1.5) : base;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const bossBulletStepFor = (level: number, boss: BossState): number => {
|
|
204
|
+
const levelBoost = Math.max(1, level);
|
|
205
|
+
const base = Math.max(1, Math.floor(BOSS_BULLET_STEP_TICKS / levelBoost));
|
|
206
|
+
return bossIsEnraged(boss) ? Math.max(1, Math.floor(base / 1.5)) : base;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const invaderRowScoreFor = (invader: Point, offsetY: number): number => {
|
|
210
|
+
const spacing = 1 + INVADER_SPACING_Y;
|
|
211
|
+
const rawRow = Math.round((invader.y - offsetY - INVADER_START_Y) / spacing);
|
|
212
|
+
const row = Math.max(0, Math.min(INVADER_ROW_SCORES.length - 1, rawRow));
|
|
213
|
+
return INVADER_ROW_SCORES[row] ?? INVADER_SCORE;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const createInitialState = (highScore = 0): GameState => {
|
|
217
|
+
const invaders = createInvaders();
|
|
218
|
+
return {
|
|
219
|
+
invaders,
|
|
220
|
+
invaderDir: 1,
|
|
221
|
+
invaderFrame: 0,
|
|
222
|
+
invaderMoveDelay: invaderDelayFor(1, invaders.length),
|
|
223
|
+
invaderMoveCounter: 0,
|
|
224
|
+
invaderOffsetY: 0,
|
|
225
|
+
playerX: Math.floor(GAME_WIDTH / 2),
|
|
226
|
+
playerBullets: [],
|
|
227
|
+
invaderBullets: [],
|
|
228
|
+
playerCooldown: 0,
|
|
229
|
+
invaderCooldown: invaderFireDelayFor(1, invaders.length),
|
|
230
|
+
playerMoveDir: 0,
|
|
231
|
+
playerMoveHold: 0,
|
|
232
|
+
bulletTick: 0,
|
|
233
|
+
wavePauseTicks: 0,
|
|
234
|
+
pendingWave: false,
|
|
235
|
+
score: 0,
|
|
236
|
+
highScore,
|
|
237
|
+
lives: INITIAL_LIVES,
|
|
238
|
+
level: 1,
|
|
239
|
+
gameOver: false,
|
|
240
|
+
ufo: { x: 0, dir: 1, active: false },
|
|
241
|
+
ufoCooldown: UFO_BASE_COOLDOWN,
|
|
242
|
+
boss: createBossState(1),
|
|
243
|
+
bossEnrageTicks: 0,
|
|
244
|
+
bossEnrageBlink: false,
|
|
245
|
+
bossIntroPhase: null,
|
|
246
|
+
scatterInvaders: [],
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const cloneState = (state: GameState): GameState => ({
|
|
251
|
+
...state,
|
|
252
|
+
invaders: state.invaders.map((invader) => ({ ...invader })),
|
|
253
|
+
playerBullets: state.playerBullets.map((bullet) => ({ ...bullet })),
|
|
254
|
+
invaderBullets: state.invaderBullets.map((bullet) => ({ ...bullet })),
|
|
255
|
+
ufo: { ...state.ufo },
|
|
256
|
+
boss: { ...state.boss },
|
|
257
|
+
bossEnrageTicks: state.bossEnrageTicks,
|
|
258
|
+
bossEnrageBlink: state.bossEnrageBlink,
|
|
259
|
+
bossIntroPhase: state.bossIntroPhase,
|
|
260
|
+
scatterInvaders: state.scatterInvaders.map((invader) => ({ ...invader })),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const normalizeState = (state: GameState): GameState => {
|
|
264
|
+
const invaders = state.invaders ?? createInvaders();
|
|
265
|
+
const level = state.level ?? 1;
|
|
266
|
+
const boss = state.boss
|
|
267
|
+
? {
|
|
268
|
+
...state.boss,
|
|
269
|
+
y: state.boss.y ?? BOSS_Y,
|
|
270
|
+
maxHp: state.boss.maxHp ?? BOSS_HP * Math.max(1, level),
|
|
271
|
+
}
|
|
272
|
+
: createBossState(level);
|
|
273
|
+
return {
|
|
274
|
+
...state,
|
|
275
|
+
invaders,
|
|
276
|
+
ufo: state.ufo ?? { x: 0, dir: 1, active: false },
|
|
277
|
+
ufoCooldown: state.ufoCooldown ?? UFO_BASE_COOLDOWN,
|
|
278
|
+
invaderOffsetY: state.invaderOffsetY ?? 0,
|
|
279
|
+
playerMoveDir: state.playerMoveDir ?? 0,
|
|
280
|
+
playerMoveHold: state.playerMoveHold ?? 0,
|
|
281
|
+
bulletTick: state.bulletTick ?? 0,
|
|
282
|
+
wavePauseTicks: state.wavePauseTicks ?? 0,
|
|
283
|
+
pendingWave: state.pendingWave ?? false,
|
|
284
|
+
invaderCooldown: state.invaderCooldown ?? invaderFireDelayFor(level, invaders.length),
|
|
285
|
+
boss,
|
|
286
|
+
bossEnrageTicks: state.bossEnrageTicks ?? 0,
|
|
287
|
+
bossEnrageBlink: state.bossEnrageBlink ?? false,
|
|
288
|
+
bossIntroPhase: state.bossIntroPhase ?? null,
|
|
289
|
+
scatterInvaders: state.scatterInvaders ?? [],
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
class SpaceInvadersComponent {
|
|
294
|
+
private state: GameState;
|
|
295
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
296
|
+
private onClose: () => void;
|
|
297
|
+
private onSave: (state: GameState | null) => void;
|
|
298
|
+
private tui: { requestRender: () => void };
|
|
299
|
+
private cachedLines: string[] = [];
|
|
300
|
+
private cachedWidth = 0;
|
|
301
|
+
private version = 0;
|
|
302
|
+
private cachedVersion = -1;
|
|
303
|
+
private paused: boolean;
|
|
304
|
+
private cheatBuffer = "";
|
|
305
|
+
private cheatBufferTicks = 0;
|
|
306
|
+
|
|
307
|
+
constructor(
|
|
308
|
+
tui: { requestRender: () => void },
|
|
309
|
+
onClose: () => void,
|
|
310
|
+
onSave: (state: GameState | null) => void,
|
|
311
|
+
savedState?: GameState,
|
|
312
|
+
) {
|
|
313
|
+
this.tui = tui;
|
|
314
|
+
this.onClose = onClose;
|
|
315
|
+
this.onSave = onSave;
|
|
316
|
+
|
|
317
|
+
if (savedState && !savedState.gameOver) {
|
|
318
|
+
this.state = normalizeState(savedState);
|
|
319
|
+
this.paused = true;
|
|
320
|
+
} else {
|
|
321
|
+
const highScore = savedState?.highScore ?? 0;
|
|
322
|
+
this.state = createInitialState(highScore);
|
|
323
|
+
this.paused = false;
|
|
324
|
+
this.startLoop();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private startLoop(): void {
|
|
329
|
+
if (this.interval) return;
|
|
330
|
+
this.interval = setInterval(() => {
|
|
331
|
+
if (this.paused || this.state.gameOver) return;
|
|
332
|
+
this.tick();
|
|
333
|
+
this.markDirty();
|
|
334
|
+
}, TICK_MS);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private stopLoop(): void {
|
|
338
|
+
if (!this.interval) return;
|
|
339
|
+
clearInterval(this.interval);
|
|
340
|
+
this.interval = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private markDirty(): void {
|
|
344
|
+
this.version++;
|
|
345
|
+
this.tui.requestRender();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private tick(): void {
|
|
349
|
+
this.decayCheatBuffer();
|
|
350
|
+
if (this.state.bossIntroPhase) {
|
|
351
|
+
this.updateBossIntro();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (this.state.bossEnrageTicks > 0) {
|
|
355
|
+
this.state.bossEnrageTicks -= 1;
|
|
356
|
+
this.state.bossEnrageBlink = !this.state.bossEnrageBlink;
|
|
357
|
+
if (this.state.bossEnrageTicks === 0) {
|
|
358
|
+
this.state.bossEnrageBlink = false;
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (this.state.wavePauseTicks > 0) {
|
|
363
|
+
this.state.wavePauseTicks -= 1;
|
|
364
|
+
if (this.state.wavePauseTicks === 0 && this.state.pendingWave) {
|
|
365
|
+
this.startNextWave();
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
this.state.bulletTick += 1;
|
|
371
|
+
if (this.state.playerCooldown > 0) this.state.playerCooldown--;
|
|
372
|
+
if (this.state.invaderCooldown > 0) this.state.invaderCooldown--;
|
|
373
|
+
|
|
374
|
+
this.applyHeldMovement();
|
|
375
|
+
if (this.state.boss.active) {
|
|
376
|
+
this.updateBoss();
|
|
377
|
+
} else {
|
|
378
|
+
this.updateUfo();
|
|
379
|
+
}
|
|
380
|
+
this.moveBullets();
|
|
381
|
+
this.resolveBulletCollisions();
|
|
382
|
+
if (this.state.gameOver) {
|
|
383
|
+
this.stopLoop();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (this.state.boss.active) {
|
|
387
|
+
this.maybeFireInvaderBullet();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
this.moveInvaders();
|
|
391
|
+
if (this.state.gameOver) {
|
|
392
|
+
this.stopLoop();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
this.maybeFireInvaderBullet();
|
|
396
|
+
this.maybeAdvanceWave();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private decayCheatBuffer(): void {
|
|
400
|
+
if (this.cheatBufferTicks <= 0) return;
|
|
401
|
+
this.cheatBufferTicks -= 1;
|
|
402
|
+
if (this.cheatBufferTicks <= 0) {
|
|
403
|
+
this.cheatBuffer = "";
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private registerCheatInput(data: string): boolean {
|
|
408
|
+
if (data.length !== 1) return false;
|
|
409
|
+
const lower = data.toLowerCase();
|
|
410
|
+
if (lower < "a" || lower > "z") return false;
|
|
411
|
+
this.cheatBuffer = (this.cheatBuffer + lower).slice(-CHEAT_CODE.length);
|
|
412
|
+
this.cheatBufferTicks = CHEAT_BUFFER_TICKS;
|
|
413
|
+
if (!this.cheatBuffer.endsWith(CHEAT_CODE)) return false;
|
|
414
|
+
this.cheatBuffer = "";
|
|
415
|
+
this.activateBoss();
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private activateBoss(): void {
|
|
420
|
+
if (this.state.boss.active || this.state.bossIntroPhase) return;
|
|
421
|
+
const centered = Math.floor((GAME_WIDTH - BOSS_WIDTH) / 2);
|
|
422
|
+
const maxHp = BOSS_HP * Math.max(1, this.state.level);
|
|
423
|
+
const scatterInvaders = this.state.invaders.map((invader) => ({
|
|
424
|
+
x: invader.x,
|
|
425
|
+
y: invader.y,
|
|
426
|
+
vx: Math.random() < 0.5 ? -1 : 1,
|
|
427
|
+
vy: Math.random() < 0.5 ? -1 : 1,
|
|
428
|
+
}));
|
|
429
|
+
|
|
430
|
+
this.state.boss = {
|
|
431
|
+
active: false,
|
|
432
|
+
x: Math.max(0, Math.min(GAME_WIDTH - BOSS_WIDTH, centered)),
|
|
433
|
+
y: -BOSS_HEIGHT,
|
|
434
|
+
dir: 1,
|
|
435
|
+
hp: maxHp,
|
|
436
|
+
maxHp,
|
|
437
|
+
frame: 0,
|
|
438
|
+
moveCounter: 0,
|
|
439
|
+
};
|
|
440
|
+
this.state.invaders = [];
|
|
441
|
+
this.state.scatterInvaders = scatterInvaders;
|
|
442
|
+
this.state.invaderBullets = [];
|
|
443
|
+
this.state.playerBullets = [];
|
|
444
|
+
this.state.invaderOffsetY = 0;
|
|
445
|
+
this.state.invaderMoveCounter = 0;
|
|
446
|
+
this.state.pendingWave = false;
|
|
447
|
+
this.state.wavePauseTicks = 0;
|
|
448
|
+
this.state.bossEnrageTicks = 0;
|
|
449
|
+
this.state.bossEnrageBlink = false;
|
|
450
|
+
this.state.bossIntroPhase = scatterInvaders.length ? "scatter" : "descend";
|
|
451
|
+
this.state.playerMoveDir = 0;
|
|
452
|
+
this.state.playerMoveHold = 0;
|
|
453
|
+
this.state.ufo.active = false;
|
|
454
|
+
this.state.ufoCooldown = UFO_BASE_COOLDOWN;
|
|
455
|
+
this.state.invaderCooldown = bossFireDelayFor(this.state.level, this.state.boss);
|
|
456
|
+
this.markDirty();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private updateBossIntro(): void {
|
|
460
|
+
if (this.state.bossIntroPhase === "scatter") {
|
|
461
|
+
this.updateScatterInvaders();
|
|
462
|
+
if (this.state.scatterInvaders.length === 0) {
|
|
463
|
+
this.state.bossIntroPhase = "descend";
|
|
464
|
+
}
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (this.state.bossIntroPhase === "descend") {
|
|
468
|
+
this.state.boss.y += 1;
|
|
469
|
+
if (this.state.boss.y >= BOSS_Y) {
|
|
470
|
+
this.state.boss.y = BOSS_Y;
|
|
471
|
+
this.state.boss.active = true;
|
|
472
|
+
this.state.bossIntroPhase = null;
|
|
473
|
+
this.state.boss.moveCounter = 0;
|
|
474
|
+
this.state.invaderCooldown = bossFireDelayFor(this.state.level, this.state.boss);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private updateScatterInvaders(): void {
|
|
480
|
+
const remaining: ScatterInvader[] = [];
|
|
481
|
+
for (const invader of this.state.scatterInvaders) {
|
|
482
|
+
const next = {
|
|
483
|
+
...invader,
|
|
484
|
+
x: invader.x + invader.vx,
|
|
485
|
+
y: invader.y + invader.vy,
|
|
486
|
+
};
|
|
487
|
+
if (next.x < -1 || next.x > GAME_WIDTH || next.y < -1 || next.y > GAME_HEIGHT) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
remaining.push(next);
|
|
491
|
+
}
|
|
492
|
+
this.state.scatterInvaders = remaining;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private updateBoss(): void {
|
|
496
|
+
if (!this.state.boss.active) return;
|
|
497
|
+
const moveDelay = bossMoveDelayFor(this.state.boss, this.state.level);
|
|
498
|
+
this.state.boss.moveCounter += 1;
|
|
499
|
+
if (this.state.boss.moveCounter < moveDelay) return;
|
|
500
|
+
this.state.boss.moveCounter = 0;
|
|
501
|
+
let nextX = this.state.boss.x + this.state.boss.dir;
|
|
502
|
+
if (nextX < 0 || nextX + BOSS_WIDTH > GAME_WIDTH) {
|
|
503
|
+
this.state.boss.dir = (this.state.boss.dir === 1 ? -1 : 1) as Direction;
|
|
504
|
+
nextX = this.state.boss.x + this.state.boss.dir;
|
|
505
|
+
}
|
|
506
|
+
this.state.boss.x = Math.max(0, Math.min(GAME_WIDTH - BOSS_WIDTH, nextX));
|
|
507
|
+
this.state.boss.frame = this.state.boss.frame === 0 ? 1 : 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private applyHeldMovement(): void {
|
|
511
|
+
if (this.state.playerMoveHold <= 0 || this.state.playerMoveDir === 0) return;
|
|
512
|
+
this.state.playerX = Math.max(
|
|
513
|
+
0,
|
|
514
|
+
Math.min(GAME_WIDTH - 1, this.state.playerX + this.state.playerMoveDir * PLAYER_MOVE_STEP),
|
|
515
|
+
);
|
|
516
|
+
this.state.playerMoveHold -= 1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private queuePlayerMove(dir: Direction): void {
|
|
520
|
+
this.state.playerMoveDir = dir;
|
|
521
|
+
this.state.playerMoveHold = PLAYER_MOVE_HOLD_TICKS;
|
|
522
|
+
this.state.playerX = Math.max(0, Math.min(GAME_WIDTH - 1, this.state.playerX + dir * PLAYER_MOVE_STEP));
|
|
523
|
+
this.markDirty();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private updateUfo(): void {
|
|
527
|
+
if (this.state.ufo.active) {
|
|
528
|
+
this.state.ufo.x += this.state.ufo.dir;
|
|
529
|
+
if (this.state.ufo.x < 0 || this.state.ufo.x >= GAME_WIDTH) {
|
|
530
|
+
this.state.ufo.active = false;
|
|
531
|
+
this.state.ufoCooldown = UFO_BASE_COOLDOWN;
|
|
532
|
+
}
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (this.state.ufoCooldown > 0) {
|
|
537
|
+
this.state.ufoCooldown -= 1;
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (Math.random() < 0.25) {
|
|
542
|
+
const dir: Direction = Math.random() < 0.5 ? 1 : -1;
|
|
543
|
+
this.state.ufo = {
|
|
544
|
+
x: dir === 1 ? 0 : GAME_WIDTH - 1,
|
|
545
|
+
dir,
|
|
546
|
+
active: true,
|
|
547
|
+
};
|
|
548
|
+
} else {
|
|
549
|
+
this.state.ufoCooldown = 10;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private moveBullets(): void {
|
|
554
|
+
const nextPlayerBullets: Bullet[] = [];
|
|
555
|
+
for (const bullet of this.state.playerBullets) {
|
|
556
|
+
const moved = { ...bullet, y: bullet.y - 1 };
|
|
557
|
+
if (moved.y >= 0) {
|
|
558
|
+
nextPlayerBullets.push(moved);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
this.state.playerBullets = nextPlayerBullets;
|
|
562
|
+
|
|
563
|
+
const invaderStep = this.state.boss.active
|
|
564
|
+
? bossBulletStepFor(this.state.level, this.state.boss)
|
|
565
|
+
: INVADER_BULLET_STEP_TICKS;
|
|
566
|
+
const shouldMoveInvaderBullets = this.state.bulletTick % invaderStep === 0;
|
|
567
|
+
const nextInvaderBullets: Bullet[] = [];
|
|
568
|
+
for (const bullet of this.state.invaderBullets) {
|
|
569
|
+
const moved = shouldMoveInvaderBullets ? { ...bullet, y: bullet.y + 1 } : { ...bullet };
|
|
570
|
+
if (moved.y <= PLAYER_Y) {
|
|
571
|
+
nextInvaderBullets.push(moved);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
this.state.invaderBullets = nextInvaderBullets;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private resolveBulletCollisions(): void {
|
|
578
|
+
const invaderBulletByPos = new Map<string, Bullet>();
|
|
579
|
+
for (const bullet of this.state.invaderBullets) {
|
|
580
|
+
invaderBulletByPos.set(`${bullet.x},${bullet.y}`, bullet);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const invaderIndexByPos = new Map<string, number>();
|
|
584
|
+
for (let i = 0; i < this.state.invaders.length; i++) {
|
|
585
|
+
const invader = this.state.invaders[i];
|
|
586
|
+
invaderIndexByPos.set(`${invader.x},${invader.y}`, i);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const bossBulletsUnblockable = this.state.boss.active && bossIsEnraged(this.state.boss);
|
|
590
|
+
|
|
591
|
+
const hitBoss = (bullet: Bullet): boolean => {
|
|
592
|
+
if (!this.state.boss.active) return false;
|
|
593
|
+
const bossY = this.state.boss.y;
|
|
594
|
+
if (bullet.y < bossY || bullet.y >= bossY + BOSS_HEIGHT) return false;
|
|
595
|
+
if (bullet.x < this.state.boss.x || bullet.x >= this.state.boss.x + BOSS_WIDTH) return false;
|
|
596
|
+
const wasEnraged = bossIsEnraged(this.state.boss);
|
|
597
|
+
this.state.boss.hp -= 1;
|
|
598
|
+
if (this.state.boss.hp <= 0) {
|
|
599
|
+
this.state.boss.active = false;
|
|
600
|
+
this.state.bossEnrageTicks = 0;
|
|
601
|
+
this.state.bossEnrageBlink = false;
|
|
602
|
+
this.state.score += BOSS_SCORE;
|
|
603
|
+
if (this.state.score > this.state.highScore) {
|
|
604
|
+
this.state.highScore = this.state.score;
|
|
605
|
+
}
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
if (!wasEnraged && bossIsEnraged(this.state.boss)) {
|
|
609
|
+
this.state.bossEnrageTicks = BOSS_ENRAGE_TICKS;
|
|
610
|
+
this.state.bossEnrageBlink = true;
|
|
611
|
+
}
|
|
612
|
+
return true;
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const afterBulletClash: Bullet[] = [];
|
|
616
|
+
for (const bullet of this.state.playerBullets) {
|
|
617
|
+
const key = `${bullet.x},${bullet.y}`;
|
|
618
|
+
const blocking = invaderBulletByPos.get(key);
|
|
619
|
+
if (blocking) {
|
|
620
|
+
if (!(blocking.unblockable || bossBulletsUnblockable)) {
|
|
621
|
+
invaderBulletByPos.delete(key);
|
|
622
|
+
}
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
afterBulletClash.push(bullet);
|
|
626
|
+
}
|
|
627
|
+
this.state.invaderBullets = Array.from(invaderBulletByPos.values());
|
|
628
|
+
|
|
629
|
+
const hitInvaders = new Map<number, number>();
|
|
630
|
+
const remainingPlayerBullets: Bullet[] = [];
|
|
631
|
+
for (const bullet of afterBulletClash) {
|
|
632
|
+
const key = `${bullet.x},${bullet.y}`;
|
|
633
|
+
if (this.state.ufo.active && bullet.y === UFO_Y && bullet.x === this.state.ufo.x) {
|
|
634
|
+
this.state.ufo.active = false;
|
|
635
|
+
this.state.ufoCooldown = UFO_BASE_COOLDOWN;
|
|
636
|
+
this.state.score += UFO_SCORE;
|
|
637
|
+
if (this.state.score > this.state.highScore) {
|
|
638
|
+
this.state.highScore = this.state.score;
|
|
639
|
+
}
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (hitBoss(bullet)) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const invaderIndex = invaderIndexByPos.get(key);
|
|
646
|
+
if (invaderIndex !== undefined) {
|
|
647
|
+
if (!hitInvaders.has(invaderIndex)) {
|
|
648
|
+
const invader = this.state.invaders[invaderIndex];
|
|
649
|
+
hitInvaders.set(invaderIndex, invaderRowScoreFor(invader, this.state.invaderOffsetY));
|
|
650
|
+
}
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
remainingPlayerBullets.push(bullet);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (hitInvaders.size > 0) {
|
|
657
|
+
this.state.invaders = this.state.invaders.filter((_invader, idx) => !hitInvaders.has(idx));
|
|
658
|
+
let scoreDelta = 0;
|
|
659
|
+
for (const value of hitInvaders.values()) {
|
|
660
|
+
scoreDelta += value;
|
|
661
|
+
}
|
|
662
|
+
this.state.score += scoreDelta;
|
|
663
|
+
if (this.state.score > this.state.highScore) {
|
|
664
|
+
this.state.highScore = this.state.score;
|
|
665
|
+
}
|
|
666
|
+
this.state.invaderMoveDelay = invaderDelayFor(this.state.level, this.state.invaders.length);
|
|
667
|
+
const newFireDelay = invaderFireDelayFor(this.state.level, this.state.invaders.length);
|
|
668
|
+
if (this.state.invaderCooldown > newFireDelay) {
|
|
669
|
+
this.state.invaderCooldown = newFireDelay;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
this.state.playerBullets = remainingPlayerBullets;
|
|
673
|
+
|
|
674
|
+
const remainingInvaderBullets: Bullet[] = [];
|
|
675
|
+
for (const bullet of this.state.invaderBullets) {
|
|
676
|
+
if (bullet.x === this.state.playerX && bullet.y === PLAYER_Y) {
|
|
677
|
+
this.state.lives -= 1;
|
|
678
|
+
if (this.state.lives <= 0) {
|
|
679
|
+
this.state.gameOver = true;
|
|
680
|
+
}
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
remainingInvaderBullets.push(bullet);
|
|
684
|
+
}
|
|
685
|
+
this.state.invaderBullets = remainingInvaderBullets;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private moveInvaders(): void {
|
|
689
|
+
this.state.invaderMoveCounter += 1;
|
|
690
|
+
if (this.state.invaderMoveCounter < this.state.invaderMoveDelay) return;
|
|
691
|
+
this.state.invaderMoveCounter = 0;
|
|
692
|
+
|
|
693
|
+
const dir = this.state.invaderDir;
|
|
694
|
+
let hitEdge = false;
|
|
695
|
+
for (const invader of this.state.invaders) {
|
|
696
|
+
const nextX = invader.x + dir;
|
|
697
|
+
if (nextX < 0 || nextX >= GAME_WIDTH) {
|
|
698
|
+
hitEdge = true;
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (hitEdge) {
|
|
704
|
+
this.state.invaderDir = (dir === 1 ? -1 : 1) as Direction;
|
|
705
|
+
for (const invader of this.state.invaders) {
|
|
706
|
+
invader.y += 1;
|
|
707
|
+
if (invader.y >= PLAYER_Y) {
|
|
708
|
+
this.state.gameOver = true;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
this.state.invaderOffsetY += 1;
|
|
712
|
+
} else {
|
|
713
|
+
for (const invader of this.state.invaders) {
|
|
714
|
+
invader.x += dir;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
this.state.invaderFrame = this.state.invaderFrame === 0 ? 1 : 0;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private maybeFireInvaderBullet(): void {
|
|
722
|
+
if (this.state.invaderCooldown > 0) return;
|
|
723
|
+
const maxBullets = this.state.boss.active ? BOSS_MAX_BULLETS : MAX_INVADER_BULLETS;
|
|
724
|
+
if (this.state.invaderBullets.length >= maxBullets) return;
|
|
725
|
+
|
|
726
|
+
if (this.state.boss.active) {
|
|
727
|
+
const fireChance = bossFireChanceFor(this.state.boss);
|
|
728
|
+
if (Math.random() > fireChance) {
|
|
729
|
+
this.state.invaderCooldown = 1;
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const bossEnraged = bossIsEnraged(this.state.boss);
|
|
733
|
+
const bulletY = this.state.boss.y + BOSS_HEIGHT;
|
|
734
|
+
const leftX = this.state.boss.x + 1;
|
|
735
|
+
const rightX = this.state.boss.x + BOSS_WIDTH - 2;
|
|
736
|
+
const slots = maxBullets - this.state.invaderBullets.length;
|
|
737
|
+
if (slots >= 1) {
|
|
738
|
+
this.state.invaderBullets.push({
|
|
739
|
+
x: leftX,
|
|
740
|
+
y: bulletY,
|
|
741
|
+
from: "invader",
|
|
742
|
+
unblockable: bossEnraged,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
if (slots >= 2 && rightX !== leftX) {
|
|
746
|
+
this.state.invaderBullets.push({
|
|
747
|
+
x: rightX,
|
|
748
|
+
y: bulletY,
|
|
749
|
+
from: "invader",
|
|
750
|
+
unblockable: bossEnraged,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
this.state.invaderCooldown = bossFireDelayFor(this.state.level, this.state.boss);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (this.state.invaders.length === 0) return;
|
|
758
|
+
|
|
759
|
+
const fireChance = invaderFireChanceFor(this.state.invaders.length);
|
|
760
|
+
if (Math.random() > fireChance) {
|
|
761
|
+
this.state.invaderCooldown = 1;
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const shooters = new Map<number, Point>();
|
|
766
|
+
for (const invader of this.state.invaders) {
|
|
767
|
+
const existing = shooters.get(invader.x);
|
|
768
|
+
if (!existing || invader.y > existing.y) {
|
|
769
|
+
shooters.set(invader.x, invader);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const shooterList = Array.from(shooters.values());
|
|
774
|
+
if (shooterList.length === 0) return;
|
|
775
|
+
const shooter = shooterList[Math.floor(Math.random() * shooterList.length)];
|
|
776
|
+
|
|
777
|
+
this.state.invaderBullets.push({ x: shooter.x, y: shooter.y + 1, from: "invader" });
|
|
778
|
+
this.state.invaderCooldown = invaderFireDelayFor(this.state.level, this.state.invaders.length);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private maybeAdvanceWave(): void {
|
|
782
|
+
if (this.state.invaders.length > 0) return;
|
|
783
|
+
if (this.state.pendingWave) return;
|
|
784
|
+
this.state.pendingWave = true;
|
|
785
|
+
this.state.wavePauseTicks = READY_TICKS;
|
|
786
|
+
this.state.playerBullets = [];
|
|
787
|
+
this.state.invaderBullets = [];
|
|
788
|
+
this.state.playerMoveHold = 0;
|
|
789
|
+
this.state.playerMoveDir = 0;
|
|
790
|
+
this.state.ufo.active = false;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private startNextWave(): void {
|
|
794
|
+
this.state.level += 1;
|
|
795
|
+
this.state.invaders = createInvaders();
|
|
796
|
+
this.state.invaderDir = 1;
|
|
797
|
+
this.state.invaderFrame = 0;
|
|
798
|
+
this.state.invaderMoveDelay = invaderDelayFor(this.state.level, this.state.invaders.length);
|
|
799
|
+
this.state.invaderMoveCounter = 0;
|
|
800
|
+
this.state.invaderOffsetY = 0;
|
|
801
|
+
this.state.playerBullets = [];
|
|
802
|
+
this.state.invaderBullets = [];
|
|
803
|
+
this.state.playerCooldown = 0;
|
|
804
|
+
this.state.invaderCooldown = invaderFireDelayFor(this.state.level, this.state.invaders.length);
|
|
805
|
+
this.state.playerX = Math.floor(GAME_WIDTH / 2);
|
|
806
|
+
this.state.playerMoveDir = 0;
|
|
807
|
+
this.state.playerMoveHold = 0;
|
|
808
|
+
this.state.ufo = { x: 0, dir: 1, active: false };
|
|
809
|
+
this.state.ufoCooldown = UFO_BASE_COOLDOWN;
|
|
810
|
+
this.state.wavePauseTicks = 0;
|
|
811
|
+
this.state.pendingWave = false;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private togglePause(): void {
|
|
815
|
+
this.paused = !this.paused;
|
|
816
|
+
if (this.paused) {
|
|
817
|
+
this.stopLoop();
|
|
818
|
+
} else {
|
|
819
|
+
this.startLoop();
|
|
820
|
+
}
|
|
821
|
+
this.markDirty();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private restartGame(): void {
|
|
825
|
+
const highScore = this.state.highScore;
|
|
826
|
+
this.state = createInitialState(highScore);
|
|
827
|
+
this.paused = false;
|
|
828
|
+
this.stopLoop();
|
|
829
|
+
this.startLoop();
|
|
830
|
+
this.onSave(null);
|
|
831
|
+
this.markDirty();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
handleInput(data: string): void {
|
|
835
|
+
if (this.state.bossIntroPhase) {
|
|
836
|
+
if (matchesKey(data, "escape")) {
|
|
837
|
+
this.dispose();
|
|
838
|
+
this.onSave(cloneState(this.state));
|
|
839
|
+
this.onClose();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (data === "q" || data === "Q") {
|
|
843
|
+
this.dispose();
|
|
844
|
+
this.onSave(null);
|
|
845
|
+
this.onClose();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (this.registerCheatInput(data)) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (this.paused) {
|
|
856
|
+
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
857
|
+
this.dispose();
|
|
858
|
+
this.onClose();
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
this.paused = false;
|
|
862
|
+
this.startLoop();
|
|
863
|
+
this.markDirty();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (matchesKey(data, "escape")) {
|
|
868
|
+
this.dispose();
|
|
869
|
+
this.onSave(cloneState(this.state));
|
|
870
|
+
this.onClose();
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (data === "q" || data === "Q") {
|
|
875
|
+
this.dispose();
|
|
876
|
+
this.onSave(null);
|
|
877
|
+
this.onClose();
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
|
|
882
|
+
this.restartGame();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (data === "p" || data === "P") {
|
|
887
|
+
this.togglePause();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (matchesKey(data, "left") || data === "a" || data === "A") {
|
|
892
|
+
this.queuePlayerMove(-1);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (matchesKey(data, "right") || data === "d" || data === "D") {
|
|
897
|
+
this.queuePlayerMove(1);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (data === " " && !this.state.gameOver) {
|
|
902
|
+
if (this.state.playerCooldown === 0 && this.state.playerBullets.length < MAX_PLAYER_BULLETS) {
|
|
903
|
+
this.state.playerBullets.push({ x: this.state.playerX, y: PLAYER_Y - 1, from: "player" });
|
|
904
|
+
this.state.playerCooldown = PLAYER_SHOT_DELAY;
|
|
905
|
+
this.markDirty();
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
invalidate(): void {
|
|
911
|
+
this.cachedWidth = 0;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
render(width: number): string[] {
|
|
915
|
+
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
|
916
|
+
return this.cachedLines;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const maxCells = Math.floor((width - 2) / CELL_WIDTH);
|
|
920
|
+
if (maxCells < MIN_RENDER_CELLS) {
|
|
921
|
+
const message = "Lobster Invaders needs a wider terminal";
|
|
922
|
+
const line = truncateToWidth(message, width);
|
|
923
|
+
this.cachedLines = [line];
|
|
924
|
+
this.cachedWidth = width;
|
|
925
|
+
this.cachedVersion = this.version;
|
|
926
|
+
return this.cachedLines;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const renderWidth = Math.min(GAME_WIDTH, maxCells);
|
|
930
|
+
const boxWidth = renderWidth * CELL_WIDTH;
|
|
931
|
+
|
|
932
|
+
const color = (code: string, text: string) => `\x1b[${code}m${text}\x1b[0m`;
|
|
933
|
+
const dim = (text: string) => color("2", text);
|
|
934
|
+
const accent = (text: string) => color("1;31", text);
|
|
935
|
+
const scoreColor = (text: string) => color("33", text);
|
|
936
|
+
const livesColor = (text: string) => color("32", text);
|
|
937
|
+
const levelColor = (text: string) => color("35", text);
|
|
938
|
+
const invaderColor = (text: string) => color("31", text);
|
|
939
|
+
const bossEnraged = bossIsEnraged(this.state.boss);
|
|
940
|
+
const bossCellColor = (text: string, col: number) => {
|
|
941
|
+
if (!bossEnraged) return color("1;37", text);
|
|
942
|
+
const center = (BOSS_WIDTH - 1) / 2;
|
|
943
|
+
const dist = Math.abs(col - center);
|
|
944
|
+
const code = dist <= 0.75 ? "1;31" : dist <= 1.75 ? "31" : "2;31";
|
|
945
|
+
return color(code, text);
|
|
946
|
+
};
|
|
947
|
+
const bossBulletColor = (text: string) => color("95", text);
|
|
948
|
+
const playerColor = (text: string) => color("1;36", text);
|
|
949
|
+
const playerBulletColor = (text: string) => color("33", text);
|
|
950
|
+
const invaderBulletColor = (text: string) => color("31;1", text);
|
|
951
|
+
const ufoColor = (text: string) => color("1;37", text);
|
|
952
|
+
|
|
953
|
+
const lines: string[] = [];
|
|
954
|
+
const topBorder = dim(`+${"-".repeat(boxWidth)}+`);
|
|
955
|
+
|
|
956
|
+
lines.push(this.padLine(topBorder, width));
|
|
957
|
+
|
|
958
|
+
const titleLine = `${accent("LOBSTER INVADERS")} ${dim(`Claws: ${this.state.invaders.length}`)}`;
|
|
959
|
+
const statsLine =
|
|
960
|
+
`Score: ${scoreColor(String(this.state.score))} ` +
|
|
961
|
+
`Lives: ${livesColor(String(this.state.lives))} ` +
|
|
962
|
+
`Level: ${levelColor(String(this.state.level))} ` +
|
|
963
|
+
`High: ${dim(String(this.state.highScore))}`;
|
|
964
|
+
|
|
965
|
+
lines.push(this.padLine(this.boxLine(titleLine, boxWidth), width));
|
|
966
|
+
lines.push(this.padLine(this.boxLine(statsLine, boxWidth), width));
|
|
967
|
+
lines.push(this.padLine(this.boxLine(dim("-".repeat(boxWidth)), boxWidth), width));
|
|
968
|
+
|
|
969
|
+
const invaderMap = new Set(this.state.invaders.map((invader) => `${invader.x},${invader.y}`));
|
|
970
|
+
const scatterMap = new Set(this.state.scatterInvaders.map((invader) => `${invader.x},${invader.y}`));
|
|
971
|
+
const invaderBulletMap = new Set(this.state.invaderBullets.map((bullet) => `${bullet.x},${bullet.y}`));
|
|
972
|
+
const playerBulletMap = new Set(this.state.playerBullets.map((bullet) => `${bullet.x},${bullet.y}`));
|
|
973
|
+
const bossMap = new Map<string, string>();
|
|
974
|
+
const showBoss = this.state.boss.active || this.state.bossIntroPhase === "descend";
|
|
975
|
+
if (showBoss) {
|
|
976
|
+
const frame = BOSS_FRAMES[this.state.boss.frame];
|
|
977
|
+
for (let row = 0; row < BOSS_HEIGHT; row++) {
|
|
978
|
+
const y = this.state.boss.y + row;
|
|
979
|
+
if (y < 0 || y >= GAME_HEIGHT) continue;
|
|
980
|
+
for (let col = 0; col < BOSS_WIDTH; col++) {
|
|
981
|
+
const cell = frame[row][col] ?? " ";
|
|
982
|
+
const key = `${this.state.boss.x + col},${y}`;
|
|
983
|
+
bossMap.set(key, bossCellColor(cell, col));
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const invaderGlyph = invaderColor(this.state.invaderFrame === 0 ? "}{" : "><");
|
|
989
|
+
const playerGlyph = playerColor("/\\");
|
|
990
|
+
const playerBulletGlyph = playerBulletColor("||");
|
|
991
|
+
const invaderBulletGlyph = bossEnraged ? bossBulletColor("!!") : invaderBulletColor("!!");
|
|
992
|
+
const ufoGlyph = ufoColor("==");
|
|
993
|
+
|
|
994
|
+
for (let y = 0; y < GAME_HEIGHT; y++) {
|
|
995
|
+
let row = "";
|
|
996
|
+
for (let x = 0; x < renderWidth; x++) {
|
|
997
|
+
const key = `${x},${y}`;
|
|
998
|
+
if (this.state.ufo.active && y === UFO_Y && x === this.state.ufo.x) {
|
|
999
|
+
row += ufoGlyph;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (playerBulletMap.has(key)) {
|
|
1003
|
+
row += playerBulletGlyph;
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
if (invaderBulletMap.has(key)) {
|
|
1007
|
+
row += invaderBulletGlyph;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
const bossCell = bossMap.get(key);
|
|
1011
|
+
if (bossCell) {
|
|
1012
|
+
row += bossCell;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
if (y === PLAYER_Y && x === this.state.playerX) {
|
|
1016
|
+
row += playerGlyph;
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (scatterMap.has(key)) {
|
|
1020
|
+
row += invaderGlyph;
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (invaderMap.has(key)) {
|
|
1024
|
+
row += invaderGlyph;
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
row += " ";
|
|
1028
|
+
}
|
|
1029
|
+
lines.push(this.padLine(`|${row}|`, width));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
lines.push(this.padLine(this.boxLine(dim("-".repeat(boxWidth)), boxWidth), width));
|
|
1033
|
+
|
|
1034
|
+
let footer: string;
|
|
1035
|
+
if (this.state.bossEnrageTicks > 0) {
|
|
1036
|
+
footer = this.state.bossEnrageBlink ? accent("BOILING MAD LOBSTER") : "";
|
|
1037
|
+
} else if (this.state.pendingWave && this.state.wavePauseTicks > 0) {
|
|
1038
|
+
footer = `${accent("READY")} - Wave ${this.state.level + 1} incoming`;
|
|
1039
|
+
} else if (this.paused) {
|
|
1040
|
+
footer = `${accent("PAUSED")} - Press any key to resume, ${accent("Q")} to quit`;
|
|
1041
|
+
} else if (this.state.gameOver) {
|
|
1042
|
+
footer = `${color("31;1", "GAME OVER")} - Press ${accent("R")} to restart, ${accent("Q")} to quit`;
|
|
1043
|
+
} else {
|
|
1044
|
+
footer = `Left/Right or A/D move, ${accent("Space")} fire, ${accent("ESC")} save`;
|
|
1045
|
+
}
|
|
1046
|
+
lines.push(this.padLine(this.boxLine(footer, boxWidth), width));
|
|
1047
|
+
lines.push(this.padLine(topBorder, width));
|
|
1048
|
+
|
|
1049
|
+
this.cachedLines = lines;
|
|
1050
|
+
this.cachedWidth = width;
|
|
1051
|
+
this.cachedVersion = this.version;
|
|
1052
|
+
|
|
1053
|
+
return lines;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private boxLine(content: string, width: number): string {
|
|
1057
|
+
const truncated = truncateToWidth(content, width);
|
|
1058
|
+
const padding = Math.max(0, width - visibleWidth(truncated));
|
|
1059
|
+
return `|${truncated}${" ".repeat(padding)}|`;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private padLine(line: string, width: number): string {
|
|
1063
|
+
const truncated = truncateToWidth(line, width);
|
|
1064
|
+
const padding = Math.max(0, width - visibleWidth(truncated));
|
|
1065
|
+
return truncated + " ".repeat(padding);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
dispose(): void {
|
|
1069
|
+
this.stopLoop();
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export default function (pi: ExtensionAPI) {
|
|
1074
|
+
pi.registerCommand("spice-invaders", {
|
|
1075
|
+
description: "Play Lobster Invaders!",
|
|
1076
|
+
handler: async (_args, ctx) => {
|
|
1077
|
+
if (!ctx.hasUI) {
|
|
1078
|
+
ctx.ui.notify("Lobster Invaders requires interactive mode", "error");
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const entries = ctx.sessionManager.getEntries();
|
|
1083
|
+
let savedState: GameState | undefined;
|
|
1084
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1085
|
+
const entry = entries[i];
|
|
1086
|
+
if (entry.type === "custom" && entry.customType === SPACE_INVADERS_SAVE_TYPE) {
|
|
1087
|
+
savedState = entry.data as GameState;
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
await ctx.ui.custom((tui, _theme, _kb, done) => {
|
|
1093
|
+
return new SpaceInvadersComponent(
|
|
1094
|
+
tui,
|
|
1095
|
+
() => done(undefined),
|
|
1096
|
+
(state) => {
|
|
1097
|
+
pi.appendEntry(SPACE_INVADERS_SAVE_TYPE, state);
|
|
1098
|
+
},
|
|
1099
|
+
savedState,
|
|
1100
|
+
);
|
|
1101
|
+
});
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
}
|