Extract pill UI helper and replace text backgrounds with rounded pills

This change introduces a new `src/util/pill.js` module that provides reusable helpers for creating "pill"-style UI labels: a semi-transparent white rounded rectangle backing (with black stroke) sized to the text, plus optional interactivity. It replaces the previous pattern of using Phaser's plain rectangular `backgroundColor` on Text objects, which couldn't produce rounded corners or consistent padding/stroke styling.

Key changes:
- Add `createPill(scene, x, y, text, style, originX, originY)` returning a Container with graphics backing + text, supporting corner origins and automatic re-fitting when text updates.
- Add `pillInteractive(container, config)` to make pills clickable with a properly sized hit area (Containers lack texture frames by default).
- Update UI scenes (`MainMenuScene`, `PlayScene`, `LevelSelectScene`, `LevelScoreScene`) to use `createPill` for HUD elements, labels, score/time displays, and menu/back buttons.
- Remove per-call `backgroundColor` usage in favor of the shared pill styling constants in `pill.js`.

This centralizes pill styling and behavior, improves visual consistency (rounded corners, padding, stroke), and simplifies interaction setup across scenes.
This commit is contained in:
Brian Fertig 2026-08-22 11:07:32 -06:00
parent b81ffac547
commit ba3385ee19
5 changed files with 158 additions and 34 deletions

View File

@ -3,6 +3,7 @@ import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE, SCORE } from '../config.js';
import { markLevelComplete } from '../util/progress.js'; import { markLevelComplete } from '../util/progress.js';
import { createButton } from '../util/ui.js'; import { createButton } from '../util/ui.js';
import { formatTime } from '../util/formatTime.js'; import { formatTime } from '../util/formatTime.js';
import { createPill } from '../util/pill.js';
const KID_ROW_Y = GAME_HEIGHT * 0.62; const KID_ROW_Y = GAME_HEIGHT * 0.62;
const KID_SPACING = 92 * WORLD_SCALE; 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. // so this reads as that same clock continuing, not a new element.
const startX = 24 * WORLD_SCALE; const startX = 24 * WORLD_SCALE;
const hudY = 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', fontFamily: 'monospace',
fontSize: `${32 * WORLD_SCALE}px`, fontSize: `${32 * WORLD_SCALE}px`,
fontStyle: 'bold', fontStyle: 'bold',
color: '#1a1f29', color: '#1a1f29',
backgroundColor: '#ffffffaa', }, 1, 0).setDepth(10);
}).setOrigin(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', fontFamily: 'monospace',
fontSize: `${32 * WORLD_SCALE}px`, fontSize: `${32 * WORLD_SCALE}px`,
fontStyle: 'bold', fontStyle: 'bold',
color: '#1a1f29', color: '#1a1f29',
backgroundColor: '#ffffffaa', }).setScale(0).setDepth(10);
}).setOrigin(0.5).setScale(0).setDepth(10);
// Both start alpha 0 - they sit at Phaser's default (0,0) position until // 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 // _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, strokeThickness: 6 * WORLD_SCALE,
}).setOrigin(0.5, 0.5).setAlpha(0).setDepth(10); }).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', fontFamily: 'monospace',
fontSize: `${20 * WORLD_SCALE}px`, fontSize: `${20 * WORLD_SCALE}px`,
fontStyle: 'bold', fontStyle: 'bold',
color: '#2c8f3c', color: '#2c8f3c',
backgroundColor: '#ffffffaa', }, 0, 0.5).setAlpha(0).setDepth(10);
}).setOrigin(0, 0.5).setAlpha(0).setDepth(10);
this.kidSprites = []; this.kidSprites = [];
this.kidLabels = []; this.kidLabels = [];

View File

