-
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.
-
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)
-
Layout bug fix The Galaga on top of the screen is not visibile on the browser page, please fix this
-
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.
Created
June 14, 2026 14:37
-
-
Save ivanfioravanti/4c89cba147a8428b25f2e25dfe891664 to your computer and use it in GitHub Desktop.
GalagaPrompt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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> MOVE <b>SPACE</b> FIRE<br> | |
| <b>ENTER</b> START <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> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment