Skip to content

Instantly share code, notes, and snippets.

@ivanfioravanti
Created June 14, 2026 14:37
Show Gist options
  • Select an option

  • Save ivanfioravanti/4c89cba147a8428b25f2e25dfe891664 to your computer and use it in GitHub Desktop.

Select an option

Save ivanfioravanti/4c89cba147a8428b25f2e25dfe891664 to your computer and use it in GitHub Desktop.
GalagaPrompt
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>GALAGA — 1981 Namco Arcade</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
<style>
/* ============================================================
GALAGA — single-file arcade remake
Native Namco resolution: 224 × 288 (portrait), CRT cabinet
============================================================ */
:root{
--cab-red: #b01818;
--cab-dark: #5a0c0c;
--cab-shadow: #2a0505;
--marquee-gold:#ffcf33;
--marquee-red:#ff3b30;
--screen-bg:#000010;
--neon:#5ad7ff;
}
*{ box-sizing:border-box; margin:0; padding:0; }
html,body{
height:100%;
background:
radial-gradient(120% 90% at 50% 0%, #1a1626 0%, #0a0810 55%, #050307 100%);
font-family:'Press Start 2P', monospace;
color:#eee;
overflow:hidden;
-webkit-font-smoothing:none;
}
/* faint room texture / dust */
body::before{
content:"";
position:fixed; inset:0; pointer-events:none; z-index:0;
background-image:
repeating-linear-gradient(90deg, rgba(255,255,255,.012) 0 2px, transparent 2px 4px);
mix-blend-mode:screen;
}
.stage{
position:fixed; inset:0;
display:flex; align-items:center; justify-content:center;
z-index:1;
}
.cabinet{
transform-origin:center center;
display:flex; flex-direction:column; align-items:center;
filter: drop-shadow(0 30px 50px rgba(0,0,0,.7));
}
/* ---------- MARQUEE ---------- */
.marquee{
width:730px; height:92px;
background:
linear-gradient(180deg,#2a0606,#1a0303);
border:6px solid #3a0a0a;
border-bottom:none;
border-radius:14px 14px 4px 4px;
box-shadow:
inset 0 0 30px rgba(0,0,0,.9),
inset 0 4px 10px rgba(255,255,255,.06),
0 0 30px rgba(255,40,40,.25);
display:flex; align-items:center; justify-content:center;
position:relative;
overflow:hidden;
}
.marquee::after{ /* light-tube glow line */
content:""; position:absolute; inset:0;
background:repeating-linear-gradient(90deg, rgba(255,220,120,.04) 0 6px, transparent 6px 12px);
pointer-events:none;
}
.marquee h1{
font-size:42px; letter-spacing:4px;
background:linear-gradient(180deg,#fff6c8 0%, var(--marquee-gold) 35%, var(--marquee-red) 100%);
-webkit-background-clip:text; background-clip:text; color:transparent;
text-shadow:0 0 18px rgba(255,180,40,.55), 0 0 40px rgba(255,60,60,.35);
filter:drop-shadow(0 2px 0 #7a1200);
}
.marquee .sub{
position:absolute; bottom:8px; left:0; right:0;
text-align:center; font-size:8px; color:#ffd9a0; opacity:.7; letter-spacing:2px;
}
/* ---------- BEZEL + SCREEN ---------- */
.bezel{
width:780px;
background:linear-gradient(180deg,#260404,#140202 60%,#0d0101);
border:6px solid #3a0a0a;
border-top:none;
border-radius:0 0 18px 18px;
padding:26px 30px 30px;
display:flex; justify-content:center;
box-shadow:inset 0 8px 24px rgba(0,0,0,.9);
}
.screen-wrap{
position:relative;
border-radius:22px / 26px;
padding:14px;
background:#000;
box-shadow:
0 0 0 4px #0a0a0a,
0 0 0 14px #1c0606,
inset 0 0 60px rgba(0,0,0,.95);
}
.screen{
position:relative;
width:672px; height:864px; /* 224×288 × 3 */
background:var(--screen-bg);
border-radius:14px / 16px;
overflow:hidden;
}
canvas#game{
display:block;
width:100%; height:100%;
image-rendering:pixelated;
image-rendering:crisp-edges;
}
/* CRT scanlines + vignette + curvature glow */
.crt-scan{
position:absolute; inset:0; pointer-events:none; z-index:5;
background:repeating-linear-gradient(0deg,
rgba(0,0,0,.22) 0 2px, rgba(0,0,0,0) 2px 4px);
border-radius:14px / 16px;
mix-blend-mode:multiply;
}
.crt-vig{
position:absolute; inset:0; pointer-events:none; z-index:6;
background:radial-gradient(120% 120% at 50% 50%,
rgba(0,0,0,0) 55%, rgba(0,0,0,.55) 100%);
border-radius:14px / 16px;
}
.crt-glow{
position:absolute; inset:0; pointer-events:none; z-index:4;
box-shadow:inset 0 0 90px rgba(90,215,255,.06);
border-radius:14px / 16px;
}
/* ---------- HUD OVERLAY ---------- */
.hud{ position:absolute; inset:0; z-index:3; pointer-events:none; user-select:none; }
.hud-row{ position:absolute; left:0; right:0; display:flex; }
.hud-top{ top:14px; padding:0 22px; }
.hud-bot{ bottom:14px; padding:0 22px; align-items:flex-end; }
.hud-block{ display:flex; flex-direction:column; align-items:center; }
.hud-label{ font-size:11px; color:#ff3b3b; letter-spacing:1px; text-shadow:0 0 6px rgba(255,60,60,.6); }
.hud-label.gold{ color:#ffcf33; text-shadow:0 0 6px rgba(255,200,40,.6); }
.hud-val{ font-size:18px; color:#fff; margin-top:7px; text-shadow:0 0 6px rgba(120,200,255,.45); }
.hud-left{ margin-right:auto; }
.hud-right{ margin-left:auto; text-align:right; }
.lives{ display:flex; gap:6px; margin-top:6px; height:22px; align-items:center; }
.lives svg{ width:30px; height:22px; display:block; filter:drop-shadow(0 0 4px rgba(120,200,255,.4)); }
.stage-flag{
display:flex; align-items:center; gap:7px; font-size:13px; color:#ffcf33;
text-shadow:0 0 6px rgba(255,200,40,.5);
}
.stage-flag .flag{
width:14px; height:18px; background:#ff3b3b; clip-path:polygon(0 0,100% 0,70% 35%,100% 70%,0 70%);
box-shadow:0 0 6px rgba(255,60,60,.6);
}
/* center messages */
.msg{
position:absolute; left:0; right:0; z-index:3; pointer-events:none;
text-align:center; display:none;
}
.msg.show{ display:block; }
.msg-ready{ top:46%; font-size:26px; color:#fff; letter-spacing:3px; text-shadow:0 0 10px rgba(120,200,255,.7); }
.msg-ready.blink{ animation:blink 0.7s steps(1) infinite; }
.msg-stage{ top:18%; font-size:30px; color:#ffcf33; letter-spacing:2px; text-shadow:0 0 12px rgba(255,200,40,.7); }
.msg-stage small{ display:block; font-size:13px; color:#5ad7ff; margin-top:10px; letter-spacing:2px; }
.msg-challenge{ top:33%; font-size:22px; color:#5ad7ff; letter-spacing:2px; text-shadow:0 0 12px rgba(90,215,255,.7); }
.msg-perfect{ top:42%; font-size:24px; color:#ffcf33; text-shadow:0 0 14px rgba(255,200,40,.8); }
.msg-perfect small{ display:block; font-size:13px; color:#fff; margin-top:10px; }
.msg-over{ top:38%; font-size:34px; color:#ff3b3b; letter-spacing:3px; text-shadow:0 0 16px rgba(255,60,60,.8); }
.msg-over small{ display:block; font-size:13px; color:#ffcf33; margin-top:18px; }
/* title / attract overlay */
.attract{
position:absolute; inset:0; z-index:7; display:flex; flex-direction:column;
align-items:center; justify-content:center; text-align:center;
background:radial-gradient(120% 90% at 50% 40%, rgba(0,0,30,.25), rgba(0,0,0,.55));
}
.attract.hide{ display:none; }
.attract .title{
font-size:64px; letter-spacing:6px;
background:linear-gradient(180deg,#fff6c8,#ffcf33 40%,#ff3b30);
-webkit-background-clip:text; background-clip:text; color:transparent;
text-shadow:0 0 26px rgba(255,160,40,.5);
filter:drop-shadow(0 3px 0 #7a1200);
animation:floaty 3s ease-in-out infinite;
}
.attract .tagline{ font-size:12px; color:#5ad7ff; letter-spacing:3px; margin-top:14px; text-shadow:0 0 8px rgba(90,215,255,.6);}
.attract .prompt{
margin-top:46px; font-size:16px; color:#fff; letter-spacing:2px;
animation:blink 1s steps(1) infinite;
}
.attract .keys{
margin-top:30px; font-size:9px; color:#aaa; line-height:1.9; letter-spacing:1px;
}
.attract .keys b{ color:#ffcf33; }
.attract .hs{ position:absolute; bottom:34px; font-size:12px; color:#ffcf33; letter-spacing:2px; text-shadow:0 0 8px rgba(255,200,40,.5); }
@keyframes blink{ 50%{ opacity:0; } }
@keyframes floaty{ 50%{ transform:translateY(-6px); } }
.flash{ /* player-death white flash */
position:absolute; inset:0; z-index:6; pointer-events:none;
background:#fff; opacity:0;
}
/* ---------- CONTROL PANEL ---------- */
.panel{
width:780px; margin-top:0;
background:linear-gradient(180deg,#140202,#2a0606 12%,#1a0303);
border:6px solid #3a0a0a; border-top:none;
border-radius:0 0 22px 22px;
padding:22px 40px 26px;
display:flex; align-items:center; justify-content:space-between;
box-shadow:0 18px 30px rgba(0,0,0,.6);
}
.panel .stick{
width:70px; height:70px; border-radius:50%;
background:radial-gradient(circle at 35% 30%, #ff5b5b, #b01818 55%, #5a0c0c);
box-shadow:inset 0 -8px 14px rgba(0,0,0,.6), 0 6px 10px rgba(0,0,0,.6);
position:relative;
transform-origin:50% 96px; /* pivot at the shaft base */
transition:transform .08s cubic-bezier(.2,.8,.3,1);
}
.panel .stick.left { transform:rotate(-14deg); }
.panel .stick.right { transform:rotate(14deg); }
.panel .stick::after{
content:""; position:absolute; left:50%; bottom:-26px; transform:translateX(-50%);
width:24px; height:30px; background:linear-gradient(180deg,#2a2a2a,#0a0a0a); border-radius:6px;
}
.panel .btns{ display:flex; gap:26px; }
.panel .btn{
width:60px; height:60px; border-radius:50%;
background:radial-gradient(circle at 35% 30%, #ffe48a, #ffcf33 50%, #b88600);
box-shadow:inset 0 -8px 14px rgba(0,0,0,.5), 0 6px 10px rgba(0,0,0,.6);
display:flex; align-items:center; justify-content:center;
font-size:9px; color:#5a3b00; text-align:center; line-height:1.3;
cursor:pointer; user-select:none;
-webkit-tap-highlight-color:transparent;
}
.panel .btn{ transition: transform .04s, box-shadow .04s, filter .04s; }
.panel .btn:active,
.panel .btn.pressed{
transform:translateY(4px);
box-shadow:inset 0 -3px 8px rgba(0,0,0,.55), 0 2px 4px rgba(0,0,0,.55);
filter:brightness(.86);
}
.panel .info{ font-size:9px; color:#caa; line-height:2; text-align:right; letter-spacing:1px; }
.panel .info b{ color:#ffcf33; }
.panel .credit{ text-align:center; font-size:8px; color:#888; letter-spacing:2px; line-height:2; }
.panel .credit b{ color:#ff5b5b; }
.top-tools{
position:fixed; top:14px; right:14px; z-index:20;
display:flex; gap:8px;
}
.top-tools button{
font-family:'Press Start 2P'; font-size:9px; color:#ccc;
background:rgba(20,20,30,.6); border:1px solid #444; border-radius:6px;
padding:9px 11px; cursor:pointer; letter-spacing:1px;
}
.top-tools button:hover{ color:#fff; border-color:#888; }
.hint{
position:fixed; bottom:8px; left:0; right:0; text-align:center;
font-size:8px; color:#666; letter-spacing:2px; z-index:20;
}
/* touch controls (mobile) */
.touch{ display:none; }
@media (hover:none) and (pointer:coarse){
.touch{ display:flex; }
.panel{ display:none; }
}
.touch{
position:fixed; bottom:0; left:0; right:0; z-index:25;
justify-content:space-between; padding:14px 18px 22px;
pointer-events:none;
}
.touch .pad{ display:flex; gap:14px; pointer-events:auto; }
.touch .tbtn{
width:68px; height:68px; border-radius:50%;
background:rgba(255,255,255,.08); border:2px solid rgba(255,255,255,.25);
color:#fff; font-size:9px; display:flex; align-items:center; justify-content:center;
user-select:none; -webkit-tap-highlight-color:transparent;
}
.touch .fire{ background:rgba(255,60,60,.25); border-color:rgba(255,90,90,.5); width:84px; height:84px; font-size:11px; }
</style>
</head>
<body>
<div class="top-tools">
<button id="btnMute" title="toggle sound">🔊 SOUND</button>
<button id="btnPause" title="pause / resume">⏸ PAUSE</button>
</div>
<div class="stage">
<div class="cabinet" id="cabinet">
<!-- MARQUEE -->
<div class="marquee">
<h1>GALAGA</h1>
<div class="sub">© 1981 NAMCO · ARCADE REMAKE</div>
</div>
<!-- BEZEL / CRT -->
<div class="bezel">
<div class="screen-wrap">
<div class="screen" id="screen">
<canvas id="game" width="224" height="288"></canvas>
<!-- HUD -->
<div class="hud">
<div class="hud-row hud-top">
<div class="hud-block hud-left">
<div class="hud-label">1UP</div>
<div class="hud-val" id="hudScore">0</div>
</div>
<div class="hud-block">
<div class="hud-label gold">HIGH SCORE</div>
<div class="hud-val" id="hudHigh">20000</div>
</div>
<div class="hud-block hud-right" style="visibility:hidden">
<div class="hud-label">2UP</div>
<div class="hud-val">0</div>
</div>
</div>
<div class="hud-row hud-bot">
<div class="hud-left">
<div class="lives" id="hudLives"></div>
</div>
<div class="hud-right stage-flag">
<div class="flag"></div><span id="hudStage">1</span>
</div>
</div>
</div>
<!-- center messages -->
<div class="msg msg-stage" id="msgStage">STAGE 1</div>
<div class="msg msg-challenge" id="msgChall">CHALLENGING STAGE</div>
<div class="msg msg-ready blink" id="msgReady">READY</div>
<div class="msg msg-perfect" id="msgPerfect"></div>
<div class="msg msg-over" id="msgOver">GAME OVER<small>SCORE 0</small></div>
<div class="flash" id="flash"></div>
<!-- attract / title -->
<div class="attract" id="attract">
<div class="title">GALAGA</div>
<div class="tagline">DEFEND THE GALAXY</div>
<div class="prompt" id="attractPrompt">PRESS ENTER TO PLAY</div>
<div class="keys">
<b>← →</b> / <b>A D</b> &nbsp; MOVE &nbsp;&nbsp; <b>SPACE</b> FIRE<br>
<b>ENTER</b> START &nbsp;&nbsp; <b>P</b> PAUSE
</div>
<div class="hs" id="attractHs">HIGH SCORE 20000</div>
</div>
<!-- CRT overlays -->
<div class="crt-glow"></div>
<div class="crt-scan"></div>
<div class="crt-vig"></div>
</div>
</div>
</div>
<!-- CONTROL PANEL -->
<div class="panel">
<div class="stick" title="joystick"></div>
<div class="credit">
<b>INSERT COIN</b><br>1 PLAY 3 LIVES<br>BONUS @ 20K / 70K
</div>
<div class="btns">
<div class="btn" id="cpStart">START<br>ENTER</div>
<div class="btn" id="cpFire">FIRE<br>SPACE</div>
</div>
</div>
<!-- touch controls -->
<div class="touch" id="touch">
<div class="pad">
<div class="tbtn" data-k="left">◀</div>
<div class="tbtn" data-k="right">▶</div>
</div>
<div class="pad">
<div class="tbtn fire" data-k="fire">FIRE</div>
</div>
</div>
</div>
</div>
<div class="hint">ARROWS MOVE · SPACE FIRE · ENTER START · P PAUSE · M MUTE</div>
<script>
"use strict";
/* =====================================================================
GALAGA — engine
Native res 224×288. Fixed-ish 60fps loop (dt-scaled).
===================================================================== */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const W = 224, H = 288;
/* ---------- tiny math helpers ---------- */
const clamp = (v,a,b)=> v<a?a : v>b?b : v;
const rand = (a,b)=> a + Math.random()*(b-a);
const randi = (a,b)=> Math.floor(rand(a,b+1));
const choose= arr => arr[Math.floor(Math.random()*arr.length)];
const TAU = Math.PI*2;
/* =====================================================================
1. PIXEL SPRITES (hand-coded 16×16 bitmaps)
===================================================================== */
const PALETTE = {
// shared colours
W:'#ffffff', R:'#ff4040', K:'#9aa6c8',
Y:'#ffd000', O:'#ff8a1e',
// bee (yellow body, blue accents)
B:'#2a4ce8', b:'#6a8aff',
// butterfly
M:'#a01040', m:'#ff6f9c', P:'#ff85b0',
// boss (green body)
G:'#30c848', D:'#0a6020'
};
/* each sprite = { colors:{char:hex}, frames:[ rows[] ] } */
function makeSprite(colors, frames){
return { colors, frames };
}
const BOSS_F0 = [
"................",
".......GG.......",
".......GG.......",
"....GGGGGGGG....",
"...GGGYYYYGGG...",
"...GWWGGGGWWG...",
"..GGWWGGGGWWGG..",
"..GGDDGGGGDDGG..",
"..GGGGGGGGGGGG..",
"...GGGGGGGGGG...",
"....GGGYYGGG....",
".....GGYYGG.....",
"......GGGG......",
"................",
"................",
"................"
];
const BOSS_F1 = [
"................",
".....G....G.....",
"....GG....GG....",
"....GGGGGGGG....",
"...GGGYYYYGGG...",
"...GWWGGGGWWG...",
"..GGWWGGGGWWGG..",
"..GGDDGGGGDDGG..",
"..GGGGGGGGGGGG..",
"...GGGGGGGGGG...",
"....GGGYYGGG....",
".....GGYYGG.....",
"......GGGG......",
"................",
"................",
"................"
];
const SPR = {
fighter: makeSprite({},[[
"................",
".......WW.......",
".......WW.......",
"......WWWW......",
"......WKKW......",
".....WWWWWW.....",
"....WWWWWWWW....",
"..RWWWWWWWWWWR..",
"...WWWWWWWWWW...",
"....WWWWWWWW....",
"....RWW..WWR....",
".....R....R.....",
"......RRRR......",
"................",
"................",
"................"
]]),
bee: makeSprite({},[
[ /* frame 0 */
"................",
"......B..B......",
".....BB..BB.....",
"....YYYYYYYY....",
"...YYYYYYYYYY...",
"...YWWYYYYWWY...",
"...YYYYYYYYYY...",
"...YYYYYYYYYY...",
"....YYYYYYYY....",
".....YYBBYY.....",
"......B..B......",
"................",
"................",
"................",
"................",
"................"
],
[ /* frame 1 */
"................",
".......BB.......",
".......BB.......",
"....YYYYYYYY....",
"...YYYYYYYYYY...",
"...YWWYYYYWWY...",
"...YYYYYYYYYY...",
"...YYYYYYYYYY...",
"....YYYYYYYY....",
".....BYYYYB.....",
".....B....B.....",
"................",
"................",
"................",
"................",
"................"
]
]),
butterfly: makeSprite({},[
[ /* frame 0 */
"................",
"......R..R......",
".....RR..RR.....",
"....RRRRRRRR....",
"...RMWRRRRWMR...",
"...RMMRRRRMMR...",
"..RRMMRRRRMMRR..",
"..RRMRRRRRRMRR..",
"...RMRRRRRRMR...",
"....RRRRRRRR....",
".....RRRRRR.....",
"......RRRR......",
"................",
"................",
"................",
"................"
],
[ /* frame 1 */
"................",
".....R....R.....",
"....RR....RR....",
"...RRRRRRRRRR...",
"..RRMWRRRRWMRR..",
"..RRMMRRRRMMRR..",
".RRMMRRRRRRMMRR.",
".RMRRRRRRRRRRMR.",
"..RRRRRRRRRRRR..",
"...RRRRRRRRRR...",
"....RRRRRRRR....",
".....RRRRRR.....",
"................",
"................",
"................",
"................"
]
]),
boss: makeSprite({},[ BOSS_F0, BOSS_F1 ]),
bossHit: makeSprite({G:'#5ad7ff',Y:'#ffffff',D:'#1a6080'},[ BOSS_F0, BOSS_F1 ])
};
/* build offscreen-canvas cache for every (sprite, frame[, flipped]) */
const cache = new Map();
function keyOf(name, frame, flip){ return name+'|'+frame+(flip?'|f':''); }
function buildSprite(name, frame, flip){
const def = SPR[name];
const data = def.frames[frame];
const h = data.length, w = data[0].length;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const g = c.getContext('2d');
for(let y=0;y<h;y++){
const row = data[y];
for(let x=0;x<w;x++){
// each cell is 1 char; palette keys map via def.colors flag (true)
const ch = row[x];
if(ch==='.' || ch===' ') continue;
const cmap = def.colors || {};
g.fillStyle = (typeof cmap[ch]==='string') ? cmap[ch] : (PALETTE[ch] || '#ff00ff');
g.fillRect(x, y, 1, 1);
}
}
if(flip){
const f = document.createElement('canvas');
f.width=w; f.height=h;
const fg = f.getContext('2d');
fg.translate(0,h); fg.scale(1,-1);
fg.drawImage(c,0,0);
return f;
}
return c;
}
function getSprites(name){
// pre-build all frames + a flipped variant when needed
const def = SPR[name];
const out = [];
for(let f=0; f<def.frames.length; f++){
let c = cache.get(keyOf(name,f,false));
if(!c){ c = buildSprite(name,f,false); cache.set(keyOf(name,f,false), c); }
out.push(c);
}
return out;
}
function getSpriteFlipped(name){
const def = SPR[name];
let c = cache.get(keyOf(name,0,true));
if(!c){ c = buildSprite(name,0,true); cache.set(keyOf(name,0,true), c); }
return c;
}
// pre-warm
['fighter','bee','butterfly','boss','bossHit'].forEach(getSprites);
getSpriteFlipped('fighter');
function drawSpr(name, frame, x, y){
const arr = getSprites(name);
ctx.drawImage(arr[frame % arr.length], x|0, y|0);
}
function drawSprFlip(name, frame, x, y){
// vertical flip (for captured ship pointing down)
const arr = getSprites(name);
const src = arr[frame % arr.length];
ctx.save();
ctx.translate(0, y + src.height); ctx.scale(1,-1);
ctx.drawImage(src, x|0, 0);
ctx.restore();
}
/* =====================================================================
2. STARFIELD
===================================================================== */
const STARS = [];
const STAR_COLORS = ['#ffffff','#5ad7ff','#ffd400','#ff5b5b','#7dff7d','#ff9bff'];
function initStars(){
STARS.length = 0;
for(let i=0;i<70;i++){
STARS.push({
x: Math.random()*W,
y: Math.random()*H,
spd: rand(0.15, 0.9),
col: choose(STAR_COLORS),
ph: Math.random()*TAU,
tw: rand(0.4,1)
});
}
}
function drawStars(t){
for(const s of STARS){
const a = 0.35 + 0.65*Math.abs(Math.sin(t*0.004 + s.ph));
ctx.globalAlpha = a * s.tw;
ctx.fillStyle = s.col;
ctx.fillRect(s.x|0, s.y|0, 1, s.speed>0.5?2:1);
}
ctx.globalAlpha = 1;
}
function updateStars(f){
for(const s of STARS){
s.y += s.spd * f;
if(s.y > H){ s.y = -1; s.x = Math.random()*W; s.col = choose(STAR_COLORS); }
}
}
/* =====================================================================
3. AUDIO (Web Audio — retro blips)
===================================================================== */
let AC = null, masterGain = null, muted = false;
function initAudio(){
if(AC) return;
try{
AC = new (window.AudioContext||window.webkitAudioContext)();
masterGain = AC.createGain();
masterGain.gain.value = 0.18;
masterGain.connect(AC.destination);
}catch(e){ AC = null; }
}
function blip(freq, dur, type, vol, slideTo){
if(!AC || muted) return;
const t = AC.currentTime;
const o = AC.createOscillator();
const g = AC.createGain();
o.type = type||'square';
o.frequency.setValueAtTime(freq, t);
if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(1,slideTo), t+dur);
g.gain.setValueAtTime((vol||0.5), t);
g.gain.exponentialRampToValueAtTime(0.0008, t+dur);
o.connect(g); g.connect(masterGain);
o.start(t); o.stop(t+dur+0.02);
}
function noise(dur, vol){
if(!AC || muted) return;
const t = AC.currentTime;
const n = Math.floor(AC.sampleRate*dur);
const buf = AC.createBuffer(1, n, AC.sampleRate);
const d = buf.getChannelData(0);
for(let i=0;i<n;i++) d[i] = (Math.random()*2-1) * (1 - i/n);
const src = AC.createBufferSource(); src.buffer = buf;
const g = AC.createGain(); g.gain.value = vol||0.5;
const filt = AC.createBiquadFilter(); filt.type='bandpass'; filt.frequency.value=800;
src.connect(filt); filt.connect(g); g.connect(masterGain);
src.start(t);
}
const SFX = {
shoot: ()=> blip(880, 0.10, 'square', 0.35, 320),
enemyShot: ()=> blip(420, 0.10, 'sawtooth', 0.18, 200),
hit: ()=> blip(620, 0.08, 'square', 0.4, 900),
bossHit:()=> blip(300, 0.12, 'square', 0.45, 700),
explode:()=> { noise(0.32, 0.6); blip(180, 0.3, 'sawtooth', 0.4, 60); },
bigExplode:()=>{ noise(0.6, 0.8); blip(110, 0.5, 'sawtooth', 0.5, 40); },
capture:()=> { blip(660,0.5,'sine',0.4,120); },
bonus: ()=> { [523,659,784,1047].forEach((f,i)=> setTimeout(()=>blip(f,0.12,'square',0.4), i*70)); },
extra: ()=> { [659,784,988,1175,1568].forEach((f,i)=> setTimeout(()=>blip(f,0.10,'square',0.4), i*60)); },
coin: ()=> { blip(988,0.06,'square',0.3); setTimeout(()=>blip(1319,0.10,'square',0.3),60); },
start: ()=> {
// Galaga-ish fanfare
const seq = [
[523,0],[523,120],[523,240],[415,360],[659,520],[523,720],[415,840],
[659,1000],[784,1120],[440,1240]
];
seq.forEach(([f,d])=> setTimeout(()=>blip(f,0.16,'square',0.4), d));
}
};
/* =====================================================================
4. GAME STATE
===================================================================== */
const STATE = {
ATTRACT:'attract', INTRO:'intro', READY:'ready',
PLAY:'play', CHALLENGE:'challenge', CLEAR:'clear',
GAMEOVER:'gameover', PAUSED:'paused'
};
let game = null;
function freshGame(){
return {
mode: STATE.ATTRACT,
score: 0,
high: parseInt(localStorage.getItem('galaga_high')||'20000',10),
lives: 3,
stage: 1,
bonus1: false, bonus2: false,
challenge: false,
timer: 0,
subTimer: 0,
paused: false,
flashT: 0,
perfectHits: 0,
perfectTotal: 0
};
}
/* entities */
let player, enemies, bullets, eBullets, particles, captures, beams;
const FORM = {
x: 49, dx: 18, y: 50, dy: 16,
drift: 0
};
/* =====================================================================
5. INPUT
===================================================================== */
const keys = { left:false, right:false, fire:false };
const justPressed = { start:false, fire:false, pause:false };
window.addEventListener('keydown', e=>{
if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' '].includes(e.key)) e.preventDefault();
const k = e.key.toLowerCase();
if(k==='arrowleft'||k==='a') keys.left=true;
if(k==='arrowright'||k==='d') keys.right=true;
if(k===' '||k==='arrowup'||k==='w'){ if(!keys.fire) justPressed.fire=true; keys.fire=true; }
if(k==='enter') justPressed.start=true;
if(k==='p') justPressed.pause=true;
if(k==='m') toggleMute();
initAudio();
});
window.addEventListener('keyup', e=>{
const k = e.key.toLowerCase();
if(k==='arrowleft'||k==='a') keys.left=false;
if(k==='arrowright'||k==='d') keys.right=false;
if(k===' '||k==='arrowup'||k==='w') keys.fire=false;
});
/* control panel + touch */
function bindBtn(id, downFn, upFn){
const el = document.getElementById(id);
if(!el) return;
const dn = e=>{ e.preventDefault(); downFn&&downFn(); initAudio(); };
const up = e=>{ e.preventDefault(); upFn&&upFn(); };
el.addEventListener('touchstart', dn, {passive:false});
el.addEventListener('mousedown', dn);
el.addEventListener('touchend', up, {passive:false});
el.addEventListener('mouseup', up);
}
bindBtn('cpStart', ()=> justPressed.start=true);
bindBtn('cpFire', ()=>{ if(!keys.fire) justPressed.fire=true; keys.fire=true; }, ()=> keys.fire=false);
document.querySelectorAll('.tbtn').forEach(el=>{
const k = el.getAttribute('data-k');
const dn = e=>{ e.preventDefault(); initAudio();
if(k==='left') keys.left=true;
if(k==='right') keys.right=true;
if(k==='fire'){ if(!keys.fire) justPressed.fire=true; keys.fire=true; }
};
const up = e=>{ e.preventDefault();
if(k==='left') keys.left=false;
if(k==='right') keys.right=false;
if(k==='fire') keys.fire=false;
};
el.addEventListener('touchstart', dn,{passive:false});
el.addEventListener('touchend', up,{passive:false});
el.addEventListener('touchcancel', up,{passive:false});
});
document.getElementById('btnMute').addEventListener('click', toggleMute);
document.getElementById('btnPause').addEventListener('click', ()=> justPressed.pause=true);
function toggleMute(){
muted = !muted;
document.getElementById('btnMute').textContent = muted?'🔇 MUTED':'🔊 SOUND';
}
/* =====================================================================
6. ENTITY HELPERS
===================================================================== */
function addScore(n){
game.score += n;
if(game.score > game.high){
game.high = game.score;
localStorage.setItem('galaga_high', String(game.high));
}
// bonus lives
if(!game.bonus1 && game.score >= 20000){ game.bonus1=true; game.lives++; SFX.extra(); }
else if(!game.bonus2 && game.score >= 70000){ game.bonus2=true; game.lives++; SFX.extra(); }
}
function explode(x,y,big){
const n = big?22:12;
const cols = ['#ffffff','#ffd400','#ff8a1e','#ff3b30'];
for(let i=0;i<n;i++){
const a = Math.random()*TAU;
const sp = rand(0.4, big?2.6:1.8);
particles.push({
x, y, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp,
life: rand(18,38), max:38,
col: choose(cols), sz: Math.random()<0.4?2:1
});
}
(big?SFX.bigExplode:SFX.explode)();
}
function fireBullet(){
if(!player || player.dead) return;
const onScreen = bullets.length;
if(onScreen >= 2) return;
if(player.fireCd > 0) return;
player.fireCd = 7;
if(player.double){
bullets.push({x:player.x+2, y:player.y+2, vy:-4.2});
bullets.push({x:player.x+11,y:player.y+2, vy:-4.2});
} else {
bullets.push({x:player.x+7, y:player.y+2, vy:-4.2});
}
SFX.shoot();
}
/* =====================================================================
7. STAGE BUILD
===================================================================== */
function slotX(col){ return FORM.x + col*FORM.dx; }
function slotY(row){ return FORM.y + row*FORM.dy; }
function buildStage(n){
game.challenge = (n % 4 === 0);
enemies = []; bullets = []; eBullets = []; particles = []; captures = []; beams = [];
player.double = false;
game.perfectHits = 0;
if(game.challenge){ buildChallenge(n); return; }
// Real Galaga formation: 4 Bosses centered in the top row (cols 2-5),
// 2 rows of Butterflies (Goei), 2 rows of Bees (Zako). 8 columns wide.
const layout = [
{ row:0, cols:[2,3,4,5], type:'boss' },
{ row:1, cols:[0,1,2,3,4,5,6,7], type:'butterfly' },
{ row:2, cols:[0,1,2,3,4,5,6,7], type:'butterfly' },
{ row:3, cols:[0,1,2,3,4,5,6,7], type:'bee' },
{ row:4, cols:[0,1,2,3,4,5,6,7], type:'bee' }
];
let _id = 0;
for(const L of layout){
for(const c of L.cols){
const type = L.type;
enemies.push({
type, col:c, row:L.row,
sx: slotX(c), sy: slotY(L.row),
x: (c<4? -20 : W+20), y: -30 - L.row*20 - c*3, // start off-screen
vx:0, vy:0,
state:'entry', // entry -> formation -> dive -> return
entryT: 20 + c*7 + L.row*8,
frame:0, frameT:0,
hp: type==='boss'?2:1,
hit:false,
fireT: rand(60,200),
dive:null,
carrying:false, // boss carrying captured ship
captureDive:false, // boss attempting capture
beaming:false,
id: _id++
});
}
}
}
/* challenge stage: waves of non-firing enemies flying patterns */
let challWaves = [], challSpawnT = 0;
function buildChallenge(n){
challWaves = [];
// 8 waves of 5 enemies each = 40, varied patterns
const patterns = [
{from:'left', loop:false, arc:0},
{from:'right', loop:false, arc:0},
{from:'left', loop:true, arc:1},
{from:'right', loop:true, arc:-1},
{from:'left', loop:true, arc:-1},
{from:'right', loop:true, arc:1},
{from:'top', loop:true, arc:0},
{from:'top', loop:true, arc:0}
];
let baseDelay = 40;
patterns.forEach((p, wi)=>{
const type = (wi<1)?'boss': (wi<4?'butterfly':'bee');
for(let i=0;i<5;i++){
challWaves.push({
type, active:false, dead:false, hit:false,
t:0, delay: baseDelay + wi*150 + i*12,
from:p.from, loop:p.loop, arc:p.arc,
offset:i*22, x:-40, y:-40, frame:0, frameT:0
});
game.perfectTotal++;
}
});
challSpawnT = 0;
}
/* =====================================================================
8. DIVE PATHS
===================================================================== */
function startDive(e, capture){
e.state = 'dive';
e.captureDive = !!capture && e.type==='boss' && !e.carrying && !player.double && !hasCaptured();
// Large sweeping arc that begins exactly at the formation slot, then swoops
// down through the playfield and loops back up — the signature Galaga dive.
const ecx = e.x+8, ecy = e.y+8; // slot centre
let sideDir = (ecx < W/2 ? 1 : -1); // sweep toward screen centre…
if(Math.random()<0.25) sideDir = -sideDir; // …with occasional variety
const sideX = sideDir*64; // arc centre offset to the side
const upY = 26; // arc centre above the slot
const cx = ecx + sideX, cy = ecy - upY;
const R = Math.hypot(sideX, upY); // ≈ 69
const startAng = Math.atan2(ecy - cy, ecx - cx); // vector centre → slot
// sweep so the enemy passes through the BOTTOM of the arc (the attack run)
const angSpd = (0.020 + game.stage*0.0014) * (sideDir>0 ? -1 : 1);
e.dive = {
t:0, dir:sideDir, R, cx, cy,
ang:startAng, angSpd,
driftY: 0.5 + game.stage*0.02, driftX: sideDir*-0.03,
swept:0,
fireT: rand(25,70),
capture: e.captureDive, captureStart:false, beamFrames:0
};
}
function hasCaptured(){
return enemies.some(en=> en.carrying);
}
function aliveFormationCount(){
let n=0; for(const e of enemies) if(e.state!=='dead') n++; return n;
}
function updateDive(e, f){
const d = e.dive;
d.t += f;
e.frameT += f; if(e.frameT>8){ e.frameT=0; e.frame=1-e.frame; }
// --- CAPTURE hover/beam phase: boss stops and beams down over the player ---
if(d.capture && d.captureStart){
d.beamFrames += f;
e.beaming = true;
const hx = player.x - 8 + Math.sin(d.beamFrames*0.12)*12;
const hy = player.y - 56;
e.x += (hx - e.x)*0.09*f;
e.y += (hy - e.y)*0.09*f;
if(d.beamFrames>14 && d.beamFrames<120){
if(Math.abs((e.x+8)-(player.x+8))<18 && player.y>e.y && !player.dead){
doCapture(e);
return true;
}
}
if(d.beamFrames>150){
e.beaming=false; e.state='return'; e.x=e.sx; e.y=-20; e.dive=null; return false;
}
return false;
}
// --- Normal large-arc movement (the sweeping Galaga dive) ---
d.ang += d.angSpd * f;
d.swept += Math.abs(d.angSpd * f);
d.cy += d.driftY * f; // arc centre sinks → loop descends through field
d.cx += d.driftX * f;
e.x = (d.cx + d.R*Math.cos(d.ang)) - 8;
e.y = (d.cy + d.R*Math.sin(d.ang)) - 8;
// begin capture hover once the boss has swept low enough
if(d.capture && !d.captureStart && e.y > player.y - 90){
d.captureStart = true;
}
// fire aimed shots during the downward half of the arc
const sinA = Math.sin(d.ang);
if(!d.capture){
d.fireT -= f;
if(d.fireT<=0 && sinA>0.15 && e.y<H-24 && e.y>8){
d.fireT = rand(40,110)/(1+game.stage*0.07);
const dx=(player.x+8)-(e.x+8), dy=(player.y)-(e.y+8);
const dist=Math.hypot(dx,dy)||1, sp=1.6+game.stage*0.04;
eBullets.push({x:e.x+8, y:e.y+10, vx:dx/dist*sp, vy:dy/dist*sp});
SFX.enemyShot();
}
}
// return to formation after a near-full loop, or if it leaves the bottom
if(d.swept > Math.PI*1.9 || e.y > H+30){
e.beaming=false; e.state='return'; e.x=e.sx; e.y=-20; e.dive=null;
}
return false;
}
function doCapture(e){
// player loses current ship; ship becomes cargo
SFX.capture();
e.carrying = true;
e.captureDive = false;
e.beaming = false;
e.state = 'return';
e.x = e.sx; e.y = -20;
e.dive = null;
// consume a life
game.lives--;
player.invuln = 120;
player.x = W/2-8;
if(game.lives <= 0){
player.dead = true;
gameOver();
} else {
// respawn continues with remaining ships
}
}
function updateReturn(e, f){
// glide down into slot
const ty = e.sy, tx = e.sx;
e.y += 1.4*f;
e.x += (tx - e.x)*0.06*f;
e.frameT += f; if(e.frameT>10){e.frameT=0; e.frame=1-e.frame;}
if(e.y >= ty){ e.y=ty; e.x=tx; e.state='formation'; }
}
/* =====================================================================
9. UPDATE LOOP
===================================================================== */
let diveTimer = 90;
function update(f, dt, t){
const g = game;
FORM.drift = Math.sin(t*0.0006)*3; // formation is nearly static (real Galaga)
updateStars(f);
// particles always update
for(const p of particles){ p.x+=p.vx*f; p.y+=p.vy*f; p.life-=f; }
particles = particles.filter(p=>p.life>0);
if(g.flashT>0) g.flashT-=f;
switch(g.mode){
case STATE.ATTRACT: updateAttract(f,t); break;
case STATE.INTRO: updateIntro(f,t); break;
case STATE.READY: updateReady(f,t); break;
case STATE.PLAY: updatePlay(f,t); break;
case STATE.CHALLENGE:updateChallenge(f,t); break;
case STATE.CLEAR: updateClear(f,t); break;
case STATE.GAMEOVER:updateGameOver(f,t); break;
}
}
function togglePause(){
if(game.mode===STATE.PLAY || game.mode===STATE.CHALLENGE){
game._prev = game.mode; game.mode = STATE.PAUSED; showMsg();
} else if(game.mode===STATE.PAUSED){
game.mode = game._prev || STATE.PLAY; showMsg();
}
}
function updateAttract(f,t){
// a few flavor enemies fly across
if(justPressed.start || justPressed.fire){
startGame();
return;
}
}
function startGame(){
game = freshGame();
game.lives = 3;
game.mode = STATE.INTRO;
SFX.start();
initAudio();
setupPlayer();
buildStage(1);
game.timer = 0;
hideAttract();
showMsg();
}
function setupPlayer(){
player = {
x: W/2-8, y: 232,
double:false, dead:false,
fireCd:0, invuln:60, blink:0
};
}
function updateIntro(f,t){
game.timer += f;
// animate enemy entry
updateEntry(f);
// allow moving but not firing
updatePlayerMove(f);
if(game.timer > 150){
game.mode = STATE.READY;
game.timer = 0;
showMsg();
}
}
function updateEntry(f){
// Enemies swoop in from the top corners on a curved arc to their slots,
// assembling the formation the way the arcade does.
for(const e of enemies){
if(e.state!=='entry') continue;
e.entryT -= f;
if(e.entryT>0) continue;
if(e.entryP===undefined){
e.entryP = 0;
e.esx = (e.col<4 ? -16 : W); // nearest top corner
e.esy = -16;
}
e.entryP += 0.022*f;
const p = Math.min(1, e.entryP);
e.x = e.esx + (e.sx - e.esx)*p + Math.sin(p*Math.PI)*(e.col<4?1:-1)*8;
e.y = e.esy + (e.sy - e.esy)*p;
e.frameT += f; if(e.frameT>8){e.frameT=0; e.frame=1-e.frame;}
if(p>=1){ e.x=e.sx; e.y=e.sy; e.state='formation'; }
}
}
function updateReady(f,t){
game.timer += f;
updatePlayerMove(f);
updateEntry(f);
if(game.timer > 110){
game.mode = game.challenge? STATE.CHALLENGE : STATE.PLAY;
game.timer = 0;
diveTimer = 90;
showMsg();
}
}
function updatePlay(f,t){
game.timer += f;
updateEntry(f); // advance any still-entering enemies
updatePlayerMove(f);
if(keys.fire || justPressed.fire) fireBullet();
if(player.fireCd>0) player.fireCd-=f;
if(player.invuln>0) player.invuln-=f;
// bullets
updateBullets(f);
// enemies
let remaining=0;
for(const e of enemies){
if(e.state==='dead') continue;
remaining++;
if(e.state==='formation'){ updateFormation(e,f,t); }
else if(e.state==='dive'){ updateDive(e,f); }
else if(e.state==='return'){ updateReturn(e,f); }
// 'entry' state is advanced by updateEntry() above
}
// schedule dives
const alive = remaining;
diveTimer -= f;
if(diveTimer<=0 && alive>0){
diveTimer = Math.max(28, (150 - game.stage*8) * (alive/40)) + rand(0,30);
triggerDive();
}
// enemy bullets vs player
collideEnemyBullets(f);
// body collision with diving enemies
collideBodies(f);
if(remaining===0){
game.mode = STATE.CLEAR; game.timer = 0; showMsg();
}
}
function updateFormation(e,f,t){
// sit in slot (with drift), animate
e.x = e.sx + FORM.drift;
e.y = e.sy;
e.frameT += f; if(e.frameT>12){e.frameT=0; e.frame=1-e.frame;}
}
function triggerDive(){
// gather eligible formation enemies
const eligible = enemies.filter(e=> e.state==='formation');
if(eligible.length===0) return;
// chance for a capture dive if a boss is available & conditions allow
const bosses = eligible.filter(e=> e.type==='boss');
let count = 1;
if(Math.random()<0.35 && eligible.length>3) count = 2;
// sometimes prefer bees (front rows)
for(let i=0;i<count;i++){
let pick;
if(i===0 && bosses.length>0 && !hasCaptured() && !player.double && game.stage>1 && Math.random()<0.4){
pick = choose(bosses);
startDive(pick, true);
} else {
pick = choose(eligible);
startDive(pick, false);
}
}
}
function updatePlayerMove(f){
if(player.dead) return;
const sp = 2.1;
if(keys.left) player.x -= sp*f;
if(keys.right) player.x += sp*f;
player.x = clamp(player.x, 6, W-22);
}
function updateBullets(f){
// player bullets
for(const b of bullets) b.y += b.vy*f;
bullets = bullets.filter(b=> b.y > -10);
// player bullets vs enemies
for(let i=bullets.length-1;i>=0;i--){
const b = bullets[i];
let hit=false;
for(const e of enemies){
if(e.state==='dead' || e.state==='entry') continue;
if(Math.abs(b.x-(e.x+8))<8 && Math.abs(b.y-(e.y+8))<8){
hitEnemy(e, b);
hit=true;
break;
}
}
if(hit){ bullets.splice(i,1); }
}
// enemy bullets
for(const b of eBullets){ b.x += b.vx*f; b.y += b.vy*f; }
eBullets = eBullets.filter(b=> b.y<H+6 && b.y>-6 && b.x>-6 && b.x<W+6);
}
function hitEnemy(e, b){
if(e.type==='boss' && e.hp>1){
e.hp--; e.hit=true; SFX.bossHit();
explode(b.x, b.y, false);
return;
}
// destroy
const diving = (e.state==='dive');
let pts = 0;
if(e.type==='bee') pts = diving?100:50;
else if(e.type==='butterfly') pts = diving?100:80; // butterflies 80/100
else if(e.type==='boss') pts = diving?400:150;
// boss carrying ship: free the ship
if(e.type==='boss' && e.carrying){
pts = 1000;
freeCapturedShip(e);
}
addScore(pts);
explode(e.x+8, e.y+8, e.type==='boss');
e.state='dead';
SFX.hit();
// remove bullet particles already spawned
}
function freeCapturedShip(boss){
// spawn a captured ship that flies down to dock with player -> double
captures.push({ x:boss.x+8, y:boss.y+18, t:0, dock:false });
boss.carrying=false;
}
function collideEnemyBullets(f){
if(player.dead || player.invuln>0) {
// still cull bullets that leave
return;
}
for(let i=eBullets.length-1;i>=0;i--){
const b = eBullets[i];
if(Math.abs(b.x-(player.x+8))<8 && Math.abs(b.y-(player.y+8))<8){
eBullets.splice(i,1);
killPlayer();
return;
}
}
}
function collideBodies(f){
if(player.dead || player.invuln>0) return;
for(const e of enemies){
if(e.state!=='dive') continue;
if(e.captureDive) continue; // capture bosses don't body-kill
if(Math.abs((e.x+8)-(player.x+8))<10 && Math.abs((e.y+8)-(player.y+8))<10){
// collision kills player; enemy survives (faithful)
killPlayer();
return;
}
}
}
function killPlayer(){
if(player.dead) return;
player.dead = true;
explode(player.x+8, player.y+8, true);
game.flashT = 14;
game.lives--;
if(game.lives<=0){
setTimeout(()=> gameOver(), 900);
} else {
setTimeout(()=>{
setupPlayerRespawn();
}, 1100);
}
}
function setupPlayerRespawn(){
player.dead=false;
player.x = W/2-8;
player.invuln = 130;
player.double=false; // lose double on death
}
function gameOver(){
game.mode = STATE.GAMEOVER;
game.timer = 0;
showMsg();
setTimeout(()=>{ game.mode = STATE.ATTRACT; showAttract(); }, 4200);
}
function updateClear(f,t){
game.timer += f;
// update remaining particles, drift enemies none
if(game.timer > 110){
game.stage++;
buildStage(game.stage);
game.mode = STATE.INTRO;
game.timer = 0;
setupPlayer();
showMsg();
}
}
/* ---- CHALLENGE STAGE ---- */
function updateChallenge(f,t){
game.timer += f;
updatePlayerMove(f);
if(keys.fire || justPressed.fire) fireBullet();
if(player.fireCd>0) player.fireCd-=f;
challSpawnT += f;
// spawn due waves
for(const w of challWaves){
if(!w.active && !w.dead && challSpawnT >= w.delay){
w.active=true; w.t=0;
}
}
// update active
for(const w of challWaves){
if(!w.active || w.dead) continue;
w.t += f;
moveChallenge(w, f);
w.frameT += f; if(w.frameT>7){w.frameT=0; w.frame=1-w.frame;}
}
// bullets vs challenge enemies
updateBulletsChallenge(f);
// check all done
const remaining = challWaves.filter(w=> !w.dead && !w.hit).length;
const stillToSpawn = challWaves.filter(w=> !w.active && !w.hit).length;
if(remaining===0 && stillToSpawn===0 && game.timer>120){
// end challenge
const allHit = challWaves.every(w=> w.hit);
if(allHit && game.perfectHits>=game.perfectTotal){
addScore(10000);
showPerfect();
}
game.mode = STATE.CLEAR; game.timer=0; showMsg();
}
}
function moveChallenge(w, f){
// Looping, crossing flight paths (synchronized arcade challenge choreography)
const tt = w.t;
if(w.from==='left' || w.from==='right'){
const dir = w.from==='left'?1:-1;
const across = (w.from==='left'? -24 : W+24) + dir*tt*1.6;
const loopY = 92 + 48*Math.sin(tt*0.05) + (w.loop? 18*Math.sin(tt*0.10):0);
const loopX = (w.loop? 14*Math.cos(tt*0.10)*dir : 0);
w.x = across + loopX + w.offset*0.2*dir;
w.y = loopY;
} else { // top
w.x = 112 + 78*Math.sin(tt*0.045 + w.arc) + w.offset*0.25;
w.y = -24 + tt*1.5 + (w.loop? 42*Math.sin(tt*0.07):0);
}
if(w.x>W+34 || w.x<-34 || w.y>H+34){
w.dead=true; // escaped (counts as not-hit)
}
}
function updateBulletsChallenge(f){
for(const b of bullets) b.y += b.vy*f;
bullets = bullets.filter(b=> b.y>-10);
for(let i=bullets.length-1;i>=0;i--){
const b = bullets[i];
let hit=false;
for(const w of challWaves){
if(!w.active||w.hit||w.dead) continue;
if(Math.abs(b.x-(w.x+8))<8 && Math.abs(b.y-(w.y+8))<8){
w.hit=true; w.dead=true;
let pts = w.type==='boss'?400 : w.type==='butterfly'?160 : 100;
addScore(pts);
explode(w.x+8,w.y+8, w.type==='boss');
SFX.hit();
game.perfectHits++;
hit=true; break;
}
}
if(hit) bullets.splice(i,1);
}
}
/* =====================================================================
10. RENDER
===================================================================== */
function render(t){
ctx.fillStyle = '#000010';
ctx.fillRect(0,0,W,H);
drawStars(t);
// tractor beams (capture dives)
if(enemies){
for(const e of enemies){ if(e.beaming) drawBeam(e.x+8, e.y+8, t); }
}
// enemies
if(game && enemies){
for(const e of enemies){
if(e.state==='dead') continue;
const onScreen = (e.x>-20 && e.x<W+20 && e.y>-20 && e.y<H+20);
if(!onScreen && e.state!=='entry') {
// still update-drawn below? skip offscreen
}
if(e.x<-20||e.x>W+4) continue;
let name = e.type;
if(e.type==='boss' && e.hit) name='bossHit';
drawSpr(name, e.frame, e.x, e.y);
if(e.carrying){
drawSprFlip('fighter', 0, e.x, e.y+10);
}
}
}
// challenge enemies
if(game && game.mode===STATE.CHALLENGE){
for(const w of challWaves){
if(!w.active||w.hit||w.dead) continue;
drawSpr(w.type, w.frame, w.x, w.y);
}
}
// captured ships docking
if(captures){
for(const c of captures){
drawSprFlip('fighter', 0, c.x-8, c.y);
}
}
// player
if(game && player && !player.dead){
const blink = player.invuln>0 && Math.floor(t/60)%2===0;
if(!blink){
drawSpr('fighter', 0, player.x, player.y);
if(player.double) drawSpr('fighter', 0, player.x-9, player.y);
}
}
// bullets
ctx.fillStyle = '#ffffaa';
for(const b of bullets){
ctx.fillRect(b.x|0, b.y|0, 1, 4);
}
ctx.fillStyle = '#ff5b5b';
for(const b of eBullets){
ctx.fillRect(b.x|0, b.y|0, 2, 2);
ctx.fillStyle='#ffaa55'; ctx.fillRect(b.x|0, (b.y+2)|0, 2,1); ctx.fillStyle='#ff5b5b';
}
// particles
for(const p of particles){
ctx.globalAlpha = clamp(p.life/p.max,0,1);
ctx.fillStyle = p.col;
ctx.fillRect(p.x|0, p.y|0, p.sz, p.sz);
}
ctx.globalAlpha = 1;
// flash
if(game && game.flashT>0){
document.getElementById('flash').style.opacity = clamp(game.flashT/14,0,0.8);
} else {
document.getElementById('flash').style.opacity = 0;
}
}
function drawBeam(x0, y0, t){
// Iconic Galaga tractor beam: a swirling, widening cone of light downward.
const segs = 34;
ctx.globalAlpha = 0.6;
for(let i=0;i<segs;i++){
const yy = y0 + i*2;
if(yy>H+4) break;
const frac = i/segs;
const widen = 2 + frac*16; // cone widens as it falls
const spin = i*0.55 + t*0.025;
ctx.fillStyle = (i%2===0)?'#9bffb0':'#5ad7ff';
// swirling edges
ctx.fillRect((x0 + widen*Math.cos(spin))|0, yy|0, 1, 2);
ctx.fillRect((x0 - widen*Math.cos(spin))|0, yy|0, 1, 2);
if(i%3===0) ctx.fillRect(x0|0, yy|0, 1, 1); // bright core
}
ctx.globalAlpha = 1;
}
/* =====================================================================
11. HUD / OVERLAYS
===================================================================== */
const elScore = document.getElementById('hudScore');
const elHigh = document.getElementById('hudHigh');
const elLives = document.getElementById('hudLives');
const elStage = document.getElementById('hudStage');
const elMsgStage = document.getElementById('msgStage');
const elMsgChall = document.getElementById('msgChall');
const elMsgReady = document.getElementById('msgReady');
const elMsgPerfect = document.getElementById('msgPerfect');
const elMsgOver = document.getElementById('msgOver');
const elAttract = document.getElementById('attract');
const elAttractHs = document.getElementById('attractHs');
const FIGHTER_SVG = `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
<polygon fill="#fff" points="8,1 10,6 10,8 15,12 11,12 11,10 10,10 10,14 6,14 6,10 5,10 5,12 1,12 6,8 6,6"/>
<polygon fill="#ff3b30" points="2,11 4,9 4,11"/>
<polygon fill="#ff3b30" points="14,11 12,9 12,11"/>
</svg>`;
function showAttract(){
elAttract.classList.remove('hide');
elAttractHs.textContent = 'HIGH SCORE ' + (game?game.high:20000);
}
function hideAttract(){ elAttract.classList.add('hide'); }
function showMsg(){
// hide all
elMsgStage.classList.remove('show');
elMsgChall.classList.remove('show');
elMsgReady.classList.remove('show');
elMsgPerfect.classList.remove('show');
elMsgOver.classList.remove('show');
const g = game; if(!g) return;
if(g.mode===STATE.INTRO){
if(g.challenge){
elMsgChall.textContent='CHALLENGING STAGE';
elMsgChall.classList.add('show');
} else {
elMsgStage.innerHTML = 'STAGE '+g.stage+'<small>'+(g.challenge?'':'GET READY')+'</small>';
elMsgStage.classList.add('show');
}
} else if(g.mode===STATE.READY){
elMsgReady.classList.add('show');
} else if(g.mode===STATE.GAMEOVER){
elMsgOver.innerHTML = 'GAME OVER<small>SCORE '+g.score+'</small>';
elMsgOver.classList.add('show');
} else if(g.mode===STATE.PAUSED){
elMsgReady.textContent='PAUSED';
elMsgReady.classList.remove('blink');
elMsgReady.classList.add('show');
}
if(g.mode!==STATE.READY){ elMsgReady.textContent='READY'; elMsgReady.classList.add('blink'); }
if(g.mode===STATE.PAUSED){ elMsgReady.textContent='PAUSED'; elMsgReady.classList.remove('blink'); }
}
function showPerfect(){
elMsgPerfect.innerHTML = 'PERFECT!<small>FIGHTER BONUS 10000</small>';
elMsgPerfect.classList.add('show');
SFX.bonus();
setTimeout(()=> elMsgPerfect.classList.remove('show'), 2200);
}
function updateHUD(){
if(!game) return;
elScore.textContent = String(game.score).padStart(1,'0');
elHigh.textContent = String(game.high);
elStage.textContent = String(game.stage);
// lives icons (lives-1 reserve icons, since current is in play)
const reserve = Math.max(0, game.lives-1);
let html='';
for(let i=0;i<reserve;i++) html += FIGHTER_SVG;
elLives.innerHTML = html;
}
/* cabinet controls reflect live input */
const elStick = document.querySelector('.panel .stick');
const elFireBtn = document.getElementById('cpFire');
function updatePanel(){
if(elStick){
elStick.classList.toggle('left', !!(keys.left && !keys.right));
elStick.classList.toggle('right', !!(keys.right && !keys.left));
}
if(elFireBtn) elFireBtn.classList.toggle('pressed', !!keys.fire);
}
/* =====================================================================
12. MAIN LOOP
===================================================================== */
let last = performance.now();
function loop(t){
let dt = (t-last)/1000; last = t;
dt = Math.min(dt, 0.05);
const f = dt*60;
if(game){
if(justPressed.pause) togglePause();
if(game.mode!==STATE.PAUSED){
update(f, dt, t);
// advance captures docking animation
for(const c of (captures||[])){
c.y += 1.2*f;
c.t += f;
if(player && !c.dock && c.y > player.y - 20 && !player.dead && !player.double){
player.double = true; c.dock=true;
SFX.bonus();
}
}
captures = (captures||[]).filter(c=> !c.dock);
}
render(t);
updateHUD();
updatePanel();
} else {
drawStars(t);
}
// reset edge triggers
justPressed.start=false; justPressed.fire=false; justPressed.pause=false;
requestAnimationFrame(loop);
}
/* =====================================================================
13. BOOT
===================================================================== */
function boot(){
game = freshGame();
enemies=[]; bullets=[]; eBullets=[]; particles=[]; captures=[]; player=null;
initStars();
// a couple of decorative enemies for attract? keep simple
showAttract();
updateHUD();
requestAnimationFrame(loop);
}
boot();
/* responsive cabinet fit */
function fitCabinet(){
const cab = document.getElementById('cabinet');
const w = window.innerWidth, h = window.innerHeight;
const scale = Math.min(w/(cab.offsetWidth+30), h/(cab.offsetHeight+10), 1.15);
cab.style.transform = `scale(${scale})`;
}
window.addEventListener('resize', fitCabinet);
window.addEventListener('load', fitCabinet);
setTimeout(fitCabinet, 50);
setTimeout(fitCabinet, 500);
</script>
</body>
</html>
  1. Initial build request Build a pixel-perfect Galaga arcade game in a single self-contained HTML file with inline CSS and JS. Replicate the original 1981 Namco arcade cabinet experience with full fidelity to the original rules, sprite designs, and mechanics.

  2. Fidelity review (with a reference screenshot) please review sprites and how ships attack, because galaga was different [Image #1] here an example. But search on internet for more details (accompanied by the screenshot)

  3. Layout bug fix The Galaga on top of the screen is not visibile on the browser page, please fix this

  4. Cabinet control animation request Can we animate the joystick so that it moves left center right based on key pressed by the player? It would be great adding a fire button that seems pressed when player fires to ships.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment