diff --git a/assets/backgrounds/bg_menu.png b/assets/backgrounds/bg_menu.png new file mode 100644 index 0000000..2d689e0 Binary files /dev/null and b/assets/backgrounds/bg_menu.png differ diff --git a/assets/images/ctc.png b/assets/images/ctc.png new file mode 100644 index 0000000..ffdd5ed Binary files /dev/null and b/assets/images/ctc.png differ diff --git a/assets/images/paid.png b/assets/images/paid.png new file mode 100644 index 0000000..2817835 Binary files /dev/null and b/assets/images/paid.png differ diff --git a/src/scenes/IntroScene.js b/src/scenes/IntroScene.js index 77bf21e..369ae97 100644 --- a/src/scenes/IntroScene.js +++ b/src/scenes/IntroScene.js @@ -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); } } diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index bc53d14..7266c0a 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -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')); } diff --git a/src/scenes/MainMenuScene.js b/src/scenes/MainMenuScene.js index c408adf..9295a8f 100644 --- a/src/scenes/MainMenuScene.js +++ b/src/scenes/MainMenuScene.js @@ -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); } } diff --git a/src/util/assetManifest.js b/src/util/assetManifest.js index 12670ba..6baf0d2 100644 --- a/src/util/assetManifest.js +++ b/src/util/assetManifest.js @@ -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 }, ];