feat: replace text titles with poster art backgrounds and animated logo

- IntroScene: use bg_main.png poster art as full background, layer
  logo.png with a breathing tween aligned to the baked wordmark via
  fitted similarity transform constants, and add a double-stroke
  "Press any key" prompt (replaced auto-advance with input-only trigger).
- MainMenuScene: replace text title with bg_menu.png background and
  centered logo.png overlay; add semi-transparent background pill to
  instructions text for readability over the treeline.
- LevelSelectScene: add bg_menu.png background and background pill to
  back button text for contrast.
- assetManifest: register bg_main, logo, and bg_menu textures.
- New assets: bg_menu.png, ctc.png, paid.png, bg_main.png, logo.png.
This commit is contained in:
Brian Fertig 2026-08-20 14:35:21 -06:00
parent e7cb1d907a
commit 6cf184fd4d
7 changed files with 125 additions and 29 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
assets/images/ctc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/images/paid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

View File

@ -2,6 +2,35 @@ import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { playMenuMusic } from '../util/music.js';
// bg_main.png (the poster art) already has the "MONSTERPLEX" wordmark baked
// into its top-left corner, tilted a few degrees. logo.png is that same
// wordmark as a standalone transparent-background graphic, meant to sit
// exactly on top of the baked version so it can be the one animated
// element - but logo.png's own art has a slightly different built-in tilt
// than the baked version, so simply overlaying it flat left a visible
// double edge. The constants below come from fitting a similarity
// transform (rotation + uniform scale + translation) between the "M" and
// "X" letter centroids measured in each image - the exact position/scale/
// rotation that maps logo.png's wordmark onto bg_main's - expressed as
// fractions of bg_main.png's native 1672x941 pixels (position/width) or
// plain degrees (rotation) so they scale correctly regardless of
// WORLD_SCALE/canvas size.
const LOGO_CENTER_X_FRAC = 0.25205;
const LOGO_CENTER_Y_FRAC = 0.16541;
const LOGO_WIDTH_FRAC = 0.75215;
const LOGO_ASPECT = 752 / 1392; // logo.png's own native height/width
// Negative = counter-clockwise (Phaser's angle is positive-clockwise) -
// logo.png's own art is tilted ~2.4deg CCW already; the baked wordmark is
// tilted ~7.6deg CCW, so this is the remaining ~5.2deg to close the gap.
const LOGO_ROTATION_DEG = -8.22351;
// Centered horizontally on the 5-icon row (DRIVE/JUMP/etc, itself centered
// around x=878 of bg_main.png's 1672px width), vertically in the gap
// between the jumping bus's tires (bottom edge ~y=700) and that icon row's
// own top edge (~y=760).
const PROMPT_X_FRAC = 0.45511;
const PROMPT_Y_FRAC = 0.77077;
export default class IntroScene extends Phaser.Scene {
constructor() {
super('Intro');
@ -11,36 +40,84 @@ export default class IntroScene extends Phaser.Scene {
this.cameras.main.setBackgroundColor('#8fc7e8');
playMenuMusic(this);
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 60 * WORLD_SCALE, 'MONSTERPLEX', {
fontFamily: 'monospace',
fontSize: `${52 * WORLD_SCALE}px`,
color: '#1a1f29',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add.image(0, 0, 'bg_main').setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'a school bus survival ride', {
fontFamily: 'monospace',
fontSize: `${18 * WORLD_SCALE}px`,
color: '#2255aa',
}).setOrigin(0.5);
this._buildLogo();
this._buildPrompt();
const prompt = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 90 * WORLD_SCALE, 'Press any key or tap to continue', {
fontFamily: 'monospace',
fontSize: `${16 * WORLD_SCALE}px`,
color: '#1a1f29',
}).setOrigin(0.5);
// No auto-advance timer - this screen now waits indefinitely for the
// player to actually press/tap.
this._advance = () => this.scene.start('MainMenu');
this.input.keyboard.once('keydown', this._advance);
this.input.once('pointerdown', this._advance);
}
// Layers logo.png exactly over the wordmark already painted into
// bg_main.png, then breathes it (grow/shrink, looping) so it's the one
// part of the poster that visibly moves.
_buildLogo() {
const logo = this.add.image(GAME_WIDTH * LOGO_CENTER_X_FRAC, GAME_HEIGHT * LOGO_CENTER_Y_FRAC, 'logo').setOrigin(0.5);
logo.setAngle(LOGO_ROTATION_DEG);
const targetWidth = GAME_WIDTH * LOGO_WIDTH_FRAC;
logo.setDisplaySize(targetWidth, targetWidth * LOGO_ASPECT);
// Starts at exactly this size (matching the baked wordmark), grows, then
// returns to it - never smaller, never settling at the grown size.
const baseScale = logo.scale;
this.tweens.add({
targets: prompt,
alpha: { from: 1, to: 0.2 },
targets: logo,
scale: baseScale * 1.08,
duration: 1400,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
// Phaser's Text only supports one stroke pass, so a black-then-white
// double outline (readable against literally any part of the busy poster
// art behind it) is built from two stacked Text objects at the same
// position: a white one (its stroke forms the outer ring) behind a
// slightly less-stroked black one (whose own stroke/fill covers the
// inner ring) - the exposed sliver of the white layer around the black
// one's edge is what reads as the white outline.
_buildPrompt() {
const x = GAME_WIDTH * PROMPT_X_FRAC;
const y = GAME_HEIGHT * PROMPT_Y_FRAC;
const text = 'Press any key or tap to continue';
// A thick outer stroke relative to font size was filling in letterform
// counters (the holes in a/e/o) on a bold monospace font, which read as
// "blobby" rather than crisp - thinner strokes on a slightly bigger font
// keep the outline readable without eating the letter shapes themselves.
const fontSize = `${20 * WORLD_SCALE}px`;
const outline = this.add.text(x, y, text, {
fontFamily: 'monospace',
fontSize,
fontStyle: 'bold',
color: '#ffffff',
stroke: '#ffffff',
strokeThickness: 5 * WORLD_SCALE,
}).setOrigin(0.5);
const fill = this.add.text(x, y, text, {
fontFamily: 'monospace',
fontSize,
fontStyle: 'bold',
color: '#000000',
stroke: '#000000',
strokeThickness: 2 * WORLD_SCALE,
}).setOrigin(0.5);
// Shallow range (was dipping to 0.3) - low enough to still read as a
// pulse, never low enough to actually wash the double-stroke out against
// the bright sky behind it.
this.tweens.add({
targets: [outline, fill],
alpha: { from: 1, to: 0.65 },
duration: 700,
yoyo: true,
repeat: -1,
});
this._advance = () => this.scene.start('MainMenu');
this.input.keyboard.once('keydown', this._advance);
this.input.once('pointerdown', this._advance);
this.time.delayedCall(6000, this._advance);
}
}

View File

@ -13,6 +13,8 @@ export default class LevelSelectScene extends Phaser.Scene {
this.cameras.main.setBackgroundColor('#8fc7e8');
playMenuMusic(this);
this.add.image(0, 0, 'bg_menu').setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
this.add.text(GAME_WIDTH / 2, 60 * WORLD_SCALE, 'SELECT LEVEL', {
fontFamily: 'monospace',
fontSize: `${32 * WORLD_SCALE}px`,
@ -32,10 +34,13 @@ export default class LevelSelectScene extends Phaser.Scene {
this._createCard(x, y, cardWidth, cardHeight, level);
});
// backgroundColor pill (same convention as PlayScene's HUD text) - this
// corner sits right over bg_menu.png's dark treeline.
const back = this.add.text(40 * WORLD_SCALE, GAME_HEIGHT - 40 * WORLD_SCALE, '< Menu', {
fontFamily: 'monospace',
fontSize: `${16 * WORLD_SCALE}px`,
color: '#1a1f29',
backgroundColor: '#ffffffaa',
}).setInteractive({ useHandCursor: true });
back.on('pointerdown', () => this.scene.start('MainMenu'));
}

View File

@ -12,22 +12,29 @@ export default class MainMenuScene extends Phaser.Scene {
this.cameras.main.setBackgroundColor('#8fc7e8');
playMenuMusic(this);
this.add.text(GAME_WIDTH / 2, 90 * WORLD_SCALE, 'MONSTERPLEX', {
fontFamily: 'monospace',
fontSize: `${40 * WORLD_SCALE}px`,
color: '#1a1f29',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add.image(0, 0, 'bg_menu').setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
// logo.png's own texture has a lot of transparent padding around the
// actual wordmark (see IntroScene's LOGO_* constants), so its display
// width needs to be noticeably bigger than the visible logo is meant to
// read as.
const logoWidth = 620 * WORLD_SCALE;
const logoAspect = 752 / 1392; // logo.png's native height/width
this.add.image(GAME_WIDTH / 2, 90 * WORLD_SCALE, 'logo').setOrigin(0.5).setDisplaySize(logoWidth, logoWidth * logoAspect);
createButton(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 - 20 * WORLD_SCALE, 'PLAY', () => {
this.scene.start('LevelSelect');
});
// backgroundColor pill (same convention as PlayScene's HUD text) - the
// treeline in bg_menu.png sits right behind this text and would
// otherwise swallow the dark-on-dark-green portion of it.
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 60 * WORLD_SCALE, 'Arrows/WASD to drive and lean.\nDon\'t let the g-force throw the kids out!', {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29',
align: 'center',
backgroundColor: '#ffffffaa',
}).setOrigin(0.5);
}
}

View File

@ -17,5 +17,12 @@ export const ASSET_MANIFEST = [
{ key: 'bg_far', path: 'assets/backgrounds/bg_far.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8, tileable: true },
{ key: 'bg_mid', path: 'assets/backgrounds/bg_mid.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x6fae7d, tileable: true },
{ key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true },
// Intro screen's poster art (960x540 native design aspect) and the
// standalone wordmark graphic IntroScene layers on top of it, animated -
// see IntroScene._buildLogo.
{ key: 'bg_main', path: 'assets/backgrounds/bg_main.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x2255aa },
{ key: 'logo', path: 'assets/backgrounds/logo.png', width: 320 * WORLD_SCALE, height: 173 * WORLD_SCALE, kind: 'rect', color: 0xe040a0 },
// Shared full-bleed background for MainMenuScene and LevelSelectScene.
{ key: 'bg_menu', path: 'assets/backgrounds/bg_menu.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8 },
{ key: 'favicon', path: 'assets/favicon.png', width: 32, height: 32, kind: 'rect', color: 0x2255aa },
];