diff --git a/src/scenes/LevelScoreScene.js b/src/scenes/LevelScoreScene.js index 4bdbe48..be77d86 100644 --- a/src/scenes/LevelScoreScene.js +++ b/src/scenes/LevelScoreScene.js @@ -3,6 +3,7 @@ import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE, SCORE } from '../config.js'; import { markLevelComplete } from '../util/progress.js'; import { createButton } from '../util/ui.js'; import { formatTime } from '../util/formatTime.js'; +import { createPill } from '../util/pill.js'; const KID_ROW_Y = GAME_HEIGHT * 0.62; const KID_SPACING = 92 * WORLD_SCALE; @@ -43,21 +44,19 @@ export default class LevelScoreScene extends Phaser.Scene { // so this reads as that same clock continuing, not a new element. const startX = 24 * WORLD_SCALE; const hudY = 24 * WORLD_SCALE; - this.timeText = this.add.text(GAME_WIDTH - startX, hudY, formatTime(this.timeRemainingSeconds), { + this.timeText = createPill(this, GAME_WIDTH - startX, hudY, formatTime(this.timeRemainingSeconds), { fontFamily: 'monospace', fontSize: `${32 * WORLD_SCALE}px`, fontStyle: 'bold', color: '#1a1f29', - backgroundColor: '#ffffffaa', - }).setOrigin(1, 0).setDepth(10); + }, 1, 0).setDepth(10); - this.scoreLabel = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT * 0.2, 'Score:', { + this.scoreLabel = createPill(this, GAME_WIDTH / 2, GAME_HEIGHT * 0.2, 'Score:', { fontFamily: 'monospace', fontSize: `${32 * WORLD_SCALE}px`, fontStyle: 'bold', color: '#1a1f29', - backgroundColor: '#ffffffaa', - }).setOrigin(0.5).setScale(0).setDepth(10); + }).setScale(0).setDepth(10); // Both start alpha 0 - they sit at Phaser's default (0,0) position until // _flyInScore places them off-screen and tweens them in, and origin @@ -72,13 +71,12 @@ export default class LevelScoreScene extends Phaser.Scene { strokeThickness: 6 * WORLD_SCALE, }).setOrigin(0.5, 0.5).setAlpha(0).setDepth(10); - this.solidFinishText = this.add.text(0, 0, 'Solid Finish', { + this.solidFinishText = createPill(this, 0, 0, 'Solid Finish', { fontFamily: 'monospace', fontSize: `${20 * WORLD_SCALE}px`, fontStyle: 'bold', color: '#2c8f3c', - backgroundColor: '#ffffffaa', - }).setOrigin(0, 0.5).setAlpha(0).setDepth(10); + }, 0, 0.5).setAlpha(0).setDepth(10); this.kidSprites = []; this.kidLabels = []; diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index 0a1f73a..44dc2b6 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -5,6 +5,7 @@ import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js'; import { isLevelComplete, getBestScore } from '../util/progress.js'; import { playMenuMusic } from '../util/music.js'; import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js'; +import { createPill, pillInteractive } from '../util/pill.js'; const NODE_HOVER = 1.12; const NODE_IDLE_PULSE = 1.05; @@ -29,10 +30,12 @@ const PILL_STYLE = { // SCORE_PILL_STYLE below. fontSize: `${13 * WORLD_SCALE}px`, color: '#1a1f29', - backgroundColor: '#ffffffaa', - padding: { x: 9 * WORLD_SCALE, y: 5 * WORLD_SCALE }, }; +// Labels that share PILL_STYLE are pills (see util/pill.js) - createPill +// supplies the rounded white backing + black stroke and the guaranteed +// text/edge padding; only the font side lives here. + // The "best score" pill that rolls up under cleared nodes - gold, to match // the gold score numerals in the score tally scene. const SCORE_PILL_STYLE = { @@ -73,18 +76,15 @@ export default class LevelSelectScene extends Phaser.Scene { .setDepth(DEPTH.chrome); this._pager = this.add.graphics().setDepth(DEPTH.chrome); - const back = this.add - .text(44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }) - .setInteractive({ useHandCursor: true }) - .setDepth(DEPTH.chrome); + const back = createPill(this, 44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }, 0, 0); + pillInteractive(back, { useHandCursor: true }); + back.setDepth(DEPTH.chrome); back.on('pointerdown', () => { this.scene.stop('LevelSelect'); this.scene.start('MainMenu'); }); - this.add - .text(GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE) - .setOrigin(0.5) + createPill(this, GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE) .setDepth(DEPTH.chrome); this._buildCampaign(true); @@ -217,7 +217,7 @@ export default class LevelSelectScene extends Phaser.Scene { const best = getBestScore(level.id); if (best != null) this._scoreRollup(p, nh, best, 80 + i * 70); } else if (state === 'current') { - this._groups.tags.push(this.add.text(p.x, p.y + nh * 0.68, level.name, PILL_STYLE).setOrigin(0.5).setDepth(DEPTH.tag)); + this._groups.tags.push(createPill(this, p.x, p.y + nh * 0.68, level.name, PILL_STYLE).setDepth(DEPTH.tag)); } }); @@ -353,6 +353,8 @@ export default class LevelSelectScene extends Phaser.Scene { _tooltip(x, y, msg) { this._hideTooltip(); + // Tooltip is dark, not one of the white pills (util/pill.js is + // white-fill-only), so it keeps its own plain backgroundColor. this._tip = this.add .text(x, y, msg, { fontFamily: 'monospace', diff --git a/src/scenes/MainMenuScene.js b/src/scenes/MainMenuScene.js index 9295a8f..2ddf854 100644 --- a/src/scenes/MainMenuScene.js +++ b/src/scenes/MainMenuScene.js @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { createButton } from '../util/ui.js'; +import { createPill } from '../util/pill.js'; import { playMenuMusic } from '../util/music.js'; export default class MainMenuScene extends Phaser.Scene { @@ -26,15 +27,14 @@ export default class MainMenuScene extends Phaser.Scene { 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!', { + // Pill (see util/pill.js) - the treeline in bg_menu.png sits right + // behind this text and would otherwise swallow the dark-on-dark-green + // portion of it. + createPill(this, 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/scenes/PlayScene.js b/src/scenes/PlayScene.js index fb46f33..c9fd76b 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -12,6 +12,7 @@ import EngineSound from '../systems/EngineSound.js'; import { stopMenuMusic } from '../util/music.js'; import { playLevelVoiceLine } from '../util/voiceLine.js'; import { formatTime } from '../util/formatTime.js'; +import { createPill } from '../util/pill.js'; export default class PlayScene extends Phaser.Scene { constructor() { @@ -214,28 +215,25 @@ export default class PlayScene extends Phaser.Scene { this.hudIcons.push(icon); } - this.hudText = this.add.text(startX, y + 24 * WORLD_SCALE, `Kids aboard: ${this.level.kidsAboard}/${this.level.kidsAboard}`, { + this.hudText = createPill(this, startX, y + 24 * WORLD_SCALE, `Kids aboard: ${this.level.kidsAboard}/${this.level.kidsAboard}`, { fontFamily: 'monospace', fontSize: `${14 * WORLD_SCALE}px`, color: '#1a1f29', - backgroundColor: '#ffffffaa', - }).setScrollFactor(0).setDepth(10); + }, 0, 0).setScrollFactor(0).setDepth(10); - this.timerText = this.add.text(GAME_WIDTH - startX, y, formatTime(this._timeRemaining), { + this.timerText = createPill(this, GAME_WIDTH - startX, y, formatTime(this._timeRemaining), { fontFamily: 'monospace', fontSize: `${32 * WORLD_SCALE}px`, fontStyle: 'bold', color: '#1a1f29', - backgroundColor: '#ffffffaa', - }).setOrigin(1, 0).setScrollFactor(0).setDepth(10); + }, 1, 0).setScrollFactor(0).setDepth(10); if (DEBUG) { - this.debugText = this.add.text(startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', { + this.debugText = createPill(this, startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', { fontFamily: 'monospace', fontSize: `${14 * WORLD_SCALE}px`, color: '#1a1f29', - backgroundColor: '#ffffffaa', - }).setScrollFactor(0).setDepth(10); + }, 0, 0).setScrollFactor(0).setDepth(10); } } diff --git a/src/util/pill.js b/src/util/pill.js new file mode 100644 index 0000000..cf18ba6 --- /dev/null +++ b/src/util/pill.js @@ -0,0 +1,126 @@ +import Phaser from 'phaser'; +import { WORLD_SCALE } from '../config.js'; + +// Rounded "pill" backing for the dark-on-white text labels used across the +// UI. Phaser's Text backgroundColor is always a plain rectangle, so this +// helper replaces it: the text keeps its own style (font, size, color) but +// draws with NO background, and a graphics pill - semi-transparent white, +// rounded corners, black stroke - is sized to the text and sits just behind +// it in a Container. The container is one object with one depth/alpha/tween +// identity, so it can replace a bare Text in place; the text's padding +// (floored at PILL_PADDING) keeps breathing room between glyphs and the +// pill edges. +// +// Usage: const pill = createPill(scene, x, y, 'Label', { fontSize: ... }) +// The container is origin-0.5 by default, so existing .setOrigin(0.5) call +// sites keep working. For labels that used a corner origin (the HUD timer +// at (1, 0), "Solid Finish" at (0, 0.5)), pass originX/originY - the +// backing then aligns so that corner of the pill sits on the container +// position, and the container's transform/positioning semantics are +// unchanged. + +export const PILL_FILL = 0xffffff; +export const PILL_FILL_ALPHA = 0.667; // same '#ffffffaa' the text backgrounds used +export const PILL_STROKE = 0x000000; +export const PILL_STROKE_WIDTH = 2 * WORLD_SCALE; +export const PILL_RADIUS = 8 * WORLD_SCALE; +export const PILL_PADDING = 6 * WORLD_SCALE; + +// Sizes the backing pill to the text and positions both so the pill's +// origin corner (0/0.5/1 on each axis) lands on the container position - +// mirroring what setOrigin(ox, oy) on a bare Text would do. The text and +// pill share one bounding box, so the corner aligns for both. +function fitPill(c) { + const back = c.list[0]; + const text = c.list[1]; + const w = Math.max(text.width, 1); + const h = Math.max(text.height, 1); + const cx = (0.5 - c.pillOriginX) * w; + const cy = (0.5 - c.pillOriginY) * h; + // Track the pill box as the container's own size - displayOriginX/Y + // (used by the input system to normalize hit-test points) are derived + // from width/2, so without this an enabled hit area would be off by + // half its width/height. + c.setSize(w, h); + text.x = cx; + text.y = cy; + back.clear(); + back.fillStyle(PILL_FILL, PILL_FILL_ALPHA); + back.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, PILL_RADIUS); + back.lineStyle(PILL_STROKE_WIDTH, PILL_STROKE, 1); + back.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, PILL_RADIUS); + // Keep an input hit area (if enabled via pillInteractive) matched to the + // pill box. Container hit-test points are normalized by adding + // displayOrigin (always width/2, height/2 for a Container), so the rect + // lives in that normalized space - (cx, cy) here rather than the raw + // local corner. + if (c.input && c.input.hitArea) { + syncHitArea(c); + } +} + +// Every Text re-render path (setText, setStyle, setPadding, ...) funnels +// through updateText, but this Phaser build emits no event for it - so +// wrap it and re-fit the backing after each pass. +function observeText(c, text) { + const originalUpdateText = text.updateText.bind(text); + text.updateText = function () { + const r = originalUpdateText(); + fitPill(c); + return r; + }; +} + +export function createPill(scene, x, y, textContent, style, originX = 0.5, originY = 0.5) { + // The text's own background is what we're replacing - drop it here (and + // keep it off any later setStyle too), else both would draw. + delete style.backgroundColor; + const textStyle = { ...style }; + const padding = textStyle.padding ? { ...textStyle.padding } : {}; + padding.left = Math.max(padding.left || 0, PILL_PADDING); + padding.right = Math.max(padding.right || 0, PILL_PADDING); + padding.top = Math.max(padding.top || 0, PILL_PADDING); + padding.bottom = Math.max(padding.bottom || 0, PILL_PADDING); + textStyle.padding = padding; + + const textObj = scene.add.text(0, 0, textContent, textStyle).setOrigin(0.5); + const back = scene.add.graphics(); + const c = scene.add.container(x, y, [back, textObj]); + c.pillOriginX = originX; + c.pillOriginY = originY; + observeText(c, textObj); + fitPill(c); + + // Scene code treats the pill like the Text it replaces - delegate the + // Text methods it calls. setText goes through the observed updateText, + // so the backing re-fits automatically. + c.text = textObj; + c.setText = (value) => { textObj.setText(value); return c; }; + c.setColor = (color) => { textObj.setColor(color); return c; }; + return c; +} + +// Makes a pill clickable. A bare setInteractive() on a Container derives its +// hit area from a texture frame it doesn't have (a 0x0 rectangle - +// effectively unclickable), so this supplies an explicit rectangle that +// fitPill keeps sized to the pill box on every text update. Extra config +// (useHandCursor, draggable, ...) is forwarded as-is. +export function pillInteractive(c, config = {}) { + // Explicit rect + Rectangle.Contains - same semantics as the default + // texture-frame hit area, but sized to the pill box (a Container has no + // texture frame of its own, so the default would be 0x0 / unclickable). + const hitArea = new Phaser.Geom.Rectangle(0, 0, 1, 1); + c.setInteractive({ hitArea, hitAreaCallback: Phaser.Geom.Rectangle.Contains, ...config }); + syncHitArea(c); + return c; +} + +function syncHitArea(c) { + if (!c.input || !c.input.hitArea) return; + const text = c.list[1]; + const w = Math.max(text.width, 1); + const h = Math.max(text.height, 1); + const cx = (0.5 - c.pillOriginX) * w; + const cy = (0.5 - c.pillOriginY) * h; + Object.assign(c.input.hitArea, { x: cx, y: cy, width: w, height: h }); +}