-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrebels.js
More file actions
1665 lines (1630 loc) · 120 KB
/
Copy pathrebels.js
File metadata and controls
1665 lines (1630 loc) · 120 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* PERCONA REBELS — freedom fighters for open source. A 1980s-style vertical shooter for ARCADE.
Plain canvas + Web Audio. No framework, no build step, no network calls; works from disk.
To change the line-up, edit ENEMIES, BOSSES and POWERUPS right below. */
(() => {
"use strict";
/* ================================================================== CONFIG — edit me */
// The enemies are practices open source users push back on, not companies: no logos, no names, no one's project.
// Waves feature them two at a time, in this order.
// id file name for an optional local sprite: web/rebels-assets/<id>.png (folder is gitignored)
// name full name, shown on the wave card and the attract screen
// tag short label shown under an enemy while it dives
// color main colour of the built-in sprite (shades are derived from it)
// hp hits needed to destroy one
// points score for a kill in formation (a kill while it dives scores double)
// pattern dive pattern: swoop | dive | zigzag | loop | spiral
// sprite built-in art: badge | coin | clipboard | chip | calendar | cube | paywall (practices), or the creatures
// crab | jelly | hornet | dart | cloud | orb | urchin | manta
// ability optional mechanic: flip (friendly in formation, hostile when it dives; color2 is its hostile colour),
// drain (eats your score while it dives), jam (its shots jam your guns instead of killing),
// split (breaks into two cores), renew (comes back once; a smart bomb ends it for good),
// stealth (nearly invisible unless PMM is on), armor (PXC spread and wingmen hit harder)
// hint one line on the attract screen explaining the ability
// quip one line for the wave card. Keep it playful, never insulting.
const ENEMIES = [
{ id: "relicenser", name: "The Relicenser", tag: "RELICENSER", color: "#35c46a", color2: "#f2455c", hp: 1, points: 100, pattern: "swoop", sprite: "badge", ability: "flip", hint: "Friendly until it dives", quip: "Open source until it wasn't." },
{ id: "egress-fee", name: "Egress Fee", tag: "EGRESS FEE", color: "#f4c542", hp: 1, points: 120, pattern: "zigzag", sprite: "coin", ability: "drain", hint: "Drains your score while it dives", quip: "Your data can leave any time. For a fee." },
{ id: "surprise-audit", name: "Surprise Audit", tag: "AUDIT", color: "#e2cfa0", hp: 2, points: 150, pattern: "dive", sprite: "clipboard", ability: "jam", hint: "Its paperwork jams your guns", quip: "Just a routine check. Every year." },
{ id: "core-tax", name: "Core Tax", tag: "CORE TAX", color: "#3fd0e0", hp: 2, points: 150, pattern: "spiral", sprite: "chip", ability: "split", hint: "Splits into two cores when destroyed", quip: "Priced per core. You have many cores." },
{ id: "auto-renewal", name: "Auto-Renewal", tag: "RENEWAL", color: "#ff7ac6", hp: 2, points: 160, pattern: "loop", sprite: "calendar", ability: "renew", hint: "Comes back once. A smart bomb ends it", quip: "Renewed for another three years." },
{ id: "black-box", name: "Black Box", tag: "BLACK BOX", color: "#6a6484", hp: 2, points: 170, pattern: "swoop", sprite: "cube", ability: "stealth", hint: "Nearly invisible. PMM radar reveals it", quip: "No, you can't see the source." },
{ id: "enterprise-only", name: "Enterprise-Only Feature", tag: "PAYWALL", color: "#c8643b", hp: 4, points: 200, pattern: "dive", sprite: "paywall", ability: "armor", hint: "Armoured. PXC spread shots break it", quip: "Available in the Platinum tier." },
];
// Bosses take turns every RULES.bossEvery waves. hp and points grow with each encounter. style picks the art and attacks.
const BOSSES = [
{ id: "vendor-lock-in", name: "Vendor Lock-In", tag: "VENDOR LOCK-IN", color: "#8f96ad", hp: 110, points: 5000, style: "lock", quip: "Easy to get into. Try getting out.",
intro: "BREAK THE LOCK!", phase2: ["CONTRACT RENEWAL", "NEW TERMS INCOMING"], phase3: ["THE LOCK IS BREAKING", "KEEP FIRING!"], win: ["LOCK-IN BROKEN!", "THE WAY IS OPEN"] },
{ id: "patent-troll", name: "Patent Troll", tag: "PATENT TROLL", color: "#6fae4f", hp: 120, points: 6000, style: "troll", quip: "I own the concept of rows.",
intro: "PRIOR ART, ENGAGE!", phase2: ["CEASE AND DESIST", "HERE COME THE LAWYERS"], phase3: ["CLAIMS REJECTED", "THE PATENT IS CRUMBLING"], win: ["PATENT INVALIDATED!", "PRIOR ART WINS"] },
];
const bossFor = (wave) => BOSSES[(Math.max(1, Math.floor(wave / RULES.bossEvery)) - 1) % BOSSES.length];
// Power-ups, themed on Percona products. weight = how often it drops, duration in seconds.
// icon = one of PRODUCT_ICONS below (Percona product icons); without one the drop is a lettered capsule.
const POWERUPS = [
{ id: "pmm", label: "PMM", name: "PMM", color: "#9d8cff", icon: "pmm", iconColor: "#653df4", weight: 3, duration: 15, desc: "Radar. See every dive before it starts.", banner: "PMM RADAR", sub: "EYES ON EVERY DIVE" },
{ id: "xtrabackup", label: "PXB", name: "XtraBackup", color: "#35c46a", weight: 2, desc: "Shield. Grab another for an extra life.", banner: "XTRABACKUP", sub: "SHIELD UP. YOU'RE BACKED UP", subLife: "RESTORED FROM BACKUP: +1 LIFE" },
{ id: "operator", label: "OP", name: "Percona Operator", color: "#2aa6df", icon: "operator", iconColor: "#2aa6df", weight: 2, duration: 20, desc: "Deploys two wingman drones.", banner: "PERCONA OPERATOR", sub: "2 REPLICAS DEPLOYED" },
{ id: "pxc", label: "PXC", name: "PXC", color: "#ea6525", icon: "pxc", iconColor: "#ea6525", weight: 3, duration: 15, desc: "Triple spread shot. Three nodes, three shots.", banner: "PXC", sub: "3-NODE SPREAD SHOT" },
{ id: "toolkit", label: "PT", name: "Percona Toolkit", color: "#ff5c7a", weight: 1, desc: "Smart bomb. Hits everything on screen.", banner: "PERCONA TOOLKIT", sub: "PT-KILL! SMART BOMB" },
];
const RULES = { startLives: 3, maxLives: 9, extraLifeEvery: 20000, bossEvery: 5, dropChance: 0.07, maxCombo: 8, comboStep: 5, comboWindow: 2.0 };
// Default high-score table (initials only: the game never ranks people from the roster).
const DEFAULT_SCORES = [["PXC", 30000, 10], ["PMM", 25000, 9], ["PXB", 20000, 8], ["OPS", 15000, 7], ["PTK", 12000, 6], ["SQL", 9000, 5], ["DBA", 6000, 4], ["OSS", 4000, 3], ["YAY", 2000, 2], ["GUD", 1000, 1]];
// Local sprite hook: web/rebels-assets/<id>.png replaces that enemy's built-in art. The browser can't check whether a
// file exists without requesting it (and logging a 404), so it is opt-in: set this to true, or open the page once with
// ?sprites (remembered in this browser; ?nosprites turns it off). The single-file build inlines any PNGs it finds automatically.
const LOCAL_SPRITES = false;
// Where Esc / the back link goes, as shown on screen. The public single-file build sets it to "PERCONA.COM".
const BACK_NAME = String(window.REBELS_BACK_NAME || "ARCADE").toUpperCase();
// Public builds: Esc (and the gamepad's Back button) only pause and resume; the way out is the link shown while paused.
const PUBLIC = !!window.REBELS_PUBLIC;
/* ================================================================== basics */
const W = 224, H = 288, STEP = 1 / 60; // Galaga's own resolution, portrait
const C = { yellow: "#f6fe54", purple: "#653df4", lilac: "#9d8cff", white: "#f4f2f8", dim: "#a9a5b8", faint: "#6f6b7d", red: "#ff5c7a", teal: "#22c1a5", orange: "#ff9f43", bg: "#07060b", ink: "#140c28" };
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const rnd = (a = 1, b) => (b === undefined ? Math.random() * a : a + Math.random() * (b - a));
const pick = (a) => a[(Math.random() * a.length) | 0];
const bez = (p0, p1, p2, p3, t) => { const u = 1 - t; return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3; };
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
const pad = (n, l = 6) => String(Math.max(0, Math.floor(n))).padStart(l, "0");
const norm = (s) => String(s ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "");
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const $ = (s) => document.querySelector(s);
const store = {
get(k, d) { try { const v = localStorage.getItem("arcade.rebels." + k); return v === null ? d : JSON.parse(v); } catch { return d; } },
set(k, v) { try { localStorage.setItem("arcade.rebels." + k, JSON.stringify(v)); } catch { /* private mode */ } },
};
const motionQuery = matchMedia("(prefers-reduced-motion: reduce)");
let reduced = motionQuery.matches;
motionQuery.addEventListener?.("change", (e) => (reduced = e.matches));
const asset = (path) => (window.ARCADE_ASSETS && window.ARCADE_ASSETS[path]) || path; // the single-file build inlines images here
function makeCanvas(w, h) { const c = document.createElement("canvas"); c.width = w; c.height = h; return c; }
// every offscreen canvas stays CPU-backed: they are all small, and mixing GPU and CPU canvases costs a readback per draw
const ctx2d = (c) => c.getContext("2d", { willReadFrequently: true });
const colorCtx = makeCanvas(1, 1).getContext("2d", { willReadFrequently: true });
function rgbOf(css) { colorCtx.clearRect(0, 0, 1, 1); colorCtx.fillStyle = "#000"; colorCtx.fillStyle = css; colorCtx.fillRect(0, 0, 1, 1); const d = colorCtx.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2]]; }
const hex = (c) => "#" + c.map((v) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0")).join("");
const mix = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
function hexToHsl(hx) {
const n = parseInt(hx.slice(1), 16); const r = (n >> 16) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s = 0; const l = (max + min) / 2;
if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; }
return [h, s * 100, l * 100];
}
/* ================================================================== canvases */
const buf = makeCanvas(W, H); // the low-res frame everything is drawn into
const g = buf.getContext("2d", { willReadFrequently: true }); // CPU-backed: cheap to upload to WebGL every frame
g.imageSmoothingEnabled = false;
const stage = $("#stage"), view = $("#view"), crtCanvas = $("#crt");
const vctx = view.getContext("2d");
/* ================================================================== fonts: 8x8 from Press Start 2P, 3x5 built in */
const SMALL = {
A: "25755", B: "65656", C: "34443", D: "65556", E: "74647", F: "74644", G: "34553", H: "55755", I: "72227", J: "11152", K: "55655", L: "44447", M: "57755",
N: "65555", O: "25552", P: "65644", Q: "25563", R: "65655", S: "34216", T: "72222", U: "55557", V: "55552", W: "55775", X: "55255", Y: "55222", Z: "71247",
0: "75557", 1: "26227", 2: "61247", 3: "61216", 4: "55711", 5: "74616", 6: "34757", 7: "71222", 8: "75757", 9: "75716",
" ": "00000", "-": "00700", ".": "00002", ",": "00024", "!": "22202", "?": "61202", "/": "11244", ":": "02020", "+": "02720", "'": "22000", '"': "55000",
"(": "12221", ")": "42224", "%": "51245", "&": "25253", "=": "07070", "<": "12421", ">": "42124", "#": "57575", "*": "05250", "_": "00007",
};
const Font = { big: null, small: null, smallIndex: {}, cache: new Map() };
function buildSmallFont() {
const chars = Object.keys(SMALL); const c = makeCanvas(chars.length * 4, 5); const x = ctx2d(c); x.fillStyle = "#fff";
chars.forEach((ch, i) => { Font.smallIndex[ch] = i; for (let r = 0; r < 5; r++) { const bits = +SMALL[ch][r]; for (let b = 0; b < 3; b++) if (bits & (4 >> b)) x.fillRect(i * 4 + b, r, 1, 1); } });
Font.small = c;
}
function buildBigFont(usePS2P) {
const c = makeCanvas(95 * 8, 8); const x = c.getContext("2d", { willReadFrequently: true });
if (usePS2P) {
const tmp = makeCanvas(95 * 8, 12); const t = tmp.getContext("2d", { willReadFrequently: true });
t.font = '8px "Press Start 2P"'; t.textBaseline = "top"; t.fillStyle = "#fff";
for (let i = 32; i < 127; i++) t.fillText(String.fromCharCode(i), (i - 32) * 8, 0);
const d = t.getImageData(0, 0, tmp.width, tmp.height); const a = d.data;
let top = 12; // align so that the cap height starts on row 0
for (let y = 0; y < 12 && top === 12; y++) for (let xx = ("H".charCodeAt(0) - 32) * 8; xx < ("H".charCodeAt(0) - 31) * 8; xx++) if (a[(y * tmp.width + xx) * 4 + 3] > 100) { top = y; break; }
if (top === 12) top = 0;
for (let p = 0; p < a.length; p += 4) { const on = a[p + 3] > 100; a[p] = a[p + 1] = a[p + 2] = 255; a[p + 3] = on ? 255 : 0; }
x.putImageData(d, 0, -top);
} else {
// fallback: stretch the 3x5 font into the 8x8 grid
x.fillStyle = "#fff"; const rowMap = [0, 1, 1, 2, 3, 3, 4];
for (let i = 32; i < 127; i++) { const rows = SMALL[String.fromCharCode(i).toUpperCase()]; if (!rows) continue; for (let r = 0; r < 7; r++) { const bits = +rows[rowMap[r]]; for (let b = 0; b < 3; b++) if (bits & (4 >> b)) x.fillRect((i - 32) * 8 + 1 + b * 2, r, 2, 1); } }
}
Font.big = c; Font.cache.clear();
}
function tinted(which, color) {
const key = which + color; let c = Font.cache.get(key);
if (!c) { const src = Font[which]; c = makeCanvas(src.width, src.height); const x = ctx2d(c); x.drawImage(src, 0, 0); x.globalCompositeOperation = "source-in"; x.fillStyle = color; x.fillRect(0, 0, c.width, c.height); Font.cache.set(key, c); }
return c;
}
const textWidth = (s, o = {}) => { const n = String(s).length, sc = o.scale || 1; return o.small ? (n * 4 - 1) * sc : n * 8 * sc; };
// text(str, x, y, colour, {small, scale, align: left|center|right, shadow})
function text(s, x, y, color = C.white, o = {}) {
s = String(s); if (o.small) s = norm(s).toUpperCase();
const sc = o.scale || 1, w = textWidth(s, o);
if (o.align === "center") x = Math.round(x - w / 2); else if (o.align === "right") x = Math.round(x - w);
x = Math.round(x); y = Math.round(y);
if (o.shadow) text(s, x + sc, y + sc, o.shadow, { small: o.small, scale: sc });
const atlas = tinted(o.small ? "small" : "big", color);
for (let i = 0; i < s.length; i++) {
if (o.small) { const idx = Font.smallIndex[s[i]]; if (idx !== undefined) g.drawImage(atlas, idx * 4, 0, 3, 5, x + i * 4 * sc, y, 3 * sc, 5 * sc); }
else { const code = s.charCodeAt(i); if (code > 32 && code < 127) g.drawImage(atlas, (code - 32) * 8, 0, 8, 8, x + i * 8 * sc, y, 8 * sc, 8 * sc); }
}
return w;
}
function wrap(s, maxChars) {
const out = []; let line = "";
for (const word of String(s).split(/\s+/)) { if ((line + " " + word).trim().length > maxChars && line) { out.push(line); line = word; } else line = (line + " " + word).trim(); }
if (line) out.push(line); return out;
}
const blink = (rate = 2, t = G.t) => Math.floor(t * (reduced ? Math.min(rate, 1) : rate)) % 2 === 0;
/* ================================================================== sprites (original pixel art; left half + centre column, mirrored) */
const SPR = {
crab: [["l.....", "a...a.", "a..aaa", "aaallw", "aadkaa", ".aaaaa", "..dada", ".d....", "d....."],
["......", "....a.", "l..aaa", "aaallw", "aadkaa", "aaaaaa", "..dada", "..d...", "...d.."]],
jelly: [["...aaa", "..alll", ".aalll", ".akaaa", ".aaaaa", ".d.d.d", ".d.d.d", "d..d..", "......"],
["...aaa", "..alll", ".aalll", ".akaaa", ".aaaaa", "..d.d.", ".d.d.d", "..d..d", "......"]],
hornet: [["ll....", "lll...", ".llaaa", "..akaa", "..dddd", "..aaaa", "...ddd", "....aa", ".....w"],
["......", "......", "..laaa", "llakaa", "lllddd", "..aaaa", "...ddd", "....aa", ".....w"]],
dart: [["a....y", "aa...l", "daa..l", ".daaal", "..daaw", "...daa", "....da", ".....a", "......"],
["l.....", "aa...l", "daa..l", ".daaal", "..daak", "...daa", "....da", ".....a", "......"]],
cloud: [["......", "...ll.", "..llll", ".lllll", "llklll", "llllll", ".aaaaa", "..a..a", ".a..a."],
["......", "...ll.", "..llll", ".lllll", "llklll", "llllll", ".aaaaa", ".a..a.", "..a..a"]],
orb: [["....aa", "..aall", ".aalll", "wwwwww", "aaakaa", ".aaaaa", "..daaa", "....dd", "......"],
["....aa", "..aall", ".aalll", "aaakaa", "wwwwww", ".aaaaa", "..daaa", "....dd", "......"]],
urchin: [["a....a", ".a...a", "..aaaa", ".aakll", "aaaaaa", ".aaddd", "..aaaa", ".a...a", "a....a"],
[".....a", "a....a", ".a.aaa", "..akll", "aaaaaa", "..addd", ".a.aaa", "a....a", ".....a"]],
manta: [["a.....", "aa....", "aaa.aa", "daaaal", ".dakaa", "..dddd", ".....d", ".....d", ".....a"],
["......", "......", "a...aa", "aaaaal", "ddakaa", "..dddd", ".....d", ".....d", ".....a"]],
};
// Practice enemies (11x9). Six-character rows are a left half plus the centre column, mirrored; 11-character rows are drawn as is.
Object.assign(SPR, {
badge: [["..aaaaaaa..", ".allllllla.", ".alllllkla.", ".allllklla.", ".akllkllla.", ".alkklllla.", "..allllla..", "...aalaa...", ".....a....."],
["..aaaaaaa..", ".wllllllla.", ".alllllkla.", ".allllklla.", ".akllkllla.", ".alkklllla.", "..allllla..", "...aalaa...", ".....a....."]],
badgebad: [["..aaaa", ".allll", ".alkll", ".allkl", ".alllk", ".allkl", "..alkl", "...aal", ".....a"],
["..aaaa", ".allll", ".alkll", ".allkl", ".alllk", ".allkl", "..alkl", "...aal", ".....w"]],
coin: [["...aaa", "..alll", "d.alkk", "ddalll", "d.alll", "..adll", "...aaa", "......", "......"],
["...aaa", "..alll", ".dalkk", "d.alll", "..alll", "..adll", "...aaa", "......", "......"]],
clipboard: [["....dd", ".ddddd", ".dllll", ".dlkkl", ".dllll", ".dlkkk", ".dllll", ".dllll", ".ddddd"],
["....dd", ".ddddd", ".dllll", ".dllkl", ".dllll", ".dlkkk", ".dllll", ".dllll", ".ddddd"]],
chip: [[".d.d.d", "aaaaaa", "dallll", "alkkkk", "dalkky", "alkkkk", "dallll", "aaaaaa", ".d.d.d"],
[".d.d.d", "aaaaaa", "dallll", "alkkkk", "dalkkw", "alkkkk", "dallll", "aaaaaa", ".d.d.d"]],
calendar: [["..k...", "aakaaa", "aaaaaa", "wwwwww", "wkwkwk", "wwwwww", "wkwkwk", "wwwwww", ".ddddd"],
["..k...", "llklll", "aaaaaa", "wwwwww", "wkwkwk", "wwwwww", "wkwkwr", "wwwwww", ".ddddd"]],
cube: [["...lllll...", ".lllllllll.", "lllllllllll", "aaaaawddddd", "aaaaawddddd", "aaraawddrdd", "aaaaawddddd", ".aaaawdddd.", "...aawdd..."],
["...lllll...", ".lllllllll.", "lllllllllll", "aaaaawddddd", "aaaaawddddd", "aayaawddydd", "aaaaawddddd", ".aaaawdddd.", "...aawdd..."]],
paywall: [["dddddd", "llldll", "dddddd", "ldllld", "dddddd", "llldyy", "dddddd", "ldllld", "dddddd"],
["dddddd", "llldll", "dddddd", "ldllld", "dddddd", "llldww", "dddddd", "ldllld", "dddddd"]],
core: [[".d.d.", "dakad", "akyka", "dakad", ".d.d."], [".d.d.", "dakad", "akwka", "dakad", ".d.d."]],
});
const DRONE = ["...y", "..ll", ".lal", "llaa", ".d.d"];
const SPRITE_ORDER = ["badge", "coin", "clipboard", "chip", "calendar", "cube", "paywall", "crab", "jelly", "hornet", "dart", "cloud", "orb", "urchin", "manta"];
const mirror = (rows) => rows.map((r) => (r.length === 6 || r.length === 4 ? r + [...r.slice(0, -1)].reverse().join("") : r));
function paletteFor(css) { const b = rgbOf(css); return { a: hex(b), d: hex(mix(b, [0, 0, 0], 0.45)), l: hex(mix(b, [255, 255, 255], 0.45)), w: "#ffffff", k: C.ink, y: C.yellow, r: C.red }; }
function paint(rows, pal) { const c = makeCanvas(rows[0].length, rows.length); const x = ctx2d(c); rows.forEach((row, j) => [...row].forEach((ch, i) => { if (ch !== ".") { x.fillStyle = pal[ch] || pal.a; x.fillRect(i, j, 1, 1); } })); return c; }
function silhouette(src, color = "#fff") { const c = makeCanvas(src.width, src.height); const x = ctx2d(c); x.drawImage(src, 0, 0); x.globalCompositeOperation = "source-in"; x.fillStyle = color; x.fillRect(0, 0, c.width, c.height); return c; }
function prepareTypes() {
ENEMIES.forEach((t, i) => {
t.pal = paletteFor(t.color || C.lilac);
const frames = SPR[t.sprite] || SPR[SPRITE_ORDER[i % SPRITE_ORDER.length]];
t.frames = frames.map((f) => paint(mirror(f), t.pal)); t.white = t.frames.map((f) => silhouette(f));
t.hp = Math.max(1, t.hp | 0 || 1); t.points = t.points || 100; t.tag = t.tag || t.name;
if (t.ability === "flip") { const pal2 = paletteFor(t.color2 || C.red); t.frames2 = (SPR[t.sprite2] || SPR[t.sprite + "bad"] || frames).map((f) => paint(mirror(f), pal2)); t.white2 = t.frames2.map((f) => silhouette(f)); t.pal2 = pal2; }
if (t.ability === "split") t.child = { id: t.id + "-core", name: "Core", tag: "CORE", pal: t.pal, hp: 1, points: Math.round(t.points / 3), pattern: "dive", frames: SPR.core.map((f) => paint(f, t.pal)) };
if (t.child) t.child.white = t.child.frames.map((f) => silhouette(f));
});
BOSSES.forEach((def) => {
def.pal = paletteFor(def.color);
const make = def.style === "troll" ? makeTroll : makeBoss;
def.frames = [make(def, false), make(def, true)]; def.white = def.frames.map((f) => silhouette(f));
});
DRONE.pal = paletteFor(C.purple); DRONE.img = paint(mirror(DRONE), DRONE.pal);
POWERUPS.forEach((P) => { if (P.icon && PRODUCT_ICONS[P.icon]) { const base = rgbOf(P.iconColor || P.color); P.iconImg = buildIcon(P.icon, hex(base), 23); P.iconHi = buildIcon(P.icon, hex(mix(base, [255, 255, 255], 0.55)), 23); P.iconBig = buildIcon(P.icon, hex(mix(base, [255, 255, 255], 0.15)), 38); } });
}
// Vendor Lock-In: a padlock battleship, drawn in code (48x36, anchor at 24,20)
function makeBoss(def, open) {
const c = makeCanvas(48, 36), x = ctx2d(c), P = def.pal;
const put = (i, j, col) => { x.fillStyle = col; x.fillRect(i, j, 1, 1); };
const rect = (i, j, w, h, col) => { x.fillStyle = col; x.fillRect(i, j, w, h); };
const lift = open ? 6 : 0;
for (let j = 0; j < 22; j++) for (let i = 0; i < 48; i++) {
const dx = i + 0.5 - 24, dy = j + 0.5 - 15; let d;
if (dy <= 0) d = Math.hypot(dx, dy); else d = Math.abs(dx);
if (d > 10 || d < 6) continue;
put(i, j - lift, d < 7 || d > 9 ? P.d : P.l);
}
if (open) for (let j = 16; j < 22; j++) for (let i = 14; i < 18; i++) put(i, j, i === 14 || i === 17 ? P.d : P.l); // left leg stays in
rect(8, 18, 32, 16, P.d); rect(9, 19, 30, 14, P.a); rect(9, 19, 30, 2, P.l); rect(9, 32, 30, 1, P.d);
[[11, 22], [36, 22], [11, 30], [36, 30]].forEach(([i, j]) => put(i, j, C.yellow));
const key = open ? C.red : C.ink;
for (let j = 20; j < 31; j++) for (let i = 18; i < 30; i++) { const dx = i + 0.5 - 24, dy = j + 0.5 - 24; if (Math.hypot(dx, dy) <= 2.8 || (Math.abs(dx) <= 1 && j >= 24 && j <= 29)) put(i, j, key); }
for (let j = 20; j < 31; j++) { const span = Math.min(7, j - 20); for (let s = 0; s <= span; s++) { put(7 - s, j, s === span ? P.d : P.a); put(40 + s, j, s === span ? P.d : P.a); } }
rect(1, 28, 4, 7, P.d); rect(2, 28, 2, 6, P.l); rect(43, 28, 4, 7, P.d); rect(44, 28, 2, 6, P.l);
rect(2, 34, 2, 1, C.yellow); rect(44, 34, 2, 1, C.yellow);
for (let i = 13; i < 36; i += 3) if (Math.abs(i + 0.5 - 24) > 3) rect(i, 30, 1, 2, P.d);
if (open) [[14, 21], [15, 22], [15, 23], [16, 24], [33, 25], [32, 26], [32, 27], [31, 28]].forEach(([i, j]) => put(i, j, P.d));
return c;
}
// Patent Troll: a troll holding up its patent scroll, drawn in code (48x36, anchor at 24,20). angry = phase 3.
function makeTroll(def, angry) {
const c = makeCanvas(48, 36), x = ctx2d(c), P = def.pal;
const put = (i, j, col) => { x.fillStyle = col; x.fillRect(i, j, 1, 1); };
const rect = (i, j, w, h, col) => { x.fillStyle = col; x.fillRect(i, j, w, h); };
const ell = (cx, cy, rx, ry, col) => { for (let j = 0; j < 36; j++) for (let i = 0; i < 48; i++) { const u = (i + 0.5 - cx) / rx, v = (j + 0.5 - cy) / ry; if (u * u + v * v <= 1) put(i, j, col); } };
const tri = (pts, col) => { for (let j = 0; j < 36; j++) for (let i = 0; i < 48; i++) { const px = i + 0.5, py = j + 0.5; let inside = false; for (let a = 0, b = pts.length - 1; a < pts.length; b = a++) { const [xa, ya] = pts[a], [xb, yb] = pts[b]; if ((ya > py) !== (yb > py) && px < ((xb - xa) * (py - ya)) / (yb - ya) + xa) inside = !inside; } if (inside) put(i, j, col); } };
tri([[7, 3], [17, 9], [14, 15]], P.d); tri([[41, 3], [31, 9], [34, 15]], P.d); // ears
ell(24, 26, 17, 9, P.d); ell(24, 25, 15, 8, P.a); // body
ell(24, 13, 11, 8.5, P.d); ell(24, 12.5, 10, 7.5, P.a); ell(24, 10, 7, 3.5, P.l); // head
rect(17, 8, 5, 1, P.d); rect(26, 8, 5, 1, P.d); // brows
if (angry) { put(21, 7, P.d); put(26, 7, P.d); }
rect(18, 9, 4, 3, "#ffffff"); rect(26, 9, 4, 3, "#ffffff");
const eye = angry ? C.red : C.ink; rect(20, 10, 2, 2, eye); rect(26, 10, 2, 2, eye);
ell(24, 14, 2.5, 2, P.d); // nose
rect(19, 17, 10, 1, C.ink); rect(20, 15, 1, 2, "#ffffff"); rect(27, 15, 1, 2, "#ffffff"); // mouth, tusks
ell(6, 27, 3.5, 3.5, P.a); ell(42, 27, 3.5, 3.5, P.a); // hands
rect(7, 24, 34, 9, "#b8a47a"); rect(8, 25, 32, 7, "#f4ecd6"); // the patent
rect(4, 23, 4, 11, "#d8c8a0"); rect(40, 23, 4, 11, "#d8c8a0"); rect(5, 23, 2, 11, "#f4ecd6"); rect(41, 23, 2, 11, "#f4ecd6");
rect(10, 26, 16, 1, C.ink); rect(10, 28, 20, 1, C.ink); rect(10, 30, 12, 1, C.ink);
ell(35, 29, 2.6, 2.6, angry ? C.yellow : C.red); // seal
return c;
}
// The player's ship: the Percona mountain mark (web/assets/brand/mountain-mark-white.svg), rasterised to pixels.
const MARK_PATH = "M108.9,139.5l63.5,110.1h-127L108.9,139.5z M178.8,82.2c10.3-4.9,21.8-6,33-3c12.3,3.3,22.6,11.2,29,22.3c12.6,21.8,6,49.4-14.4,63.3L178.8,82.2z M119.3,121.4l30.6-53l0,0l104.5,181.2h-61.2L119.3,121.4z M108.9,103.4L14.2,267.6h271.5l-50.3-87.2c29-18.9,38.4-57.6,20.9-88c-8.8-15.2-23-26.1-40-30.7c-15.7-4.2-32.2-2.5-46.6,4.8l-19.8-34.2L108.9,103.4z";
// Percona product icons (web/assets/brand/products/*.svg, 1123.51 units square). Only the glyphs are stored: the outline
// triangle is redrawn as crisp 1 px lines, which a proportional stroke can't survive at 19 px.
const PRODUCT_ICONS = {
pmm: "M529.44,662.42L529.44,662.42L773.07,662.42L651.25,451.44L529.44,662.42Z M435.89,555.32L419.17,526.36L340.61,662.42L374.06,662.42L435.89,555.32Z M587.99,516.98L535.5,426.08L399.05,662.42L504.03,662.42L587.99,516.98Z M529.44,683.48L529.44,683.48L773.07,683.48L651.25,894.45L529.44,683.48Z M435.89,790.57L419.17,819.53L340.61,683.48L374.06,683.48L435.89,790.57Z M587.99,828.91L535.5,919.81L399.05,683.48L504.03,683.48L587.99,828.91Z", // PMM color.svg
operator: "M285.46,815.31l31.02,82.63h452.56l68.58-82.63H285.46Z M338.58,786.24L485.24,528.52L504.78,562.26L376.93,786.24Z M607.11,706.43L653.19,786.24L801.39,786.24L607.11,449.74L412.83,786.24L561.03,786.24L607.11,706.43Z M471.65,449.74L521.09,535.38L570.54,449.74L471.65,449.74Z", // Percona Operators color.svg
pxc: "M355.31,798.52L446.37,798.52L401.31,719.55L355.31,798.52Z M287.72,808.89L271.19,834.94L271.19,899.17L332.21,836.75L364.12,864.61L404.64,863.85L357.54,808.89L287.72,808.89Z M668.31,608.9h-103.63l-115.28-47.15,29.15,51.04-112.11,61.96-64.71,111.7h34.92l64.98-111.54,63.62,111.51h66.14l-20,34.32,43.46,76.17,100.45-173.04-28.41-49.66,82.27.19,28.45,49.82h-56.27l-78.13,133.72,23.45,40,64.76-110.43,13.62-.48c36.63,0,126.32-8.48,126.32-45.12,0-65.16-97.9-133.01-163.07-133.01ZM764.38,754.63c0,5.13-4.19,9.32-9.32,9.32h-8.67c-5.13,0-9.32-4.2-9.32-9.32v-8.67c0-5.13,4.19-9.32,9.32-9.32h8.67c5.13,0,9.32,4.19,9.32,9.32v8.67Z", // Percona for MySQL black.svg (the MySQL icon stands in for PXC)
};
function buildIcon(id, css, size = 19) {
const c = makeCanvas(size, size), x = ctx2d(c), s = size / 1123.51;
x.save(); x.scale(s, s); x.fill(new Path2D(PRODUCT_ICONS[id])); x.restore();
const d = x.getImageData(0, 0, size, size), rgb = rgbOf(css);
for (let i = 0; i < d.data.length; i += 4) { const on = d.data[i + 3] > 90; d.data[i] = rgb[0]; d.data[i + 1] = rgb[1]; d.data[i + 2] = rgb[2]; d.data[i + 3] = on ? 255 : 0; }
x.putImageData(d, 0, 0);
const line = (x0, y0, x1, y1) => { const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)); x.fillStyle = css; for (let k = 0; k <= n; k++) x.fillRect(Math.round(lerp(x0, x1, k / n)), Math.round(lerp(y0, y1, k / n)), 1, 1); };
const ax = Math.floor(size / 2), ay = Math.round(112 * s), by = Math.min(size - 1, Math.round(1011 * s)), lx = Math.round(42 * s), rx = size - 1 - lx;
line(ax, ay, lx, by); line(ax, ay, rx, by); line(lx, by, rx, by);
return c;
}
let markBox = null;
function rasterMark(width) {
const path = new Path2D(MARK_PATH);
if (!markBox) {
const c = makeCanvas(300, 300), x = c.getContext("2d", { willReadFrequently: true }); x.fill(path);
const a = x.getImageData(0, 0, 300, 300).data; let x0 = 300, y0 = 300, x1 = 0, y1 = 0;
for (let j = 0; j < 300; j++) for (let i = 0; i < 300; i++) if (a[(j * 300 + i) * 4 + 3] > 20) { x0 = Math.min(x0, i); x1 = Math.max(x1, i); y0 = Math.min(y0, j); y1 = Math.max(y1, j); }
markBox = { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
}
const s = width / markBox.w, h = Math.max(1, Math.round(markBox.h * s));
const c = makeCanvas(width, h), x = c.getContext("2d", { willReadFrequently: true });
x.scale(s, s); x.translate(-markBox.x, -markBox.y); x.fill(path);
const d = x.getImageData(0, 0, width, h).data; const a = new Uint8Array(width * h);
for (let i = 0; i < a.length; i++) a[i] = d[i * 4 + 3];
return { w: width, h, a };
}
function buildMark(tintCss, width) {
const base = rgbOf(tintCss); const { w, h, a } = rasterMark(width);
const body = mix(base, [255, 255, 255], 0.12), hi = mix(base, [255, 255, 255], 0.65), sh = mix(base, [0, 0, 0], 0.42);
const on = (i, j) => i >= 0 && j >= 0 && i < w && j < h && a[j * w + i] > 70;
const c = makeCanvas(w, h), x = ctx2d(c), img = x.createImageData(w, h);
for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
const al = a[j * w + i]; if (al <= 70) continue;
let col = al > 150 ? body : sh;
if (!on(i, j - 1)) col = hi; else if (!on(i, j + 1) || !on(i + 1, j)) col = sh;
const p = (j * w + i) * 4; img.data[p] = col[0]; img.data[p + 1] = col[1]; img.data[p + 2] = col[2]; img.data[p + 3] = 255;
}
x.putImageData(img, 0, 0); return c;
}
const SHIP = { img: null, mini: null, white: null, title: null, titleShadow: null };
function buildShip(tintCss) {
let t = rgbOf(tintCss); t = mix(t, [255, 255, 255], 0.2); // keep dark guild colours readable on space
const css = hex(t); SHIP.img = buildMark(css, 17); SHIP.mini = buildMark(css, 9); SHIP.white = silhouette(SHIP.img);
}
// Optional local sprites: web/rebels-assets/<id>.png replaces the built-in art for that enemy.
function loadLocalSprites() {
const q = location.search;
if (/[?&]sprites\b/.test(q)) store.set("sprites", true); else if (/[?&]nosprites\b/.test(q)) store.set("sprites", false);
const inlined = window.REBELS_ASSETS; // set by the single-file build
if (!inlined && !(LOCAL_SPRITES || store.get("sprites", false))) return;
[...ENEMIES, ...BOSSES].forEach((t) => {
const path = `rebels-assets/${t.id}.png`;
if (inlined && !inlined[path]) return;
const img = new Image();
img.onload = () => { t.img = fitImage(img, BOSSES.includes(t) ? 48 : 16); };
img.onerror = () => {};
img.src = inlined ? inlined[path] : path;
});
}
// scale a local sprite down once, smoothly (halving steps), so big PNGs and logos stay legible at 16 px
function fitImage(img, box) {
const k = Math.min(1, box / img.width, box / img.height); if (k === 1) return img;
let cur = img, w = img.width, h = img.height;
const tw = Math.max(1, Math.round(img.width * k)), th = Math.max(1, Math.round(img.height * k));
while (w / 2 > tw) { const c = makeCanvas(Math.ceil(w / 2), Math.ceil(h / 2)), x = ctx2d(c); x.imageSmoothingQuality = "high"; x.drawImage(cur, 0, 0, w, h, 0, 0, c.width, c.height); cur = c; w = c.width; h = c.height; }
const out = makeCanvas(tw, th), x = ctx2d(out); x.imageSmoothingQuality = "high"; x.drawImage(cur, 0, 0, w, h, 0, 0, tw, th);
return out;
}
function drawType(t, frame, x, y, white, box = 16, alt = false) {
if (t.img) {
const k = Math.min(1, box / t.img.width, box / t.img.height), w = Math.max(1, Math.round(t.img.width * k)), h = Math.max(1, Math.round(t.img.height * k));
if (white) g.globalAlpha = 0.45;
g.drawImage(t.img, Math.round(x - w / 2), Math.round(y - h / 2), w, h); g.globalAlpha = 1; return;
}
const set = alt && t.frames2 ? (white ? t.white2 : t.frames2) : white ? t.white : t.frames;
const img = set[frame % set.length];
g.drawImage(img, Math.round(x - img.width / 2), Math.round(y - img.height / 2));
}
/* ================================================================== audio: everything synthesised */
const AU = {
ctx: null, out: null, sfx: null, mus: null, noise: null, muted: store.get("muted", false),
unlock() {
if (this.ctx) { if (this.ctx.state === "suspended") this.ctx.resume().catch(() => {}); return; }
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
try {
const c = (this.ctx = new AC());
this.out = c.createGain(); this.out.gain.value = this.muted ? 0 : 0.7;
const comp = c.createDynamicsCompressor(); this.out.connect(comp).connect(c.destination);
this.sfx = c.createGain(); this.sfx.gain.value = 0.9; this.sfx.connect(this.out);
this.mus = c.createGain(); this.mus.gain.value = 0.32; this.mus.connect(this.out);
const len = c.sampleRate; const b = c.createBuffer(1, len, c.sampleRate); const d = b.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
this.noise = b;
} catch { this.ctx = null; }
},
get ok() { return !!this.ctx && !this.muted; },
setMuted(m) { this.muted = m; store.set("muted", m); if (this.out) this.out.gain.setTargetAtTime(m ? 0 : 0.7, this.ctx.currentTime, 0.02); },
tone(f, dur, { type = "square", vol = 0.1, to = null, at = 0, bus = null } = {}) {
if (!this.ok) return;
try {
const c = this.ctx, t = c.currentTime + at, o = c.createOscillator(), gn = c.createGain();
o.type = type; o.frequency.setValueAtTime(f, t); if (to) o.frequency.exponentialRampToValueAtTime(Math.max(20, to), t + dur);
gn.gain.setValueAtTime(vol, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(gn).connect(bus || this.sfx); o.start(t); o.stop(t + dur + 0.02);
} catch { /* ignore */ }
},
hiss(dur, { vol = 0.2, f = 2000, to = null, q = 0.8, at = 0, type = "lowpass" } = {}) {
if (!this.ok) return;
try {
const c = this.ctx, t = c.currentTime + at, s = c.createBufferSource(), fl = c.createBiquadFilter(), gn = c.createGain();
s.buffer = this.noise; s.loop = true; fl.type = type; fl.frequency.setValueAtTime(f, t); if (to) fl.frequency.exponentialRampToValueAtTime(Math.max(40, to), t + dur); fl.Q.value = q;
gn.gain.setValueAtTime(vol, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(fl).connect(gn).connect(this.sfx); s.start(t, Math.random() * 0.5); s.stop(t + dur + 0.02);
} catch { /* ignore */ }
},
laser(spread) { this.tone(1500, 0.09, { vol: 0.045, to: 320 }); if (spread) this.tone(2000, 0.07, { type: "sawtooth", vol: 0.02, to: 600 }); },
eshot() { this.tone(560, 0.12, { type: "triangle", vol: 0.05, to: 170 }); },
boom(big) { this.hiss(big ? 0.9 : 0.32, { vol: big ? 0.42 : 0.22, f: big ? 1800 : 3200, to: 80 }); this.tone(big ? 90 : 170, big ? 0.6 : 0.18, { type: "sine", vol: big ? 0.3 : 0.1, to: 30 }); },
clink() { this.tone(950, 0.04, { vol: 0.035, to: 620 }); },
power() { [523, 659, 784, 1047, 1319].forEach((f, i) => this.tone(f, 0.09, { vol: 0.06, at: i * 0.055 })); },
oneUp() { [784, 988, 1175, 1568, 1175, 1568].forEach((f, i) => this.tone(f, 0.1, { vol: 0.07, at: i * 0.08, type: "triangle" })); },
coin() { this.tone(988, 0.08, { vol: 0.08 }); this.tone(1319, 0.35, { vol: 0.08, at: 0.08 }); },
death() { this.hiss(1.2, { vol: 0.38, f: 1200, to: 60 }); [440, 330, 247, 165].forEach((f, i) => this.tone(f, 0.22, { type: "sawtooth", vol: 0.05, at: i * 0.14, to: f * 0.7 })); },
shield() { this.tone(300, 0.3, { type: "sawtooth", vol: 0.07, to: 1200 }); },
bomb() { this.hiss(1.4, { vol: 0.5, f: 4000, to: 60 }); this.tone(60, 1, { type: "sine", vol: 0.35, to: 25 }); },
dive() { this.tone(1200, 0.35, { type: "triangle", vol: 0.02, to: 420 }); },
siren() { for (let i = 0; i < 4; i++) { this.tone(440, 0.25, { type: "sawtooth", vol: 0.05, to: 880, at: i * 0.5 }); this.tone(880, 0.25, { type: "sawtooth", vol: 0.05, to: 440, at: i * 0.5 + 0.25 }); } },
combo() { this.tone(660, 0.06, { vol: 0.05 }); this.tone(990, 0.1, { vol: 0.05, at: 0.06 }); },
start() { [523, 659, 784, 659, 784, 1047].forEach((f, i) => this.tone(f, 0.12, { vol: 0.07, at: i * 0.1 })); },
blip(f = 880) { this.tone(f, 0.05, { vol: 0.05 }); },
charge() { this.tone(200, 1.0, { type: "sawtooth", vol: 0.04, to: 1600 }); },
zap() { this.hiss(0.7, { vol: 0.28, f: 6000, to: 800, type: "bandpass", q: 2 }); this.tone(110, 0.7, { vol: 0.05 }); },
clear() { [659, 784, 988, 1319].forEach((f, i) => this.tone(f, 0.14, { vol: 0.07, at: i * 0.12, type: "triangle" })); },
over() { [392, 349, 311, 262].forEach((f, i) => this.tone(f, 0.32, { type: "triangle", vol: 0.08, at: i * 0.28 })); },
};
// A four-bar chiptune loop: square lead, triangle bass, noise drums. The boss gets it faster and lower.
const Music = {
lead: [69, 0, 72, 0, 76, 0, 81, 0, 79, 0, 76, 0, 72, 0, 74, 0, 72, 0, 77, 0, 81, 0, 77, 0, 76, 0, 72, 0, 69, 0, 72, 0,
71, 0, 74, 0, 79, 0, 83, 0, 81, 0, 79, 0, 74, 0, 71, 0, 76, 0, 0, 0, 80, 0, 83, 0, 0, 0, 81, 0, 80, 0, 76, 0],
bass: [45, 45, 57, 45, 45, 45, 57, 45, 41, 41, 53, 41, 41, 41, 53, 41, 43, 43, 55, 43, 43, 43, 55, 43, 40, 40, 52, 40, 40, 40, 52, 44],
on: false, boss: false, step: 0, next: 0, timer: null,
start(boss) {
if (!AU.ctx) return;
if (this.on && this.boss === boss) return;
this.boss = boss; this.on = true; this.step = 0; this.next = AU.ctx.currentTime + 0.06;
if (!this.timer) this.timer = setInterval(() => this.pump(), 25);
},
stop() { this.on = false; if (this.timer) clearInterval(this.timer); this.timer = null; },
pump() {
if (!this.on || !AU.ctx) return;
const c = AU.ctx, s16 = 60 / (144 * (this.boss ? 1.14 : 1)) / 4;
if (this.next < c.currentTime - 0.2) this.next = c.currentTime + 0.02; // tab was asleep
while (this.next < c.currentTime + 0.12) { this.play(this.step, this.next, s16); this.next += s16; this.step = (this.step + 1) % 64; }
},
note(m, t, dur, type, vol) {
const c = AU.ctx, o = c.createOscillator(), gn = c.createGain();
o.type = type; o.frequency.setValueAtTime(440 * Math.pow(2, (m - 69) / 12), t);
gn.gain.setValueAtTime(0.0001, t); gn.gain.linearRampToValueAtTime(vol, t + 0.005); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(gn).connect(AU.mus); o.start(t); o.stop(t + dur + 0.02);
},
drum(t, kind) {
const c = AU.ctx;
if (kind === "kick") { const o = c.createOscillator(), gn = c.createGain(); o.frequency.setValueAtTime(150, t); o.frequency.exponentialRampToValueAtTime(40, t + 0.12); gn.gain.setValueAtTime(0.5, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.14); o.connect(gn).connect(AU.mus); o.start(t); o.stop(t + 0.16); return; }
const s = c.createBufferSource(), fl = c.createBiquadFilter(), gn = c.createGain(); s.buffer = AU.noise;
fl.type = kind === "hat" ? "highpass" : "bandpass"; fl.frequency.value = kind === "hat" ? 7000 : 1800;
const dur = kind === "hat" ? 0.03 : 0.12; gn.gain.setValueAtTime(kind === "hat" ? 0.08 : 0.25, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(fl).connect(gn).connect(AU.mus); s.start(t, Math.random() * 0.5); s.stop(t + dur + 0.02);
},
play(i, t, s16) {
if (AU.muted) return;
try {
const tr = this.boss ? -3 : 0;
if (this.lead[i]) this.note(this.lead[i] + tr, t, s16 * 1.7, "square", 0.045);
if (i % 2 === 0 && this.bass[i / 2]) this.note(this.bass[i / 2] + tr, t, s16 * 1.8, "triangle", 0.16);
if (i % 8 === 0) this.drum(t, "kick"); else if (i % 8 === 4) this.drum(t, "snare");
if (i % 2 === 1) this.drum(t, "hat");
} catch { /* ignore */ }
},
};
/* ================================================================== CRT filter: WebGL when it can, CSS otherwise */
const CRT = {
gl: null, ok: false, tried: false, loc: null,
init() {
this.tried = true;
try {
const gl = crtCanvas.getContext("webgl", { antialias: false, premultipliedAlpha: false, preserveDrawingBuffer: false });
if (!gl) return false;
const vs = "attribute vec2 a;varying vec2 v;void main(){v=a*0.5+0.5;gl_Position=vec4(a,0.0,1.0);}";
const fs = `precision mediump float;varying vec2 v;uniform sampler2D t;uniform vec2 src;
vec2 warp(vec2 p){p=p*2.0-1.0;p*=1.0+vec2(p.y*p.y*0.035,p.x*p.x*0.05);return p*0.5+0.5;}
void main(){vec2 p=warp(v);
if(p.x<0.0||p.x>1.0||p.y<0.0||p.y>1.0){gl_FragColor=vec4(0.0,0.0,0.0,1.0);return;}
vec2 px=1.0/src;vec3 c=texture2D(t,p).rgb;
vec3 gl2=texture2D(t,p+vec2(px.x,0.0)).rgb+texture2D(t,p-vec2(px.x,0.0)).rgb+texture2D(t,p+vec2(0.0,px.y)).rgb+texture2D(t,p-vec2(0.0,px.y)).rgb;
c+=gl2*0.07;
float row=fract(p.y*src.y);float scan=0.68+0.32*smoothstep(0.0,0.3,row)*smoothstep(1.0,0.7,row);c*=scan;
float m=mod(gl_FragCoord.x,3.0);c*=m<1.0?vec3(1.0,0.92,0.92):m<2.0?vec3(0.92,1.0,0.92):vec3(0.92,0.92,1.0);
vec2 q=p*(1.0-p.yx);c*=pow(q.x*q.y*16.0,0.2)*1.14;
gl_FragColor=vec4(c,1.0);}`;
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error("shader"); return s; };
const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error("link");
gl.useProgram(prog);
const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const a = gl.getAttribLocation(prog, "a"); gl.enableVertexAttribArray(a); gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);
const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.uniform2f(gl.getUniformLocation(prog, "src"), W, H);
this.gl = gl; this.ok = true;
} catch { this.ok = false; }
return this.ok;
},
draw() {
const gl = this.gl;
gl.viewport(0, 0, crtCanvas.width, crtCanvas.height);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, buf); // throws on a tainted canvas (file:// images) → CSS fallback
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
};
function applyCrt() {
if (G.crt && !CRT.tried) CRT.init();
const gpu = G.crt && CRT.ok;
crtCanvas.hidden = !gpu; view.hidden = gpu;
stage.classList.toggle("crt-css", G.crt && !gpu);
}
function present() {
if (G.crt && CRT.ok) {
try { CRT.draw(); return; } catch { CRT.ok = false; applyCrt(); }
}
vctx.imageSmoothingEnabled = false; vctx.drawImage(buf, 0, 0, view.width, view.height);
}
function resize() {
const dpr = window.devicePixelRatio || 1;
const hintsH = document.body.classList.contains("touch") || innerHeight < 560 ? 12 : 44;
const availW = Math.max(100, innerWidth - 16), availH = Math.max(100, innerHeight - hintsH - 12);
let scale = Math.floor(Math.min((availW * dpr) / W, (availH * dpr) / H)); // integer scale in device pixels
if (scale < 1) scale = Math.min((availW * dpr) / W, (availH * dpr) / H);
const dw = Math.round(W * scale), dh = Math.round(H * scale);
for (const c of [view, crtCanvas]) { c.width = dw; c.height = dh; }
stage.style.width = dw / dpr + "px"; stage.style.height = dh / dpr + "px";
stage.style.setProperty("--px", scale / dpr + "px");
stage.style.marginTop = -(hintsH - 12) / 2 + "px";
}
/* ================================================================== state */
const G = {
mode: "attract", t: 0, attract: { i: 0, t: 0 }, credits: 0, paused: false, pauseEsc: false, world: null, demo: null, demoCount: 0,
pilot: null, crt: store.get("crt", true), toast: null, scores: [], lastEntry: -1, over: null, ini: null, scoresT: 0, touch: false, warp: 1, stars: [],
};
function loadScores() {
let s = store.get("scores", null);
if (!Array.isArray(s) || !s.length) s = DEFAULT_SCORES.map(([n, sc, w]) => ({ n, s: sc, w }));
G.scores = s.filter((x) => x && typeof x.s === "number").sort((a, b) => b.s - a.s).slice(0, 10);
}
// the record and who holds it: the world table, this browser's table, or you, mid-game, once you pass it
function hiEntry() {
const best = [World.top?.[0], G.scores[0]].filter(Boolean).reduce((a, b) => (b.s > a.s ? b : a), { s: 0, n: "" });
const playing = G.world && !G.world.demo && (G.mode === "game" || G.mode === "gameover") ? G.world.score : 0;
return playing > best.s ? { s: playing, n: "YOU" } : { s: best.s, n: String(best.n || "").slice(0, 3) };
}
/* ================================================================== world high scores (optional, public build)
A Google Apps Script web app in front of a Google Sheet keeps one table for every player (leaderboard/Code.gs).
The page sets window.REBELS_LEADERBOARD to its /exec URL; without it the game only keeps its local table.
Each game asks for a one-time ticket when it starts; the server times the run itself and accepts one score per
ticket. Big scores wait for a human check before they show. Only initials, score, wave, play time and kill count
are sent. */
const LEADERBOARD_URL = String(window.REBELS_LEADERBOARD || "");
const World = {
top: null, status: LEADERBOARD_URL ? "loading" : "off", rank: null, sending: false, note: "", loadedAt: 0, ticket: null,
get on() { return !!LEADERBOARD_URL; },
async call(params, body) {
const ctl = new AbortController(), timer = setTimeout(() => ctl.abort(), 8000);
try {
const url = LEADERBOARD_URL + (LEADERBOARD_URL.includes("?") ? "&" : "?") + new URLSearchParams(params);
// text/plain keeps the POST a "simple" request (no CORS preflight), which Apps Script needs
const r = await fetch(url, body ? { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "text/plain;charset=utf-8" }, signal: ctl.signal } : { signal: ctl.signal });
if (!r.ok) throw new Error("HTTP " + r.status);
return await r.json();
} finally { clearTimeout(timer); }
},
// only entries that fit the game: a broken row in the sheet must never reach the screen
clean(list) { return (Array.isArray(list) ? list : []).filter((x) => x && Number.isInteger(x.s) && x.s > 0 && x.s <= 5e6 && Number.isInteger(x.w) && x.w >= 1 && x.w <= 500).slice(0, 10).map((x) => ({ n: String(x.n || "???").slice(0, 3), s: x.s, w: x.w })); },
async refresh() {
if (!this.on || this.sending) return;
this.loadedAt = G.t;
try { const d = await this.call({ action: "top" }); if (!d || !d.ok) throw new Error("bad reply"); this.top = this.clean(d.top); this.status = "ok"; }
catch { if (this.status !== "ok") this.status = "offline"; }
},
async startRun() {
this.ticket = null;
if (!this.on) return;
try { const d = await this.call({ action: "start" }); if (d && d.ok && d.ticket) this.ticket = String(d.ticket); } catch { /* no ticket: the score stays local */ }
},
qualifies(score) { return this.on && this.status === "ok" && score > 0 && (this.top.length < 10 || score > this.top[this.top.length - 1].s); },
async submit(entry) {
if (!this.on || this.status === "off") return;
this.sending = true; this.rank = null; this.note = "SENDING TO THE WORLD TABLE...";
const ticket = this.ticket; this.ticket = null;
try {
const d = await this.call({ action: "submit" }, { ...entry, x: ticket || "" });
if (d && d.ok && d.pending) { this.top = this.clean(d.top); this.status = "ok"; this.note = "BIG SCORE! IT SHOWS ONCE A HUMAN CHECKS IT"; }
else if (d && d.ok) { this.top = this.clean(d.top); this.status = "ok"; this.rank = d.rank || null; this.note = d.rank ? `WORLD RANK #${d.rank}` : "SENT TO THE WORLD TABLE"; }
else this.note = d && d.error === "ticket" ? "COULDN'T VERIFY THIS RUN. SAVED HERE." : d && d.error === "busy" ? "WORLD TABLE BUSY. SAVED HERE." : "THE WORLD TABLE SAID NO";
} catch { this.note = "WORLD TABLE OFFLINE. SAVED HERE."; }
finally { this.sending = false; }
},
};
function toast(msg) { G.toast = { msg, t: 0 }; }
/* ================================================================== stars */
function seedStars() {
const cols = [C.white, C.white, C.lilac, "#6f6b7d", C.yellow, "#4aa3ff"];
G.stars = Array.from({ length: 90 }, (_, i) => ({ x: rnd(W), y: rnd(H), l: i % 3, c: pick(cols), tw: rnd(6) }));
}
function updateStars(dt) {
const target = G.mode === "game" && G.world && (G.world.phase === "intro" || G.world.phase === "clear") ? (reduced ? 1.6 : 5) : 1;
G.warp = lerp(G.warp, target, Math.min(1, dt * 2.5));
for (const s of G.stars) { s.y += [7, 18, 40][s.l] * G.warp * dt; if (s.y > H) { s.y -= H; s.x = rnd(W); } }
}
function drawStars() {
for (const s of G.stars) {
if (s.l === 0 && Math.sin(G.t * 3 + s.tw) < -0.6) continue;
g.fillStyle = s.c; g.globalAlpha = [0.45, 0.7, 1][s.l];
const len = G.warp > 1.5 && s.l > 0 ? Math.round(G.warp * s.l) : 1;
g.fillRect(Math.round(s.x), Math.round(s.y), 1, len);
}
g.globalAlpha = 1;
}
/* ================================================================== world */
function newWorld(demo) {
return {
demo, score: 0, lives: demo ? 3 : RULES.startLives, wave: 0, nextLife: RULES.extraLifeEvery, t: 0,
phase: "intro", phaseT: 0, spawnT: 0, isBoss: false, featured: [],
enemies: [], queue: [], ebul: [], pbul: [], parts: [], pops: [], pups: [], wing: [], boss: null, beam: null,
form: { t: 0, mix: 0, top: 46, cols: 8 }, diveT: 3, fx: { pmm: 0, pxc: 0, op: 0, shield: false },
streak: 0, streakT: 0, mult: 1, shots: 0, hits: 0, kills: 0, deathsThisWave: 0,
p: { x: W / 2, y: H - 34, alive: true, inv: 0, cd: 0, deadT: 0 }, shake: 0, flash: 0, banner: null,
};
}
const sfx = (w, fn, ...a) => { if (!w.demo) AU[fn](...a); };
function diff(w) {
const n = w.wave;
return {
diveEvery: Math.max(0.55, 2.6 - n * 0.16), maxDivers: Math.min(7, 1 + Math.floor(n / 1.5)), dive: Math.min(1.85, 0.85 + n * 0.06),
bullet: Math.min(150, 72 + n * 5), formFire: Math.min(1.6, 0.25 + n * 0.09), diveShots: n < 2 ? 1 : n < 5 ? 2 : 3, escorts: n >= 4, entry: Math.max(1.6, 2.2 - n * 0.03),
};
}
function banner(w, textMain, sub = "", dur = 1.8, color = C.yellow) { w.banner = { text: textMain, sub, t: 0, dur, color }; }
function popup(w, x, y, s, big = false) { w.pops.push({ x, y, s: String(s), t: 0, big }); }
function explode(w, x, y, colors, n = 16, speed = 90) {
n = Math.round(n * (reduced ? 0.6 : 1));
for (let i = 0; i < n; i++) { const a = rnd(Math.PI * 2), sp = rnd(speed * 0.2, speed); const life = rnd(0.3, 0.75); w.parts.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life, max: life, c: pick(colors), s: Math.random() < 0.25 ? 2 : 1 }); }
w.parts.push({ ring: true, x, y, r: 1, life: 0.28, max: 0.28, c: colors[0] });
}
function shake(w, amount) { w.shake = Math.max(w.shake, amount); }
function startWave(w) {
w.wave++; w.phase = "intro"; w.phaseT = 0; w.spawnT = 0; w.enemies = []; w.queue = []; w.boss = null; w.beam = null;
w.form.mix = 0; w.form.t = 0; w.deathsThisWave = 0; w.diveT = 3;
w.isBoss = w.wave % RULES.bossEvery === 0;
if (w.isBoss) { w.featured = [bossFor(w.wave)]; sfx(w, "siren"); } else buildFormation(w);
if (!w.demo) Music.start(w.isBoss);
}
function buildFormation(w) {
const n = w.wave, L = ENEMIES.length, k = n - 1 - Math.floor((n - 1) / RULES.bossEvery);
const A = ENEMIES[(2 * k) % L], B = ENEMIES[(2 * k + 1) % L];
const top = A.hp >= B.hp ? A : B, bottom = top === A ? B : A;
const extra = k >= Math.ceil(L / 2) ? ENEMIES[(2 * k + 2) % L] : null;
const rows = n === 1 ? 3 : n === 2 ? 4 : 5;
const types = []; for (let r = 0; r < rows; r++) types.push(r === 0 && extra ? extra : r < (rows >= 4 ? 2 : 1) ? top : bottom);
w.featured = [...new Set(types)]; w.form.rows = rows;
const d = diff(w); let at = 0;
for (let r = 0; r < rows; r++) {
for (const side of [-1, 1]) {
const cols = side < 0 ? [3, 2, 1, 0] : [4, 5, 6, 7];
cols.forEach((col, i) => w.queue.push({ at: at + i * 0.14, e: mkEnemy(w, types[r], r, col), path: r % 3, side, dur: d.entry }));
}
at += 1.2;
}
w.queue.sort((a, b) => a.at - b.at);
}
function mkEnemy(w, type, row, col) { const hp = type.hp + Math.floor((w.wave - 1) / 10); return { type, hp, row, col, x: -40, y: -40, state: "wait", t: 0, flash: 0, fired: 0, fireAt: [] }; }
function slotPos(w, e) {
const f = w.form, sway = (1 - f.mix) * 16 * Math.sin(f.t * 0.8), br = 1 + f.mix * 0.075 * (0.5 - 0.5 * Math.cos(f.t * 1.7));
return { x: W / 2 + sway + (e.col - (f.cols - 1) / 2) * 16 * br, y: f.top + e.row * 13 * (1 + (br - 1) * 0.6) };
}
function entryPoints(id, s) {
if (id === 0) return [W / 2 + s * 12, -16, W / 2 + s * 12, H * 0.5, W / 2 + s * 104, H * 0.56];
if (id === 1) return [W / 2 + s * (W / 2 + 14), H * 0.74, W / 2 - s * 50, H * 0.62, W / 2 - s * 20, H * 0.18];
return [W / 2 + s * (W / 2 + 14), -12, W / 2 - s * 70, H * 0.34, W / 2 + s * 90, H * 0.52];
}
function spawn(w, q) { const e = q.e; e.state = "enter"; e.t = 0; e.pts = entryPoints(q.path, q.side); e.dur = q.dur; e.willFire = w.wave >= 3 && Math.random() < 0.15; w.enemies.push(e); }
const DIVE_DUR = { swoop: 2.4, dive: 1.7, zigzag: 3.0, loop: 3.3, spiral: 3.4 };
function divePos(e, t) {
const pat = DIVE_DUR[e.pattern] ? e.pattern : "swoop", D = DIVE_DUR[pat], k = Math.min(1, (t * e.spd) / D);
const { sx, sy, tx, dir } = e, ey = H + 24; let x, y;
switch (pat) {
case "swoop": x = bez(sx, sx + dir * 50, tx - dir * 70, tx + dir * 30, k); y = bez(sy, sy - 34, H * 0.78, ey, k); break;
case "dive": x = bez(sx, sx + dir * 26, tx, tx, k); y = bez(sy, sy - 26, sy + 60, ey, k); break;
case "zigzag": x = lerp(sx, tx, k) + 28 * Math.sin(k * Math.PI * 4) * Math.min(1, k * 5); y = lerp(sy, ey, k) - 18 * Math.sin(Math.min(1, k * 5) * Math.PI); break;
case "loop": {
const Lx = clamp(lerp(sx, tx, 0.5), 30, W - 30), Ly = H * 0.48, r = 20;
if (k < 0.33) { const q = k / 0.33; x = bez(sx, sx + dir * 30, Lx, Lx, q); y = bez(sy, sy - 24, Ly - r - 30, Ly - r, q); }
else if (k < 0.66) { const a = -Math.PI / 2 + dir * ((k - 0.33) / 0.33) * Math.PI * 2; x = Lx + r * Math.cos(a); y = Ly + r * Math.sin(a); }
else { const q = (k - 0.66) / 0.34; x = bez(Lx, Lx + dir * 20, tx, tx, q); y = bez(Ly - r, Ly - r + 20, H * 0.8, ey, q); }
break;
}
case "spiral": { const r = 6 + 20 * k, a = k * D * 6 * dir; x = lerp(sx, tx, k) + r * Math.cos(a) - 6; y = lerp(sy, ey, k) + r * Math.sin(a) * 0.5; break; }
}
return { x, y, k, done: k >= 1 };
}
function telegraph(w, e, lead, i) {
const d = diff(w); const s = slotPos(w, e);
e.state = "tele"; e.t = 0; e.tx = clamp(w.p.x + rnd(-24, 24), 12, W - 12); e.dir = s.x < W / 2 ? -1 : 1; e.pattern = e.type.pattern; e.spd = d.dive;
if (lead) { e.tx = clamp(lead.tx + (i ? 12 : -12), 12, W - 12); e.pattern = lead.pattern; e.dir = lead.dir; e.t = -0.14 * (i + 1); }
}
function beginDive(w, e) {
const d = diff(w); e.state = "dive"; e.t = 0; e.sx = e.x; e.sy = e.y; e.fired = 0;
e.fireAt = [0.2, 0.28, 0.42, 0.56].slice(e.type.ability === "flip" ? 0 : 1, 1 + d.diveShots); sfx(w, "dive"); // the Relicenser saves its shots for the dive
}
// an enemy's shot: Surprise Audit fires paperwork that jams your guns instead of killing you
function enemyShot(w, e, opts = {}) { eShoot(w, e.x, e.y + 5, { ...opts, kind: e.type.ability === "jam" ? "j" : opts.kind || "s", speed: e.type.ability === "jam" ? 60 : opts.speed }); sfx(w, "eshot"); }
function launchDives(w, dt, d) {
if (!w.p.alive) return;
w.diveT -= dt; if (w.diveT > 0) return;
const busy = w.enemies.filter((e) => e.state === "dive" || e.state === "tele").length;
const cands = w.enemies.filter((e) => e.state === "form");
if (busy >= d.maxDivers || !cands.length) { w.diveT = 0.3; return; }
const lead = pick(cands); telegraph(w, lead);
if (d.escorts && Math.random() < 0.45) cands.filter((e) => e !== lead && e.row === lead.row + 1 && Math.abs(e.col - lead.col) <= 1).slice(0, 2).forEach((e, i) => telegraph(w, e, lead, i));
w.diveT = d.diveEvery * rnd(0.7, 1.3);
}
function eShoot(w, x, y, { aim = true, speed, angle = 0, kind = "s" } = {}) {
const s = speed || diff(w).bullet; let vx = 0, vy = s;
if (aim) { const dx = w.p.x - x, dy = Math.max(30, w.p.y - y), L = Math.hypot(dx, dy); vx = clamp((dx / L) * s, -s * 0.45, s * 0.45); vy = Math.sqrt(s * s - vx * vx); }
if (angle) { const c = Math.cos(angle), sn = Math.sin(angle); [vx, vy] = [vx * c - vy * sn, vx * sn + vy * c]; }
w.ebul.push({ x, y, vx, vy, kind });
}
function formationFire(w, dt, d) {
if (!w.p.alive || Math.random() > d.formFire * dt) return;
const near = w.enemies.filter((e) => e.state === "form" && e.type.ability !== "flip" && Math.abs(e.x - w.p.x) < 44);
if (!near.length) return;
const e = near.reduce((a, b) => (b.row > a.row ? b : a)); enemyShot(w, e, { aim: Math.random() < 0.5 });
}
function updateEnemies(w, dt, d) {
for (const e of w.enemies) {
if (e.dead) continue;
e.t += dt; e.flash = Math.max(0, e.flash - dt);
if (e.state === "enter") {
const k = Math.min(1, e.t / e.dur), s = slotPos(w, e), p = e.pts;
e.x = bez(p[0], p[2], p[4], s.x, k); e.y = bez(p[1], p[3], p[5], s.y, k);
if (e.willFire && k > 0.45 && w.p.alive && e.y > 0 && e.type.ability !== "flip") { e.willFire = false; enemyShot(w, e); }
if (k >= 1) { e.state = "form"; e.t = 0; }
} else if (e.state === "form") {
const s = slotPos(w, e); e.x = s.x; e.y = s.y;
} else if (e.state === "tele") {
const s = slotPos(w, e); e.x = s.x + (e.t > 0 ? Math.round(Math.sin(e.t * 45)) : 0); e.y = s.y;
if (e.t >= 0.6) beginDive(w, e);
} else if (e.state === "dive") {
const r = divePos(e, e.t); e.x = r.x; e.y = r.y;
if (e.fired < e.fireAt.length && r.k >= e.fireAt[e.fired]) { e.fired++; if (w.p.alive && e.y < w.p.y - 24 && e.y > 0) enemyShot(w, e); }
if (e.type.ability === "drain" && w.p.alive && e.y > 0 && e.y < H && !w.demo) { // Egress Fee: your score leaks while it dives
e.drain = (e.drain || 0) + dt;
if (e.drain >= 0.25) { e.drain -= 0.25; w.score = Math.max(0, w.score - 10); e.drained = (e.drained || 0) + 10; if (e.drained % 50 === 0) popup(w, e.x, e.y - 8, "-" + e.drained); }
}
if (r.done || e.y > H + 20) { if (e.minion) e.dead = true; else { e.state = "return"; e.t = 0; e.rx = slotPos(w, e).x; e.y = -12; } }
} else if (e.state === "return") {
const k = Math.min(1, e.t / 1.1), s = slotPos(w, e), q = easeOut(k);
e.x = lerp(e.rx, s.x, q); e.y = lerp(-12, s.y, q);
if (k >= 1) { e.state = "form"; e.t = 0; }
}
}
}
/* --- boss */
function spawnBoss(w) {
const n = Math.max(1, Math.floor(w.wave / RULES.bossEvery)), def = bossFor(w.wave), loop = Math.floor((n - 1) / BOSSES.length), hp = Math.round(def.hp * (1 + 0.5 * loop));
w.boss = { def, x: W / 2, y: -30, hp, max: hp, phase: 1, state: "enter", t: 0, mt: 0, flash: 0, inv: 0, n, dying: 0, at: { aim: 1.6, can: 2.4, min: 3.5, ring: 3, beam: 5.5, spin: 0 }, spin: 0 };
}
function damageBoss(w, dmg, x, y) {
const b = w.boss; if (!b || b.state !== "fight") return;
if (b.inv > 0) { explode(w, x, y, [C.white, C.lilac], 3, 30); return; }
b.hp -= dmg; b.flash = 0.06; sfx(w, "clink");
if (b.hp <= 0) {
b.hp = 0; b.state = "dying"; b.dying = 0; w.ebul = []; w.beam = null;
w.enemies.forEach((e) => { if (e.minion && !e.dead) { e.dead = true; explode(w, e.x, e.y, [e.type.pal.a, e.type.pal.l], 10); } });
sfx(w, "boom", true); shake(w, 8);
}
}
function updateBoss(w, dt) {
const b = w.boss; b.t += dt; b.flash = Math.max(0, b.flash - dt); b.inv = Math.max(0, b.inv - dt);
const def = b.def, troll = def.style === "troll";
if (b.state === "enter") { b.y = lerp(-30, 64, easeOut(Math.min(1, b.t / 2.5))); if (b.t >= 2.5) { b.state = "fight"; b.t = 0; banner(w, def.name.toUpperCase(), def.intro, 1.8, C.red); } return; }
if (b.state === "dying") {
b.dying += dt; shake(w, 3);
if (Math.random() < dt * 14) { explode(w, b.x + rnd(-20, 20), b.y + rnd(-14, 14), [def.pal.l, C.yellow, C.orange, C.white], 12, 70); sfx(w, "boom", false); }
if (b.dying > 2.4) {
explode(w, b.x, b.y, [C.yellow, C.white, C.orange, C.purple, def.pal.l], 70, 150); shake(w, 12); w.flash = 1; sfx(w, "boom", true);
const pts = def.points * (1 + Math.floor((b.n - 1) / BOSSES.length)); addScore(w, pts); popup(w, b.x, b.y, pts, true); w.kills++;
banner(w, def.win[0], def.win[1], 2.4, C.yellow); w.banner.hold = true; w.boss = null; // the wave clear waits for this
}
return;
}
const ratio = b.hp / b.max, ph = ratio > 0.66 ? 1 : ratio > 0.33 ? 2 : 3;
if (ph !== b.phase) {
b.phase = ph; b.inv = 1; shake(w, 6); w.ebul = []; w.beam = null; sfx(w, "siren");
const [t1, t2] = ph === 2 ? def.phase2 : def.phase3; banner(w, t1, t2, 1.8, ph === 2 ? C.red : C.yellow);
}
const frozen = w.beam && w.beam.t < w.beam.tele + w.beam.dur;
if (!frozen) b.mt += dt * (ph === 3 ? 1.7 : ph === 2 ? 1.2 : 1);
b.x = W / 2 + Math.sin(b.mt * 0.55) * (W / 2 - 32); b.y = 64 + Math.sin(b.t * 1.3) * 4;
if (!w.p.alive || b.inv > 0) return;
const at = b.at, keyY = b.y + 5;
for (const k in at) at[k] -= dt;
if (troll) {
// patent scrolls drift as they fall; "claims" fan out; phase 3 is a litigation storm from above
if (at.aim <= 0) { at.aim = ph === 3 ? 1.0 : 1.3; [-0.12, 0.12].forEach((a) => eShoot(w, b.x, b.y + 4, { angle: a, kind: "p", speed: 85 })); sfx(w, "eshot"); }
if (at.can <= 0 && ph < 3) { at.can = 2.6; [-0.5, -0.25, 0, 0.25, 0.5].forEach((a) => eShoot(w, b.x, b.y + 12, { aim: false, angle: a, kind: "p", speed: 70 })); }
if (ph === 3 && at.spin <= 0) { at.spin = 0.16; w.ebul.push({ x: rnd(12, W - 12), y: 40, vx: 0, vy: rnd(45, 70), kind: "p" }); }
} else {
if (at.aim <= 0) { at.aim = ph === 3 ? 1.1 : 1.5; [-0.22, 0, 0.22].forEach((a) => eShoot(w, b.x, keyY, { angle: a, kind: "o" })); sfx(w, "eshot"); }
if (at.can <= 0 && ph < 3) { at.can = 2.4; [-21, 21].forEach((dx) => { eShoot(w, b.x + dx, b.y + 16, { aim: false, speed: 130 }); eShoot(w, b.x + dx, b.y + 10, { aim: false, speed: 130 }); }); }
}
if (ph >= 2 && at.min <= 0) {
at.min = (ph === 3 ? 6 : 4.5) * (troll ? 0.8 : 1);
[-1, 1].forEach((side) => {
const type = pick(ENEMIES); const e = mkEnemy(w, type, 0, 0);
Object.assign(e, { minion: true, state: "dive", t: 0, sx: b.x + side * 20, sy: b.y + 8, x: b.x + side * 20, y: b.y + 8, tx: clamp(w.p.x + side * 20, 12, W - 12), dir: side, pattern: type.pattern, spd: diff(w).dive, fireAt: [0.35], fired: 0, hp: type.hp });
w.enemies.push(e);
});
}
if (ph === 2 && at.ring <= 0 && !troll) { at.ring = 3.2; const off = rnd(1); for (let i = 0; i < 10; i++) { const a = ((i + off) / 10) * Math.PI * 2; w.ebul.push({ x: b.x, y: keyY, vx: Math.cos(a) * 60, vy: Math.sin(a) * 60 + 20, kind: "o" }); } }
if (ph >= 2 && at.beam <= 0 && !w.beam) { at.beam = ph === 3 ? 7 : 6; w.beam = { side: troll ? 0 : Math.random() < 0.5 ? -1 : 1, t: 0, tele: 1, dur: 0.7, zapped: false }; sfx(w, "charge"); }
if (ph === 3 && at.spin <= 0 && !troll) { at.spin = 0.12; b.spin += 0.45; for (const arm of [0, Math.PI]) { const a = b.spin + arm; w.ebul.push({ x: b.x, y: keyY, vx: Math.cos(a) * 70, vy: Math.abs(Math.sin(a)) * 50 + 35, kind: "o" }); } }
}
function beamX(w) { return w.boss ? w.boss.x + w.beam.side * 21 : -99; }
/* --- scoring, damage, power-ups */
function addScore(w, pts) {
w.score += pts;
if (w.demo) return;
while (w.score >= w.nextLife) { w.nextLife += RULES.extraLifeEvery; if (w.lives < RULES.maxLives) { w.lives++; banner(w, "1UP!", "EXTRA LIFE", 1.8, C.teal); AU.oneUp(); } }
}
function killEnemy(w, e, bomb = false) {
e.dead = true; w.kills++;
const diving = e.state === "dive" || e.state === "tele" || e.minion;
w.streak++; w.streakT = RULES.comboWindow;
const m = Math.min(RULES.maxCombo, 1 + Math.floor(w.streak / RULES.comboStep));
if (m > w.mult) { w.mult = m; if (!w.demo) { popup(w, W / 2, 34, `COMBO X${m}`); AU.combo(); } }
const pts = e.type.points * (diving ? 2 : 1) * w.mult; addScore(w, pts);
if (diving || w.mult > 1) popup(w, e.x, e.y - 4, pts);
explode(w, e.x, e.y, [e.type.pal.a, e.type.pal.l, C.white, C.yellow], 16); shake(w, 1.5); sfx(w, "boom", false);
const ab = e.type.ability;
if (ab === "renew" && !e.renewed && !e.minion && !bomb) { // Auto-Renewal: back for another term
Object.assign(e, { dead: false, renewed: true, hp: e.type.hp, state: "return", t: 0, rx: slotPos(w, e).x, y: -12, flash: 0 }); w.kills--;
popup(w, e.x, 40, "RENEWED!");
return;
}
if (ab === "split" && e.type.child) { // Core Tax: two cores for the price of one
[-1, 1].forEach((side) => { const c = mkEnemy(w, e.type.child, 0, 0); Object.assign(c, { minion: true, state: "dive", t: 0, sx: e.x + side * 4, sy: e.y, x: e.x + side * 4, y: e.y, tx: clamp(w.p.x + side * 24, 12, W - 12), dir: side, pattern: "dive", spd: diff(w).dive * 1.1, fireAt: [], fired: 0, hp: 1 }); w.enemies.push(c); });
}
if (w.pups.length < 2 && Math.random() < RULES.dropChance + 0.02 * e.type.hp) {
const total = POWERUPS.reduce((s, p) => s + p.weight, 0); let r = rnd(total);
const P = POWERUPS.find((p) => (r -= p.weight) < 0) || POWERUPS[0];
w.pups.push({ id: P.id, P, x: clamp(e.x, 10, W - 10), y: e.y, t: 0 });
}
}
function hitEnemy(w, e, dmg, bomb = false) { e.hp -= dmg; e.flash = 0.08; if (e.hp <= 0) killEnemy(w, e, bomb); else sfx(w, "clink"); }
// player bullet on an enemy. Enterprise-Only Feature is armoured: plain shots only get through every other time,
// PXC spread shots and wingmen hit twice as hard.
function shotEnemy(w, e, b) {
if (e.type.ability === "armor") {
if (b.spread || b.wing) { hitEnemy(w, e, 2); return; }
e.armor = (e.armor || 0) + 1;
if (e.armor % 2) { explode(w, b.x, b.y, [C.faint, C.white], 3, 30); sfx(w, "clink"); return; }
}
hitEnemy(w, e, 1);
}
function hurtPlayer(w) {
const p = w.p; if (!p.alive || p.inv > 0) return false;
if (w.fx.shield) { w.fx.shield = false; p.inv = 1.2; shake(w, 3); explode(w, p.x, p.y, [C.teal, C.white], 12, 60); sfx(w, "shield"); return true; }
p.alive = false; p.deadT = 0; w.lives--; w.deathsThisWave++;
explode(w, p.x, p.y, [C.white, C.yellow, C.purple, C.lilac, C.red], 46, 120); shake(w, 8); w.flash = 0.6; sfx(w, "death");
w.fx = { pmm: 0, pxc: 0, op: 0, shield: false }; w.wing = []; w.streak = 0; w.mult = 1;
return true;
}
function applyPower(w, pu) {
const P = pu.P; let sub = P.sub;
if (P.id === "pmm") w.fx.pmm = P.duration;
else if (P.id === "xtrabackup") { if (w.fx.shield) { if (w.lives < RULES.maxLives) w.lives++; sub = P.subLife; } else w.fx.shield = true; }
else if (P.id === "operator") { w.fx.op = P.duration; if (!w.wing.length) w.wing = [-1, 1].map((side) => ({ side, x: w.p.x, y: w.p.y })); }
else if (P.id === "pxc") w.fx.pxc = P.duration;
else if (P.id === "toolkit") smartBomb(w);
addScore(w, 100); banner(w, P.banner, sub, 1.8, P.color); sfx(w, "power");
}
function smartBomb(w) {
w.ebul = []; w.flash = 1; shake(w, 6); sfx(w, "bomb");
for (let i = 0; i < 3; i++) w.parts.push({ ring: true, x: w.p.x, y: w.p.y, r: 1 + i * 10, life: 0.6, max: 0.6, c: [C.red, C.yellow, C.white][i], grow: 320 });
for (const e of w.enemies) if (!e.dead && e.y > -8 && e.y < H) hitEnemy(w, e, 2, true);
if (w.boss) damageBoss(w, 10, w.boss.x, w.boss.y);
}
/* --- player */
function updatePlayer(w, dt, ctl) {
const p = w.p;
if (!p.alive) return;
p.inv = Math.max(0, p.inv - dt); p.cd -= dt; p.jam = Math.max(0, (p.jam || 0) - dt);
let dx = (ctl.right ? 1 : 0) - (ctl.left ? 1 : 0), dy = (ctl.down ? 1 : 0) - (ctl.up ? 1 : 0);
if (ctl.ax) dx = ctl.ax; if (ctl.ay) dy = ctl.ay;
p.x = clamp(p.x + dx * 100 * dt + (ctl.dragX || 0), 9, W - 9);
p.y = clamp(p.y + dy * 80 * dt + (ctl.dragY || 0), H - 96, H - 24);
for (const m of w.wing) { m.x = lerp(m.x, p.x + m.side * 16, Math.min(1, dt * 10)); m.y = lerp(m.y, p.y + 4, Math.min(1, dt * 10)); }
const cap = (w.fx.pxc > 0 ? 12 : 5) + w.wing.length * 2;
if ((ctl.fire || ctl.auto) && p.cd <= 0 && p.jam <= 0 && w.pbul.length < cap) {
const add = (x, y, vx, flag) => w.pbul.push({ x, y, vx, vy: -270, ...flag });
add(p.x, p.y - 8, 0);
if (w.fx.pxc > 0) { add(p.x - 3, p.y - 6, -55, { spread: true }); add(p.x + 3, p.y - 6, 55, { spread: true }); }
for (const m of w.wing) add(m.x, m.y - 4, 0, { wing: true });
w.shots++; p.cd = 0.17; sfx(w, "laser", w.fx.pxc > 0);
}
}
function aiControl(w) {
const p = w.p, c = { fire: true };
if (!p.alive) return c;
let tx = W / 2;
const live = w.enemies.filter((e) => !e.dead && e.state !== "wait" && e.y > 0);
const divers = live.filter((e) => e.state === "dive" && e.y < p.y - 70);
const target = w.boss && w.boss.state === "fight" ? w.boss : divers.length ? divers.reduce((a, b) => (b.y > a.y ? b : a)) : live.length ? live.reduce((a, b) => (Math.abs(b.x - p.x) < Math.abs(a.x - p.x) ? b : a)) : null;
if (target) tx = target.x + (target === w.boss ? Math.sin(w.t) * 14 : 0);
const near = w.pups.find((u) => u.y > p.y - 70);
if (near) tx = near.x;
for (const b of w.ebul) { const dy = p.y - b.y; if (dy > 0 && dy < 60) { const fx = b.x + b.vx * (dy / Math.max(1, b.vy)); if (Math.abs(fx - p.x) < 9) { tx = p.x + (fx < p.x ? 28 : -28); break; } } }
for (const e of live) if (e.state === "dive" && e.y > p.y - 50 && e.y < p.y + 6 && Math.abs(e.x - p.x) < 14) tx = p.x + (e.x < p.x ? 30 : -30);
if (w.beam && Math.abs(beamX(w) - p.x) < 14) tx = p.x + (beamX(w) < p.x ? 30 : -30);
tx = clamp(tx, 12, W - 12);
if (tx < p.x - 2) c.left = true; else if (tx > p.x + 2) c.right = true;
return c;
}
/* --- one fixed step of the world */
function stepWorld(w, dt, ctl) {
w.t += dt; w.phaseT += dt; w.form.t += dt;
w.shake = Math.max(0, w.shake - dt * 22); w.flash = Math.max(0, w.flash - dt * 2.5);
if (w.banner && (w.banner.t += dt) > w.banner.dur) w.banner = null;
for (const k of ["pmm", "pxc", "op"]) if (w.fx[k] > 0) { w.fx[k] = Math.max(0, w.fx[k] - dt); if (k === "op" && !w.fx.op) w.wing = []; }
if (w.streakT > 0 && (w.streakT -= dt) <= 0) { w.streak = 0; w.mult = 1; }
const d = diff(w);
if (w.phase === "intro") {
updatePlayer(w, dt, ctl);
if (w.phaseT >= (w.demo ? 1.6 : w.wave === 1 ? 3.8 : 3)) { w.phase = "play"; w.phaseT = 0; w.spawnT = 0; if (w.isBoss) spawnBoss(w); }
} else {
if (w.phase === "play") {
w.spawnT += dt;
while (w.queue.length && w.queue[0].at <= w.spawnT) spawn(w, w.queue.shift());
if (!w.queue.length && w.enemies.every((e) => e.state !== "enter")) w.form.mix = Math.min(1, w.form.mix + dt * 0.6);
launchDives(w, dt, d); formationFire(w, dt, d);
}
updateEnemies(w, dt, d);
if (w.boss) updateBoss(w, dt);
if (w.beam) {
const bm = w.beam; bm.t += dt;
if (bm.t >= bm.tele && !bm.zapped) { bm.zapped = true; sfx(w, "zap"); shake(w, 3); }
if (bm.t > bm.tele + bm.dur || !w.boss) w.beam = null;
else if (bm.t >= bm.tele && Math.abs(beamX(w) - w.p.x) < 6) hurtPlayer(w);
}
updatePlayer(w, dt, w.phase === "over" ? {} : ctl);
}
// bullets
for (const b of w.pbul) { b.x += b.vx * dt; b.y += b.vy * dt; }
for (const b of w.ebul) { b.x += b.vx * dt; b.y += b.vy * dt; if (b.kind === "p") { b.t = (b.t || 0) + dt; b.x += Math.sin(b.t * 7) * 0.7; } }
// player bullets vs enemies and the boss
for (const b of w.pbul) {
if (b.hit) continue;
for (const e of w.enemies) {
if (e.dead || e.state === "wait" || Math.abs(b.x - e.x) > 6 || Math.abs(b.y - e.y) > 6) continue;
b.hit = true; w.hits++; shotEnemy(w, e, b); break;
}
const bs = w.boss;
if (!b.hit && bs && bs.state === "fight") {
const dx = Math.abs(b.x - bs.x), dy = b.y - bs.y;
if ((dx <= 17 && dy >= -15 && dy <= 14) || (dx <= 23 && dy >= 0 && dy <= 14)) { b.hit = true; w.hits++; damageBoss(w, 1, b.x, b.y); explode(w, b.x, b.y, [C.white, C.yellow], 2, 30); }
}
}
const p = w.p;
if (p.alive) {
for (const b of w.ebul) if (!b.hit && Math.abs(b.x - p.x) < 3.5 && Math.abs(b.y - (p.y + 1)) < 5) {
b.hit = true;
if (b.kind === "j") { if (p.inv <= 0) { p.jam = 1.6; popup(w, p.x, p.y - 14, "PAPERWORK!"); sfx(w, "clink"); } } else hurtPlayer(w);
break;
}
for (const e of w.enemies) if (!e.dead && e.state !== "wait" && e.state !== "form" && Math.abs(e.x - p.x) < 9 && Math.abs(e.y - p.y) < 8) { if (hurtPlayer(w)) killEnemy(w, e); break; }
for (const u of w.pups) if (!u.taken && Math.abs(u.x - p.x) < 10 && Math.abs(u.y - p.y) < 10) { u.taken = true; applyPower(w, u); }
}
for (const m of w.wing) {
for (const b of w.ebul) if (!b.hit && Math.abs(b.x - m.x) < 4 && Math.abs(b.y - m.y) < 4) { b.hit = true; m.dead = true; }
for (const e of w.enemies) if (!e.dead && (e.state === "dive" || e.state === "return") && Math.abs(e.x - m.x) < 8 && Math.abs(e.y - m.y) < 7) { m.dead = true; killEnemy(w, e); }
if (m.dead) { explode(w, m.x, m.y, [C.lilac, C.purple, C.white], 14, 70); sfx(w, "boom", false); }
}
w.wing = w.wing.filter((m) => !m.dead);
w.pbul = w.pbul.filter((b) => !b.hit && b.y > -8 && b.x > -8 && b.x < W + 8);
w.ebul = w.ebul.filter((b) => !b.hit && b.y < H + 8 && b.y > -20 && b.x > -8 && b.x < W + 8);
w.enemies = w.enemies.filter((e) => !e.dead);
for (const u of w.pups) { u.t += dt; u.y += 32 * dt; u.x += Math.sin(u.t * 3) * 0.25; }
w.pups = w.pups.filter((u) => !u.taken && u.y < H + 10);