diff --git a/assets/process_sprites.py b/assets/process_sprites.py index 58ba216..3332a6f 100644 --- a/assets/process_sprites.py +++ b/assets/process_sprites.py @@ -101,6 +101,8 @@ TABLE = { # full-art images: downscale + copy, never chroma-keyed (they aren't sprites) ART = { "logo": ("logo_marquee_b.png", 800), + "attract_shop": ("attract_shop.png", 720), + "attract_archive": ("attract_archive.png", 720), } diff --git a/public/sprites/attract_archive.png b/public/sprites/attract_archive.png new file mode 100644 index 0000000..4d7e836 Binary files /dev/null and b/public/sprites/attract_archive.png differ diff --git a/public/sprites/attract_shop.png b/public/sprites/attract_shop.png new file mode 100644 index 0000000..65e3c80 Binary files /dev/null and b/public/sprites/attract_shop.png differ diff --git a/src/GameScene.js b/src/GameScene.js index 98d631c..e02a219 100644 --- a/src/GameScene.js +++ b/src/GameScene.js @@ -42,7 +42,9 @@ const TEXTURE_KEYS = [ 'decal_cables', 'decal_cups', 'decal_flyers', 'decal_confetti', 'decal_tape', 'decal_sleeve', 'decal_dust', 'decal_puddle', 'port_digger', 'port_selector', 'port_promoter', 'port_soundtech', 'logo', + 'attract_shop', 'attract_archive', ]; +const ATTRACT_MS = 7000; // how long each attract page holds before cycling const BOSS_SCORE = { conductor: 5000, rpm78: 10000 }; const SHAZAM_SHOT_SPEED = 170; const SHAZAM_SHOT_DAMAGE = 12; @@ -170,6 +172,10 @@ export default class GameScene extends Phaser.Scene { if (this.state !== 'TITLE') return; const i = ['1', '2', '3', '4'].indexOf(ev.key); if (i >= 0) this.startGame(Object.keys(CLASSES)[i]); + else if (this.attractIndex !== 0) this.showAttract(0); // any key = back to the coin slot + }); + this.input.on('pointerdown', () => { + if (this.state === 'TITLE' && this.attractIndex !== 0) this.showAttract(0); }); this.setupTouch(); @@ -287,6 +293,7 @@ export default class GameScene extends Phaser.Scene { this.resetStats(); this.buildLevel(0); + this.buildAttract(); this.showTitle(); } @@ -1402,6 +1409,146 @@ export default class GameScene extends Phaser.Scene { this.sub.setText(`score ${this.score}\n\npress R`).setVisible(true); } + // The cab's attract loop: select -> story -> bestiary -> bosses -> archive -> scores. + // Page 0 is the playable one; any key or tap on another page snaps back to it. + buildAttract() { + const T = (y, txt, size, color, x = 480) => + this.add + .text(x, y, txt, { + fontFamily: 'monospace', + fontSize: `${size}px`, + color, + align: 'center', + backgroundColor: '#000000c0', + padding: { x: 10, y: 6 }, + }) + .setOrigin(0.5) + .setScrollFactor(0) + .setDepth(101) + .setVisible(false); + const IMG = (key, y, w, h) => + this.add + .image(480, y, key) + .setScrollFactor(0) + .setDepth(100) + .setDisplaySize(w, h) + .setVisible(false); + const CREATURE = (key, x, y, label, scale = 1.6) => { + const objs = []; + if (this.textures.exists(key)) { + objs.push(this.add.image(x, y, key).setScrollFactor(0).setDepth(101).setScale(scale).setVisible(false)); + } + objs.push( + this.add + .text(x, y + 54, label, { + fontFamily: 'monospace', + fontSize: '11px', + color: '#00e5ff', + align: 'center', + backgroundColor: '#000000d0', + padding: { x: 5, y: 3 }, + }) + .setOrigin(0.5) + .setScrollFactor(0) + .setDepth(101) + .setVisible(false), + ); + return objs; + }; + + const story = [ + IMG('attract_shop', 260, 620, 465), + T(60, 'THE LAST NIGHT', 30, '#ff00ff'), + T(560, + "The city's oldest record shop shuts at dawn.\n" + + 'The aisles rearrange. The bargain bins boil over.\n' + + 'The Sound Guy walks through the walls like he owns them.\n\n' + + 'DIG DEEP. NAME EVERY TUNE. REACH THE BOTTOM.', + 15, '#ffffff'), + ]; + + const bestiary = [ + T(60, 'KNOW YOUR CROWD', 28, '#ff00ff'), + ...CREATURE('poser', 170, 210, 'DRINK SPILLER'), + ...CREATURE('nerd', 370, 210, 'GEAR NERD'), + ...CREATURE('pirate', 570, 210, 'RECORD PIRATE'), + ...CREATURE('ghoul', 770, 210, 'CRATE GHOUL'), + ...CREATURE('shazam', 170, 380, 'SHAZAM ZOMBIE'), + ...CREATURE('gatekeeper', 370, 380, 'GATEKEEPER'), + ...CREATURE('scalper', 570, 380, 'VINYL SCALPER'), + ...CREATURE('warped', 770, 380, 'WARPED RECORD'), + T(520, 'they are not here for the music', 15, '#aaaaaa'), + ]; + + const bosses = [ + T(60, 'THE UNKILLABLE', 28, '#ff00ff'), + ...CREATURE('soundguy', 190, 230, 'THE SOUND GUY', 2), + ...CREATURE('firemarshal', 480, 230, 'THE FIRE MARSHAL', 2), + ...CREATURE('queen', 770, 230, 'THE MITE QUEEN', 1.4), + ...CREATURE('conductor', 300, 430, 'THE SPECTRAL CONDUCTOR', 1.5), + ...CREATURE('rpm78', 640, 430, 'THE MONSTROUS 78 RPM', 1.3), + T(560, 'the airhorn does not work on everything', 15, '#aaaaaa'), + ]; + + const archive = [ + IMG('attract_archive', 260, 620, 465), + T(60, 'THE WHITE LABEL', 30, '#ff00ff'), + T(560, + 'No artist. No title. No year.\n' + + 'The only tune that has never been trainspotted.\n\n' + + 'It is down there. It knows you are coming.', + 15, '#ffffff'), + ]; + + this.scoreBoard = T(300, '', 22, '#ffd11a'); + const scores = [T(120, 'HALL OF FAME', 30, '#ff00ff'), this.scoreBoard]; + + // info pages get a dimmed backdrop so art and labels aren't fighting the maze + this.attractBackdrop = this.add + .rectangle(480, 360, 960, 720, 0x000000, 0.8) + .setScrollFactor(0) + .setDepth(98) + .setVisible(false); + // NOT added to the page arrays: it's shared, and the per-page visibility loop + // would set it true for its page then false again for every later one. + + this.attractPages = [ + [this.titleLogo, ...this.classButtons, this.sub], + story, + bestiary, + bosses, + archive, + scores, + ]; + this.attractIndex = 0; + this.time.addEvent({ + delay: ATTRACT_MS, + loop: true, + callback: () => { + if (this.state === 'TITLE') this.showAttract((this.attractIndex + 1) % this.attractPages.length); + }, + }); + } + + showAttract(i) { + this.attractIndex = i; + this.attractPages.forEach((page, n) => page.forEach((o) => o.setVisible(n === i))); + this.attractBackdrop.setVisible(i !== 0); // page 0 keeps the maze visible behind it + if (i === 5) { + const hs = this.loadScores(); + this.scoreBoard.setText( + hs.length + ? hs.map((s, n) => `${n + 1}. ${s.initials} ${s.score}`).join('\n') + : 'NO NAMES ON THE WALL YET', + ); + } + } + + hideAttract() { + if (this.attractPages) this.attractPages.forEach((page) => page.forEach((o) => o.setVisible(false))); + if (this.attractBackdrop) this.attractBackdrop.setVisible(false); + } + showTitle() { this.state = 'TITLE'; this.physics.pause(); @@ -1414,10 +1561,8 @@ export default class GameScene extends Phaser.Scene { this.sub .setText((hs ? `HIGH SCORES ${hs.replace(/\n/g, ' ')}` : 'dig deep. name every tune.') + nbLine) .setPosition(480, 630) - .setFontSize(15) - .setVisible(true); - this.titleLogo.setVisible(true); - this.classButtons.forEach((b) => b.setVisible(true)); + .setFontSize(15); + this.showAttract(0); } startGame(clsKey) { @@ -1427,8 +1572,7 @@ export default class GameScene extends Phaser.Scene { this.physics.resume(); this.resetStats(); this.hideOverlay(); - this.titleLogo.setVisible(false); - this.classButtons.forEach((b) => b.setVisible(false)); + this.hideAttract(); this.state = 'PLAYING'; this.buildLevel(0); }