neon-breach 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 onkar69483
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # NEON BREACH
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A fast, true-colour, first-person shooter that runs entirely inside your terminal. No install, no GPU, no assets.
4
+
5
+ ```bash
6
+ npx neon-breach
7
+ ```
8
+
9
+ or install it globally:
10
+
11
+ ```bash
12
+ npm install -g neon-breach
13
+ neon-breach
14
+ ```
15
+
16
+ Requires **Node.js 18+** and a modern terminal (Windows Terminal, iTerm2, WezTerm, kitty, VS Code, GNOME Terminal...). Make the window big: it adapts up to 120x36 cells by default.
17
+
18
+ ## What's in it
19
+
20
+ - Raycast 3D engine with bilinear-filtered textures, perspective floor and ceiling with anti-aliased neon grid, distance fog and neon lighting
21
+ - Sub-character rendering in 24-bit colour: **quadrant blocks** (2x2 pixels per cell, default) or **braille** (2x4 pixels per cell) for a much sharper image than plain half-blocks, with diff-based output for smooth frame rates. Falls back to 256 colours automatically
22
+ - Procedurally generated levels (rooms, corridors, pillars) that get bigger and harder every level
23
+ - Three enemy types with different AI: **Drones** (melee rushers), **Sentinels** (hover, strafe and shoot) and **Titans** (armoured brutes with fireballs). They path-find to you around corners and hear gunshots
24
+ - Three weapons: **Blaster** (infinite ammo), **Scatter** shotgun, **Plasma** rifle (projectiles with splash damage)
25
+ - Pickups, minimap with fog of war, hit particles, damage vignette, weapon bob and recoil
26
+ - Kill every hostile to open the exit portal, then reach it. Scores and your best score are saved to `~/.neon-breach.json`
27
+
28
+ ## Controls
29
+
30
+ | Key | Action |
31
+ | --- | --- |
32
+ | `W` / `S` or `Up` / `Down` | Move forward / back |
33
+ | `A` / `D` | Strafe |
34
+ | `Left` / `Right` or `Q` / `E` | Turn |
35
+ | `Space` or `X` | Fire |
36
+ | `1` `2` `3` / `Tab` | Choose weapon / cycle weapons |
37
+ | `F` | Toggle autofire (fires while an enemy is under the crosshair) |
38
+ | `M` | Toggle minimap |
39
+ | `G` | Cycle render sharpness: quad / braille / half |
40
+ | `P` / `Esc` | Pause |
41
+ | `Ctrl+C` | Quit |
42
+
43
+ ### Controls feel
44
+
45
+ On **Windows**, Neon Breach reads the real keyboard state (like a native game), so it behaves the traditional way: **hold to move, release to stop**, and you can combine keys freely (e.g. move + turn + fire at once). You'll see `HOLD-TO-MOVE CONTROLS ACTIVE` a second after launch. The key state is ignored while the window is unfocused (in terminals that report focus). Use `--no-native-keys` to turn this off.
46
+
47
+ On **macOS / Linux**, terminals only report key *presses* (plus the OS key-repeat), never releases, so the game emulates holding:
48
+
49
+ - There's a brief pause between the first press and key repeat kicking in
50
+ - Only the most recently pressed key repeats, so moving while firing is awkward. That's what **autofire (`F`)** is for
51
+ - Pressing the opposite direction stops you instantly
52
+
53
+ ## Making it sharper
54
+
55
+ The picture is built from terminal characters, so the size of a "pixel" depends on your font. Two things help most:
56
+
57
+ 1. **Shrink your terminal font** (`Ctrl` + `-` or `Ctrl` + mouse wheel in most terminals) and maximise the window. The game fills the window up to 160x48 cells by default. Use `--width` / `--height` to go larger.
58
+ 2. **Pick a render mode** with `--mode` or press `G` in-game:
59
+ - `quad` (default): 2x2 pixels per cell, clean and widely supported
60
+ - `braille`: 2x4 pixels per cell, finest detail, slightly stippled look
61
+ - `half`: 1x2 pixels per cell, chunkiest but the most compatible
62
+
63
+ ## Options
64
+
65
+ ```
66
+ neon-breach --seed 123 same dungeons every run
67
+ neon-breach --mode braille sharpest rendering (also: quad, half)
68
+ neon-breach --width 200 --height 60 bigger picture (needs a big window)
69
+ neon-breach --fps 20 lighter on slow terminals
70
+ neon-breach --256 force 256-colour mode
71
+ ```
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ npm start # run from source
77
+ npm test # headless tests: level connectivity, combat, AI, rendering, input
78
+ ```
79
+
80
+ Layout: `src/world.js` (collision, raycasting, AI, projectiles), `src/raycaster.js` (renderer), `src/sprites.js` (procedural enemy art), `src/screen.js` (framebuffer + ANSI diffing), `src/game.js` (state machine, player, weapons), `src/hud.js` (weapon models, minimap, status bar).
81
+
82
+ Inspired by the classic console raycaster demos, including the C++ `ConFPS`.
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { run } from '../src/main.js';
4
+
5
+ const HELP = `NEON BREACH - a first-person shooter for your terminal
6
+
7
+ Usage: neon-breach [options]
8
+
9
+ Options:
10
+ --seed <n> Use a fixed seed (same dungeons every run)
11
+ --mode <m> Sharpness: half | quad (default) | braille. Press G in-game to cycle
12
+ --width <cols> Max render width in columns (default 160)
13
+ --height <rows> Max render height in rows (default 48)
14
+ --fps <n> Target frame rate (default 30)
15
+ --256 Force 256-colour output
16
+ --no-native-keys Disable real held-key detection (Windows); use key-repeat emulation instead
17
+ --truecolor Force 24-bit colour output
18
+ -v, --version Print version
19
+ -h, --help Show this help
20
+
21
+ Tip: a modern terminal (Windows Terminal, iTerm2, WezTerm, kitty, VS Code)
22
+ with a monospace font gives the best picture. Make the window large!`;
23
+
24
+ const args = process.argv.slice(2);
25
+ const opts = {};
26
+ const num = (i, name) => {
27
+ const v = Number(args[i + 1]);
28
+ if (!Number.isFinite(v) || v <= 0) {
29
+ console.error(`${name} needs a positive number`);
30
+ process.exit(1);
31
+ }
32
+ return Math.floor(v);
33
+ };
34
+
35
+ for (let i = 0; i < args.length; i++) {
36
+ const a = args[i];
37
+ if (a === '-h' || a === '--help') {
38
+ console.log(HELP);
39
+ process.exit(0);
40
+ } else if (a === '-v' || a === '--version') {
41
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
42
+ console.log(pkg.version);
43
+ process.exit(0);
44
+ } else if (a === '--seed') opts.seed = num(i++, '--seed');
45
+ else if (a === '--mode') {
46
+ const m = args[++i];
47
+ if (!['half', 'quad', 'braille'].includes(m)) {
48
+ console.error('--mode must be half, quad or braille');
49
+ process.exit(1);
50
+ }
51
+ opts.glyphs = m;
52
+ } else if (a === '--width') opts.width = num(i++, '--width');
53
+ else if (a === '--height') opts.height = num(i++, '--height');
54
+ else if (a === '--fps') opts.fps = num(i++, '--fps');
55
+ else if (a === '--256') opts.colors = '256';
56
+ else if (a === '--no-native-keys') opts.nativeKeys = false;
57
+ else if (a === '--truecolor') opts.colors = 'truecolor';
58
+ else {
59
+ console.error(`Unknown option: ${a}\n\n${HELP}`);
60
+ process.exit(1);
61
+ }
62
+ }
63
+
64
+ run(opts);
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "neon-breach",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A fast, true-color first-person shooter that runs entirely in your terminal. Try it with: npx neon-breach",
5
+ "type": "module",
6
+ "author": "onkar69483",
7
+ "homepage": "https://github.com/onkar69483/neon-breach#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/onkar69483/neon-breach.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/onkar69483/neon-breach/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public",
17
+ "registry": "https://registry.npmjs.org/"
18
+ },
19
+ "bin": {
20
+ "neon-breach": "bin/neon-breach.js"
21
+ },
22
+ "files": [
23
+ "bin",
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "scripts": {
31
+ "start": "node bin/neon-breach.js",
32
+ "test": "node --test"
33
+ },
34
+ "keywords": [
35
+ "game",
36
+ "fps",
37
+ "terminal",
38
+ "cli",
39
+ "raycasting",
40
+ "shooter",
41
+ "npx"
42
+ ],
43
+ "license": "MIT"
44
+ }
package/src/game.js ADDED
@@ -0,0 +1,395 @@
1
+ import { World } from './world.js';
2
+ import { renderScene } from './raycaster.js';
3
+ import {
4
+ WEAPONS, WEAPON_ORDER, drawWeapon, drawCrosshair, drawMinimap, drawStatusBar, drawLogo, applyScreenEffects,
5
+ } from './hud.js';
6
+ import { mulberry32 } from './util.js';
7
+ import { GLYPH_MODES } from './screen.js';
8
+
9
+ const MOVE_SPEED = 3.4;
10
+ const TURN_SPEED = 2.7;
11
+ const PLAYER_R = 0.22;
12
+ const MAX_AMMO = { shells: 40, cells: 150 };
13
+
14
+ export class Game {
15
+ constructor({ screen, input, seed = Date.now() & 0xffffff, storage = null }) {
16
+ this.screen = screen;
17
+ this.input = input;
18
+ this.seed = seed;
19
+ this.storage = storage;
20
+ this.high = storage ? storage.load() : 0;
21
+ this.state = 'title';
22
+ this.time = 0;
23
+ this.quit = false;
24
+ this.showMap = true;
25
+ this.message = { text: '', t: 0 };
26
+ this.hurt = 0;
27
+ this.pickFlash = 0;
28
+ this.fade = 0;
29
+ this.shake = 0;
30
+ this.seenTimer = 0;
31
+ this.demo = new World(1, mulberry32(seed ^ 0x5bd1e995));
32
+ this.resetPlayer();
33
+ this.level = 1;
34
+ this.score = 0;
35
+ this.world = this.demo;
36
+ }
37
+
38
+ resetPlayer() {
39
+ this.player = {
40
+ x: 0, y: 0, a: 0, vF: 0, vS: 0, vT: 0, hp: 100, weapon: 'pistol',
41
+ owned: new Set(['pistol']), ammo: { shells: 0, cells: 0 },
42
+ cool: 0, flash: 0, kick: 0, bob: 0, autofire: false, kills: 0,
43
+ };
44
+ }
45
+
46
+ startRun() {
47
+ this.resetPlayer();
48
+ this.score = 0;
49
+ this.runKills = 0;
50
+ this.loadLevel(1);
51
+ this.state = 'play';
52
+ }
53
+
54
+ loadLevel(n) {
55
+ this.level = n;
56
+ this.world = new World(n, mulberry32(this.seed + n * 7919));
57
+ this.world.placeWeapons(this.player.owned);
58
+ const p = this.player;
59
+ p.x = this.world.start.x;
60
+ p.y = this.world.start.y;
61
+ let best = -1;
62
+ for (let i = 0; i < 16; i++) {
63
+ const a = (i / 16) * Math.PI * 2;
64
+ const d = this.world.cast(p.x, p.y, Math.cos(a), Math.sin(a), {}, 30);
65
+ if (d > best) { best = d; p.a = a; }
66
+ }
67
+ p.vF = p.vS = p.vT = 0;
68
+ this.fade = 1;
69
+ this.levelStart = this.time;
70
+ this.say(`LEVEL ${n} - ELIMINATE ALL HOSTILES`, 3.5);
71
+ }
72
+
73
+ say(text, secs = 2.5) {
74
+ this.message = { text, t: secs };
75
+ }
76
+
77
+ damagePlayer(n) {
78
+ const p = this.player;
79
+ if (p.hp <= 0 || this.state !== 'play') return;
80
+ p.hp -= n;
81
+ this.hurt = Math.min(1, this.hurt + 0.45 + n / 50);
82
+ this.shake = 0.35;
83
+ if (p.hp <= 0) {
84
+ p.hp = 0;
85
+ this.state = 'dead';
86
+ this.deadAt = this.time;
87
+ if (this.score > this.high) {
88
+ this.high = this.score;
89
+ if (this.storage) this.storage.save(this.high);
90
+ }
91
+ }
92
+ }
93
+
94
+ onKill(e) {
95
+ this.score += e.def.score;
96
+ this.runKills = (this.runKills || 0) + 1;
97
+ if (this.world.aliveEnemies() === 0) {
98
+ this.world.exit.open = true;
99
+ this.say('ALL HOSTILES DOWN - PORTAL OPEN. REACH THE EXIT!', 4);
100
+ }
101
+ }
102
+
103
+ setWeapon(id) {
104
+ const p = this.player;
105
+ if (!p.owned.has(id) || p.weapon === id) return;
106
+ p.weapon = id;
107
+ p.cool = Math.max(p.cool, 0.15);
108
+ }
109
+
110
+ cycleWeapon() {
111
+ const p = this.player;
112
+ const owned = WEAPON_ORDER.filter((w) => p.owned.has(w));
113
+ this.setWeapon(owned[(owned.indexOf(p.weapon) + 1) % owned.length]);
114
+ }
115
+
116
+ tryFire() {
117
+ const p = this.player;
118
+ const w = WEAPONS[p.weapon];
119
+ const world = this.world;
120
+ if (w.ammo) {
121
+ if (p.ammo[w.ammo] <= 0) {
122
+ this.say(`OUT OF ${w.ammo.toUpperCase()}`, 1.2);
123
+ p.weapon = 'pistol';
124
+ p.cool = 0.3;
125
+ return;
126
+ }
127
+ p.ammo[w.ammo]--;
128
+ }
129
+ p.cool = w.cd;
130
+ p.flash = 0.08;
131
+ p.kick = 1;
132
+ world.alertNear(p.x, p.y, w.noise);
133
+ if (w.projectile) {
134
+ const a = p.a + (world.rng() - 0.5) * w.spread * 2;
135
+ world.fireProjectile('player', p.x + Math.cos(a) * 0.3, p.y + Math.sin(a) * 0.3, a, 15, w.dmg, 0x40e0ff, 0.12);
136
+ return;
137
+ }
138
+ for (let i = 0; i < w.pellets; i++) {
139
+ const a = p.a + (world.rng() - 0.5) * 2 * w.spread;
140
+ const hit = world.hitscan(p.x, p.y, a, 40);
141
+ if (hit.enemy) {
142
+ const falloff = w.pellets > 1 ? 1 - Math.min(0.7, hit.dist / 14) : 1;
143
+ world.damageEnemy(hit.enemy, w.dmg * falloff, this);
144
+ } else {
145
+ world.burst(hit.hx - Math.cos(a) * 0.05, hit.hy - Math.sin(a) * 0.05, 0.5, 0xffd060, 2, 1.5, 0.25);
146
+ }
147
+ }
148
+ }
149
+
150
+ collectPickups() {
151
+ const p = this.player;
152
+ const world = this.world;
153
+ world.pickups = world.pickups.filter((pk) => {
154
+ if (Math.hypot(pk.x - p.x, pk.y - p.y) > 0.6) return true;
155
+ switch (pk.kind) {
156
+ case 'health':
157
+ if (p.hp >= 100) return true;
158
+ p.hp = Math.min(100, p.hp + 30);
159
+ break;
160
+ case 'shells':
161
+ p.ammo.shells = Math.min(MAX_AMMO.shells, p.ammo.shells + 8);
162
+ break;
163
+ case 'cells':
164
+ p.ammo.cells = Math.min(MAX_AMMO.cells, p.ammo.cells + 30);
165
+ break;
166
+ case 'shotgun':
167
+ p.owned.add('shotgun');
168
+ p.ammo.shells = Math.min(MAX_AMMO.shells, p.ammo.shells + 8);
169
+ p.weapon = 'shotgun';
170
+ break;
171
+ case 'plasma':
172
+ p.owned.add('plasma');
173
+ p.ammo.cells = Math.min(MAX_AMMO.cells, p.ammo.cells + 30);
174
+ p.weapon = 'plasma';
175
+ break;
176
+ }
177
+ this.pickFlash = 0.6;
178
+ this.say(world.pickupMessage(pk.kind), 1.8);
179
+ return false;
180
+ });
181
+ }
182
+
183
+ markSeen() {
184
+ const { world, player: p } = this;
185
+ const out = {};
186
+ for (let i = 0; i < 72; i++) {
187
+ const a = (i / 72) * Math.PI * 2;
188
+ const dx = Math.cos(a);
189
+ const dy = Math.sin(a);
190
+ const d = Math.min(world.cast(p.x, p.y, dx, dy, out, 14), 14);
191
+ for (let t = 0; t < d; t += 0.4) world.seen[Math.floor(p.y + dy * t) * world.w + Math.floor(p.x + dx * t)] = 1;
192
+ if (d < 14) world.seen[out.my * world.w + out.mx] = 1;
193
+ }
194
+ }
195
+
196
+ update(dt) {
197
+ this.time += dt;
198
+ const inp = this.input;
199
+ this.message.t -= dt;
200
+ this.hurt = Math.max(0, this.hurt - dt * 1.8);
201
+ this.pickFlash = Math.max(0, this.pickFlash - dt * 2);
202
+ this.fade = Math.max(0, this.fade - dt * 1.6);
203
+ this.shake = Math.max(0, this.shake - dt * 1.5);
204
+
205
+ if (inp.wasKey('ctrl-c')) this.quit = true;
206
+ if (inp.wasKey('g')) {
207
+ const modes = Object.keys(GLYPH_MODES);
208
+ const next = modes[(modes.indexOf(this.screen.glyphMode) + 1) % modes.length];
209
+ this.screen.setGlyphMode(next);
210
+ this.say(`RENDER MODE: ${next.toUpperCase()}`, 1.5);
211
+ }
212
+
213
+ switch (this.state) {
214
+ case 'title':
215
+ if (inp.wasPressed('enter')) this.startRun();
216
+ else if (inp.wasKey('q')) this.quit = true;
217
+ break;
218
+ case 'play':
219
+ this.updatePlay(dt);
220
+ break;
221
+ case 'pause':
222
+ if (inp.wasPressed('pause')) this.state = 'play';
223
+ else if (inp.wasKey('q')) this.state = 'title';
224
+ break;
225
+ case 'dead':
226
+ this.world.update(dt, this.player, this);
227
+ if (this.time - this.deadAt > 0.8) {
228
+ if (inp.wasPressed('enter')) this.startRun();
229
+ else if (inp.wasKey('q')) this.state = 'title';
230
+ }
231
+ break;
232
+ case 'clear':
233
+ if (inp.wasPressed('enter')) {
234
+ const p = this.player;
235
+ p.hp = Math.min(100, p.hp + 25);
236
+ p.ammo.shells = Math.min(MAX_AMMO.shells, p.ammo.shells + 4);
237
+ p.ammo.cells = Math.min(MAX_AMMO.cells, p.ammo.cells + 15);
238
+ this.loadLevel(this.level + 1);
239
+ this.state = 'play';
240
+ }
241
+ break;
242
+ }
243
+ }
244
+
245
+ updatePlay(dt) {
246
+ const inp = this.input;
247
+ const p = this.player;
248
+ const world = this.world;
249
+
250
+ if (inp.wasPressed('pause')) { this.state = 'pause'; return; }
251
+ if (inp.wasKey('1')) this.setWeapon('pistol');
252
+ if (inp.wasKey('2')) this.setWeapon('shotgun');
253
+ if (inp.wasKey('3')) this.setWeapon('plasma');
254
+ if (inp.wasKey('tab')) this.cycleWeapon();
255
+ if (inp.wasKey('f')) { p.autofire = !p.autofire; this.say(p.autofire ? 'AUTOFIRE ON' : 'AUTOFIRE OFF', 1.2); }
256
+ if (inp.wasKey('m')) this.showMap = !this.showMap;
257
+
258
+ const axis = (pos, neg) => (inp.held(pos) ? 1 : 0) - (inp.held(neg) ? 1 : 0);
259
+ const ka = 1 - Math.exp(-dt * 16);
260
+ p.vF += (axis('fwd', 'back') - p.vF) * ka;
261
+ p.vS += (axis('strafeR', 'strafeL') - p.vS) * ka;
262
+ p.vT += (axis('turnR', 'turnL') - p.vT) * (1 - Math.exp(-dt * 20));
263
+ p.a += p.vT * TURN_SPEED * dt;
264
+
265
+ const dx = Math.cos(p.a);
266
+ const dy = Math.sin(p.a);
267
+ let mx = dx * p.vF - dy * p.vS;
268
+ let my = dy * p.vF + dx * p.vS;
269
+ const ml = Math.hypot(mx, my);
270
+ if (ml > 1) { mx /= ml; my /= ml; }
271
+ world.tryMove(p, mx * MOVE_SPEED * dt, my * MOVE_SPEED * dt, PLAYER_R);
272
+ const speed = Math.min(1, ml);
273
+ p.bob += dt * (5 + speed * 6) * (speed > 0.05 ? 1 : 0);
274
+
275
+ p.cool -= dt;
276
+ p.flash = Math.max(0, p.flash - dt);
277
+ p.kick = Math.max(0, p.kick - dt * 7);
278
+
279
+ const aim = world.hitscan(p.x, p.y, p.a, 30, 0.1);
280
+ this.onTarget = !!aim.enemy;
281
+ const wantFire = inp.held('fire') || (p.autofire && this.onTarget);
282
+ if (wantFire && p.cool <= 0) this.tryFire();
283
+
284
+ world.update(dt, p, this);
285
+ if (this.state !== 'play') return;
286
+ this.collectPickups();
287
+
288
+ this.seenTimer -= dt;
289
+ if (this.seenTimer <= 0) { this.seenTimer = 0.1; this.markSeen(); }
290
+
291
+ if (world.exit.open && Math.hypot(world.exit.x - p.x, world.exit.y - p.y) < 0.8) {
292
+ const bonus = 500 * this.level + Math.round(p.hp) * 5;
293
+ this.levelBonus = bonus;
294
+ this.score += bonus;
295
+ if (this.score > this.high) {
296
+ this.high = this.score;
297
+ if (this.storage) this.storage.save(this.high);
298
+ }
299
+ this.state = 'clear';
300
+ }
301
+ }
302
+
303
+ panel(lines, color = 0xffffff, dim = 0.45) {
304
+ const s = this.screen;
305
+ const w = Math.max(...lines.map((l) => l.text.length)) + 6;
306
+ const h = lines.length + 2;
307
+ const x0 = Math.floor((s.cols - w) / 2);
308
+ const y0 = Math.floor((s.rows - h) / 2);
309
+ s.dimRect(x0 * s.sx, y0 * s.sy, w * s.sx, h * s.sy, dim);
310
+ lines.forEach((l, i) => s.textCenter(y0 + 1 + i, l.text, l.color || color));
311
+ }
312
+
313
+ render() {
314
+ const s = this.screen;
315
+ s.clearOverlay();
316
+ const p = this.player;
317
+ const time = this.time;
318
+
319
+ if (this.state === 'title') {
320
+ const room = this.demo.rooms[0];
321
+ const cam = { x: room.cx + 0.5, y: room.cy + 0.5, a: time * 0.35, horizon: s.ph / 2, flash: 0 };
322
+ renderScene(s, this.demo, cam, time);
323
+ s.dimRect(0, 0, s.pw, s.ph, 0.7);
324
+ this.renderTitle();
325
+ return;
326
+ }
327
+
328
+ const shake = this.shake > 0 ? (Math.random() - 0.5) * this.shake * 4 : 0;
329
+ const horizon = s.ph / 2 + Math.sin(p.bob * 2) * 0.8 * Math.min(1, Math.hypot(p.vF, p.vS)) + shake;
330
+ const cam = { x: p.x, y: p.y, a: p.a, horizon, flash: p.flash > 0 ? p.flash / 0.08 : 0 };
331
+ renderScene(s, this.world, cam, time);
332
+
333
+ if (this.state !== 'dead') {
334
+ const mv = Math.min(1, Math.hypot(p.vF, p.vS));
335
+ drawWeapon(s, p.weapon, {
336
+ swayX: Math.sin(p.bob) * 2.2 * mv,
337
+ swayY: Math.abs(Math.cos(p.bob)) * 1.4 * mv,
338
+ kick: p.kick, flash: p.flash, time,
339
+ });
340
+ }
341
+ applyScreenEffects(s, this);
342
+ if (this.showMap) drawMinimap(s, this.world, p);
343
+ if (this.state === 'play' || this.state === 'pause') drawCrosshair(s, this.onTarget);
344
+ drawStatusBar(s, this);
345
+
346
+ if (this.message.t > 0 && this.state === 'play') {
347
+ s.textCenter(2, ` ${this.message.text} `, 0xfff0a0);
348
+ }
349
+
350
+ if (this.state === 'pause') {
351
+ this.panel([
352
+ { text: 'PAUSED', color: 0x60e0ff },
353
+ { text: '' },
354
+ { text: 'ESC / P resume' },
355
+ { text: 'Q quit to title' },
356
+ ]);
357
+ } else if (this.state === 'dead') {
358
+ s.dimRect(0, 0, s.pw, s.ph, 0.6);
359
+ const ready = this.time - this.deadAt > 0.8;
360
+ this.panel([
361
+ { text: 'YOU DIED', color: 0xff4050 },
362
+ { text: '' },
363
+ { text: `LEVEL ${this.level} SCORE ${this.score}` },
364
+ { text: `BEST ${this.high}`, color: 0xffd070 },
365
+ { text: '' },
366
+ { text: ready ? 'ENTER try again Q title' : '', color: 0xa0a8c0 },
367
+ ], 0xffffff, 0.3);
368
+ } else if (this.state === 'clear') {
369
+ this.panel([
370
+ { text: `LEVEL ${this.level} CLEARED`, color: 0x60ffa0 },
371
+ { text: '' },
372
+ { text: `BONUS +${this.levelBonus}` },
373
+ { text: `SCORE ${this.score} BEST ${this.high}` },
374
+ { text: '' },
375
+ { text: 'ENTER next level', color: 0xa0a8c0 },
376
+ ]);
377
+ }
378
+ }
379
+
380
+ renderTitle() {
381
+ const s = this.screen;
382
+ const top = Math.max(1, Math.floor(s.rows / 2) - 9);
383
+ drawLogo(s, top, 'NEON BREACH', this.time);
384
+ s.textCenter(top + 6, 'A TERMINAL FIRST-PERSON SHOOTER', 0xa0a8c0);
385
+ if (Math.floor(this.time * 2) % 2 === 0) s.textCenter(top + 8, '[ PRESS ENTER TO START ]', 0xfff0a0);
386
+ const help = [
387
+ 'W/S or UP/DOWN move A/D strafe LEFT/RIGHT or Q/E turn',
388
+ 'SPACE or X fire 1 2 3 / TAB weapons F autofire M map',
389
+ 'P or ESC pause G sharpness mode CTRL+C quit',
390
+ ];
391
+ help.forEach((l, i) => s.textCenter(top + 11 + i, l, 0x8890b0));
392
+ if (this.message.t > 0) s.textCenter(top + 16, this.message.text, 0xfff0a0);
393
+ if (this.high > 0) s.textCenter(top + 15, `BEST SCORE ${this.high}`, 0xffd070);
394
+ }
395
+ }