Attract mode: 6-page cab loop using the idle attract + villain art

The title screen now cycles every 7s like a real cabinet:
  0 SELECT   — logo + portrait class cards (playable page, maze visible behind)
  1 STORY    — the closing-down shop painting + the last-night premise
  2 BESTIARY — 8 grunts with names ("they are not here for the music")
  3 BOSSES   — the 5 unkillables ("the airhorn does not work on everything")
  4 ARCHIVE  — the descending-staircase painting + the White Label
  5 SCORES   — hall of fame, or "NO NAMES ON THE WALL YET"

Any key or tap snaps back to page 0; keys 1-4 start the game from any page, so
the attract loop never costs you a coin. Info pages get a dimmed backdrop and
label backgrounds so art reads instead of fighting the maze behind it.

Wires up attract_shop + attract_archive (generated in batch 3, never used).

Bug caught while verifying: the backdrop was pushed into 5 page arrays, but the
per-page visibility loop sets each object true for its page then false again for
every later page — last write wins, so it never appeared. Shared objects can't
live in the page arrays; it's toggled explicitly in showAttract now.

Verified: full 0-1-2-3-4-5-0 cycle runs unattended, any-key returns to select,
1-4 starts from any page with the right class sprite, no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 23:23:54 +10:00
parent 2266e9feeb
commit e11a201d75
4 changed files with 152 additions and 6 deletions

View File

@ -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),
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

View File

@ -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);
}