@ -5,6 +5,7 @@ import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js';
import { isLevelComplete, getBestScore } from '../util/progress.js'; import { isLevelComplete, getBestScore } from '../util/progress.js';
import { playMenuMusic } from '../util/music.js'; import { playMenuMusic } from '../util/music.js';
import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.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_HOVER = 1.12;
const NODE_IDLE_PULSE = 1.05; const NODE_IDLE_PULSE = 1.05;
@ -29,10 +30,12 @@ const PILL_STYLE = {
// SCORE_PILL_STYLE below. // SCORE_PILL_STYLE below.
fontSize: `${13 * WORLD_SCALE}px`, fontSize: `${13 * WORLD_SCALE}px`,
color: '#1a1f29', 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 "best score" pill that rolls up under cleared nodes - gold, to match
// the gold score numerals in the score tally scene. // the gold score numerals in the score tally scene.
const SCORE_PILL_STYLE = { const SCORE_PILL_STYLE = {
@ -73,18 +76,15 @@ export default class LevelSelectScene extends Phaser.Scene {
.setDepth(DEPTH.chrome); .setDepth(DEPTH.chrome);
this._pager = this.add.graphics().setDepth(DEPTH.chrome); this._pager = this.add.graphics().setDepth(DEPTH.chrome);
const back = this.add const back = createPill(this, 44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }, 0, 0);
.text(44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }) pillInteractive(back, { useHandCursor: true });
.setInteractive({ useHandCursor: true }) back.setDepth(DEPTH.chrome);
.setDepth(DEPTH.chrome);
back.on('pointerdown', () => { back.on('pointerdown', () => {
this.scene.stop('LevelSelect'); this.scene.stop('LevelSelect');
this.scene.start('MainMenu'); this.scene.start('MainMenu');
}); });
this.add createPill(this, GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE)
.text(GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE)
.setOrigin(0.5)
.setDepth(DEPTH.chrome); .setDepth(DEPTH.chrome);
this._buildCampaign(true); this._buildCampaign(true);
@ -217,7 +217,7 @@ export default class LevelSelectScene extends Phaser.Scene {
const best = getBestScore(level.id); const best = getBestScore(level.id);
if (best != null) this._scoreRollup(p, nh, best, 80 + i * 70); if (best != null) this._scoreRollup(p, nh, best, 80 + i * 70);
} else if (state === 'current') { } 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) { _tooltip(x, y, msg) {
this._hideTooltip(); 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 this._tip = this.add
.text(x, y, msg, { .text(x, y, msg, {
fontFamily: 'monospace', fontFamily: 'monospace',

View File

@ -1,6 +1,7 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { createButton } from '../util/ui.js'; import { createButton } from '../util/ui.js';
import { createPill } from '../util/pill.js';
import { playMenuMusic } from '../util/music.js'; import { playMenuMusic } from '../util/music.js';
export default class MainMenuScene extends Phaser.Scene { export default class MainMenuScene extends Phaser.Scene {
@ -26,15 +27,14 @@ export default class MainMenuScene extends Phaser.Scene {
this.scene.start('LevelSelect'); this.scene.start('LevelSelect');
}); });
// backgroundColor pill (same convention as PlayScene's HUD text) - the // Pill (see util/pill.js) - the treeline in bg_menu.png sits right
// treeline in bg_menu.png sits right behind this text and would // behind this text and would otherwise swallow the dark-on-dark-green
// otherwise swallow the dark-on-dark-green portion of it. // 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!', { 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', fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`, fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29', color: '#1a1f29',
align: 'center', align: 'center',
backgroundColor: '#ffffffaa', });
}).setOrigin(0.5);
} }
} }

View File

@ -12,6 +12,7 @@ import EngineSound from '../systems/EngineSound.js';
import { stopMenuMusic } from '../util/music.js'; import { stopMenuMusic } from '../util/music.js';
import { playLevelVoiceLine } from '../util/voiceLine.js'; import { playLevelVoiceLine } from '../util/voiceLine.js';
import { formatTime } from '../util/formatTime.js'; import { formatTime } from '../util/formatTime.js';
import { createPill } from '../util/pill.js';
export default class PlayScene extends Phaser.Scene { export default class PlayScene extends Phaser.Scene {
constructor() { constructor() {
@ -214,28 +215,25 @@ export default class PlayScene extends Phaser.Scene {
this.hudIcons.push(icon); 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', fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`, fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29', color: '#1a1f29',
backgroundColor: '#ffffffaa', }, 0, 0).setScrollFactor(0).setDepth(10);
}).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', fontFamily: 'monospace',
fontSize: `${32 * WORLD_SCALE}px`, fontSize: `${32 * WORLD_SCALE}px`,
fontStyle: 'bold', fontStyle: 'bold',
color: '#1a1f29', color: '#1a1f29',
backgroundColor: '#ffffffaa', }, 1, 0).setScrollFactor(0).setDepth(10);
}).setOrigin(1, 0).setScrollFactor(0).setDepth(10);
if (DEBUG) { 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', fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`, fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29', color: '#1a1f29',
backgroundColor: '#ffffffaa', }, 0, 0).setScrollFactor(0).setDepth(10);
}).setScrollFactor(0).setDepth(10);
} }
} }

126
src/util/pill.js Normal file
View File

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