feat: add dynamic color scheme support for colored playfields

Introduce runtime-generated canvas textures for colored playfields, allowing
players to select from predefined base/accent color combinations via a UI
dropdown. The selected scheme is persisted to localStorage and the dropdown
is shown/hidden contextually based on the active playfield type. Includes
proper DOM cleanup when clearing layers.
This commit is contained in:
Brian Fertig 2026-06-22 22:53:43 -06:00
parent 7e82de37c6
commit d7b00c8bd5
1 changed files with 95 additions and 1 deletions

View File

@ -50,6 +50,8 @@ export default class BookworkGame extends Phaser.Scene {
this.match = null;
this.playfield = null;
this.playfieldTiles = [];
this.selectedColorScheme = null;
this._colorDropdownDomEl = null;
this.bgFill = null;
this.bgTex = null;
// Battle state
@ -107,6 +109,11 @@ export default class BookworkGame extends Phaser.Scene {
const savedId = localStorage.getItem('bookwork-playfield');
this.playfield = pfItems.find(p => p.id === savedId) ?? pfItems.find(p => p.id === pfData.default) ?? pfItems[0] ?? null;
// Load saved color scheme for colored playfield
const schemes = this.cache.json.get('colored-playfields')?.schemes ?? [];
const savedSchemeId = localStorage.getItem('bookwork-colorscheme');
this.selectedColorScheme = schemes.find(s => s.id === savedSchemeId) ?? schemes[Math.floor(Math.random() * schemes.length)] ?? null;
const bgCx = GAME_WIDTH / 2, bgCy = GAME_HEIGHT / 2;
this.bgFill = this.add.rectangle(bgCx, bgCy, GAME_WIDTH, GAME_HEIGHT, 0x0f0a05).setDepth(D.bg);
this.applyPlayfieldBg(this.playfield);
@ -122,7 +129,17 @@ export default class BookworkGame extends Phaser.Scene {
}
applyPlayfieldBg(pf) {
if (pf?.key && this.textures.exists(pf.key)) {
if (pf?.type === 'colored' && this.selectedColorScheme) {
const texKey = `playfield-colored-${this.selectedColorScheme.id}`;
this.generateColoredTexture(this.selectedColorScheme, texKey);
if (this.bgTex) {
this.bgTex.setTexture(texKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setVisible(true);
} else {
this.bgTex = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, texKey)
.setDepth(D.bg).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
}
this.bgFill.setFillStyle(0x0f0a05);
} else if (pf?.key && this.textures.exists(pf.key)) {
if (this.bgTex) {
this.bgTex.setTexture(pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setVisible(true);
} else {
@ -137,6 +154,68 @@ export default class BookworkGame extends Phaser.Scene {
}
}
generateColoredTexture(scheme, texKey) {
const W = GAME_WIDTH, H = GAME_HEIGHT;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = scheme.base;
ctx.fillRect(0, 0, W, H);
const n = scheme.accents.length;
const D2 = Math.sqrt(W * W + H * H);
const px = -H / D2, py = W / D2;
scheme.accents.forEach((hex, i) => {
const t = (i + 1) / (n + 1);
const gx = W * t, gy = H * (1 - t);
const spread = D2 * 0.55;
const grad = ctx.createLinearGradient(gx - px * spread, gy - py * spread, gx + px * spread, gy + py * spread);
grad.addColorStop(0, hex + '00');
grad.addColorStop(0.5, hex + '80');
grad.addColorStop(1, hex + '00');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
});
if (this.textures.exists(texKey)) this.textures.remove(texKey);
this.textures.addCanvas(texKey, canvas);
}
_buildColorSchemeDropdown(x, y) {
const schemes = this.cache.json.get('colored-playfields')?.schemes ?? [];
if (!schemes.length) return;
const wrapper = document.createElement('div');
wrapper.style.cssText = 'text-align:center;';
const label = document.createElement('span');
label.textContent = 'Color Scheme:';
label.style.cssText = 'font-family:"Julius Sans One",sans-serif;font-size:15px;color:#9e9080;margin-right:10px;vertical-align:middle;';
const select = document.createElement('select');
select.style.cssText = 'background:#1e1a12;color:#f2ead8;border:2px solid #9e9080;border-radius:6px;padding:6px 14px;font-family:"Julius Sans One",sans-serif;font-size:15px;cursor:pointer;outline:none;vertical-align:middle;';
select.addEventListener('mouseover', () => { select.style.borderColor = '#c8a84b'; });
select.addEventListener('mouseout', () => { select.style.borderColor = '#9e9080'; });
schemes.forEach(scheme => {
const opt = document.createElement('option');
opt.value = scheme.id;
opt.textContent = scheme.name;
select.appendChild(opt);
});
select.value = this.selectedColorScheme?.id ?? schemes[0].id;
select.addEventListener('change', (e) => {
const scheme = schemes.find(s => s.id === e.target.value) ?? schemes[0];
this.selectedColorScheme = scheme;
localStorage.setItem('bookwork-colorscheme', scheme.id);
if (this.playfield?.type === 'colored') this.applyPlayfieldBg(this.playfield);
});
wrapper.appendChild(label);
wrapper.appendChild(select);
this._colorDropdownDomEl = this.add.dom(x, y, wrapper);
}
// ── Textures ────────────────────────────────────────────────────────────────
makeTextures() {
@ -181,6 +260,7 @@ export default class BookworkGame extends Phaser.Scene {
// ── View management ─────────────────────────────────────────────────────────
clearLayer() {
if (this._colorDropdownDomEl) { this._colorDropdownDomEl.destroy(); this._colorDropdownDomEl = null; }
for (const p of this.portraits) { try { p.destroy(); } catch (_) {} }
this.portraits = [];
this.turnPhase = 'idle';
@ -318,6 +398,9 @@ export default class BookworkGame extends Phaser.Scene {
this.playfield = pf;
localStorage.setItem('bookwork-playfield', pf.id);
this.applyPlayfieldBg(pf);
if (this._colorDropdownDomEl) {
this._colorDropdownDomEl.node.style.display = pf.type === 'colored' ? 'block' : 'none';
}
this.playfieldTiles.forEach(t => {
const s = t.pf.id === pf.id;
t.bg.setStrokeStyle(s ? 4 : 2, s ? COLORS.accent : COLORS.muted);
@ -328,6 +411,17 @@ export default class BookworkGame extends Phaser.Scene {
bg.on('pointerover', () => { if (this.playfield?.id !== pf.id) bg.setStrokeStyle(2, COLORS.text); });
bg.on('pointerout', () => { if (this.playfield?.id !== pf.id) bg.setStrokeStyle(2, COLORS.muted); });
});
// Color scheme dropdown positioned below the "Colored" tile
const coloredIdx = pfItems.findIndex(p => p.type === 'colored');
if (coloredIdx >= 0) {
const dropX = pfLeft + coloredIdx * (PW + PGAP);
const dropY = pfY + PH / 2 + 30;
this._buildColorSchemeDropdown(dropX, dropY);
if (this.playfield?.type !== 'colored' && this._colorDropdownDomEl) {
this._colorDropdownDomEl.node.style.display = 'none';
}
}
}
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Fight Level ${nextLevel}`, () => this.showIntro(nextLevel),