feat: add time bonus mechanic to Bejeweled and new shift artwork

- Add TIME_BONUS constants (flame: 10s, star: 10s, hyper: 15s, mult: 5s)
- Display "+X Seconds Added!" banner when special gems are triggered
- Apply accumulated time bonuses at the end of each cascade phase
- Cap time at BLITZ_SECONDS maximum
- Add 5 new shift artwork images and update shift-artwork.json
This commit is contained in:
Brian Fertig 2026-06-12 18:14:19 -06:00
parent 13b5ec6fdf
commit 8a8ea2ef3b
8 changed files with 63 additions and 7 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@ -17,6 +17,36 @@
"name": "Underwater Dolphin Adventure", "name": "Underwater Dolphin Adventure",
"key": "dolphin-underwater", "key": "dolphin-underwater",
"path": "/assets/images/shift/dolphin-underwater.png" "path": "/assets/images/shift/dolphin-underwater.png"
},
{
"id": "fall-bike",
"name": "Biking in the Fall",
"key": "fall-bike",
"path": "/assets/images/shift/fall-bike.png"
},
{
"id": "norway-fishing",
"name": "Fishing Village in Norway",
"key": "norway-fishing",
"path": "/assets/images/shift/norway-fishing.png"
},
{
"id": "puppy-space",
"name": "Room with a View",
"key": "puppy-space",
"path": "/assets/images/shift/puppy-space.png"
},
{
"id": "red-dress",
"name": "Red Dress in Paris",
"key": "red-dress",
"path": "/assets/images/shift/red-dress.png"
},
{
"id": "warrior",
"name": "Fierce Warrior",
"key": "warrior",
"path": "/assets/images/shift/warrior.png"
} }
] ]
} }

View File

@ -820,6 +820,13 @@ export default class BejeweledGame extends Phaser.Scene {
this.time.delayedCall(i * 90, () => this.playEvent(e)); this.time.delayedCall(i * 90, () => this.playEvent(e));
}); });
// Apply total time bonus after all events in the phase.
const totalTimeBonus = phase.events.reduce((sum, e) => sum + (e.timeBonus ?? 0), 0);
if (totalTimeBonus > 0) {
this.timeLeft = Math.min(this.timeLeft + totalTimeBonus, BLITZ_SECONDS);
this.redrawTimer();
}
// Clear matched gems with a burst. // Clear matched gems with a burst.
if (phase.cleared.length) playSound(this, SFX.MASTERMIND_MATCH); if (phase.cleared.length) playSound(this, SFX.MASTERMIND_MATCH);
const sparse = phase.cleared.length > 14; const sparse = phase.cleared.length > 14;
@ -899,7 +906,7 @@ export default class BejeweledGame extends Phaser.Scene {
this.multText.setText(`×${e.multiplier}`).setAlpha(1); this.multText.setText(`×${e.multiplier}`).setAlpha(1);
this.tweens.add({ targets: this.multText, scale: 1.5, duration: 160, yoyo: true, ease: 'Quad.easeOut' }); this.tweens.add({ targets: this.multText, scale: 1.5, duration: 160, yoyo: true, ease: 'Quad.easeOut' });
this.showBanner(`MULTIPLIER ×${e.multiplier}!`, '#6cc1ff'); this.showBanner(`MULTIPLIER ×${e.multiplier}!`, '#6cc1ff');
return; if (e.timeBonus > 0) this.time.delayedCall(300, () => this.timeBonusBanner(e.timeBonus));
} }
const { x, y } = this.cellXY(e.c, e.r); const { x, y } = this.cellXY(e.c, e.r);
if (e.type === 'flame') { if (e.type === 'flame') {
@ -919,6 +926,7 @@ export default class BejeweledGame extends Phaser.Scene {
}).setDepth(D.fx); }).setDepth(D.fx);
this.time.delayedCall(60, () => em.stop()); this.time.delayedCall(60, () => em.stop());
this.time.delayedCall(800, () => em.destroy()); this.time.delayedCall(800, () => em.destroy());
if (e.timeBonus > 0) this.time.delayedCall(500, () => this.timeBonusBanner(e.timeBonus));
} else if (e.type === 'star') { } else if (e.type === 'star') {
playSound(this, SFX.SCIFI_LAUNCH); playSound(this, SFX.SCIFI_LAUNCH);
this.cameras.main.shake(90, 0.003); this.cameras.main.shake(90, 0.003);
@ -935,6 +943,7 @@ export default class BejeweledGame extends Phaser.Scene {
}).setDepth(D.fx); }).setDepth(D.fx);
this.time.delayedCall(60, () => em.stop()); this.time.delayedCall(60, () => em.stop());
this.time.delayedCall(700, () => em.destroy()); this.time.delayedCall(700, () => em.destroy());
if (e.timeBonus > 0) this.time.delayedCall(400, () => this.timeBonusBanner(e.timeBonus));
} else if (e.type === 'hyper') { } else if (e.type === 'hyper') {
playSound(this, SFX.SCIFI_REVEAL); playSound(this, SFX.SCIFI_REVEAL);
this.cameras.main.shake(170, 0.006); this.cameras.main.shake(170, 0.006);
@ -967,6 +976,7 @@ export default class BejeweledGame extends Phaser.Scene {
}).setDepth(D.fx); }).setDepth(D.fx);
this.time.delayedCall(80, () => em.stop()); this.time.delayedCall(80, () => em.stop());
this.time.delayedCall(900, () => em.destroy()); this.time.delayedCall(900, () => em.destroy());
if (e.timeBonus > 0) this.time.delayedCall(600, () => this.timeBonusBanner(e.timeBonus));
} }
} }
@ -1007,6 +1017,17 @@ export default class BejeweledGame extends Phaser.Scene {
}); });
} }
timeBonusBanner(seconds) {
const cx = GAME_WIDTH / 2;
const cy = BOARD_Y + BOARD_W / 2 - 40 - 75;
const t = this.add.text(cx, cy, `+${seconds} Seconds Added!`, {
fontFamily: 'Righteous', fontSize: '52px', color: '#5eff8a',
stroke: '#0a2210', strokeThickness: 8,
}).setOrigin(0.5).setDepth(D.banner).setScale(0.3).setAlpha(0);
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
this.tweens.add({ targets: t, alpha: 0, delay: 950, duration: 300, onComplete: () => t.destroy() });
}
comboCallout(cascade) { comboCallout(cascade) {
const idx = Math.min(cascade, COMBO_WORDS.length - 1); const idx = Math.min(cascade, COMBO_WORDS.length - 1);
const word = cascade >= 7 ? 'UNBELIEVABLE!' : COMBO_WORDS[idx]; const word = cascade >= 7 ? 'UNBELIEVABLE!' : COMBO_WORDS[idx];

View File

@ -168,10 +168,10 @@ function expandClears(board, seedKeys, rng, hyperOverrides = new Map()) {
const { c, r } = unkey(k); const { c, r } = unkey(k);
const sp = board[r][c].special; const sp = board[r][c].special;
if (sp === SPECIAL.FLAME) { if (sp === SPECIAL.FLAME) {
events.push({ type: 'flame', c, r }); events.push({ type: 'flame', c, r, timeBonus: TIME_BONUS.flame });
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) add(c + dc, r + dr); for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) add(c + dc, r + dr);
} else if (sp === SPECIAL.STAR) { } else if (sp === SPECIAL.STAR) {
events.push({ type: 'star', c, r }); events.push({ type: 'star', c, r, timeBonus: TIME_BONUS.star });
for (let i = 0; i < COLS; i++) add(i, r); for (let i = 0; i < COLS; i++) add(i, r);
for (let i = 0; i < ROWS; i++) add(c, i); for (let i = 0; i < ROWS; i++) add(c, i);
} else if (sp === SPECIAL.HYPER) { } else if (sp === SPECIAL.HYPER) {
@ -190,7 +190,7 @@ function expandClears(board, seedKeys, rng, hyperOverrides = new Map()) {
if (board[rr][cc]?.color === color) { cells.push([cc, rr]); add(cc, rr); } if (board[rr][cc]?.color === color) { cells.push([cc, rr]); add(cc, rr); }
} }
} }
events.push({ type: 'hyper', c, r, color, cells }); events.push({ type: 'hyper', c, r, color, cells, timeBonus: TIME_BONUS.hyper });
} }
} }
return { keys, events }; return { keys, events };
@ -226,6 +226,7 @@ function collapse(board, rng) {
const GEM_POINTS = 30; const GEM_POINTS = 30;
const EVENT_BONUS = { flame: 100, star: 200, hyper: 400 }; const EVENT_BONUS = { flame: 100, star: 200, hyper: 400 };
const TIME_BONUS = { flame: 10, star: 10, hyper: 15, mult: 5 };
// Resolve one or more cascade phases. opts.preClear seeds phase 1 directly // Resolve one or more cascade phases. opts.preClear seeds phase 1 directly
// (hyper swaps, Last Hurrah); afterwards phases come from runs on the board. // (hyper swaps, Last Hurrah); afterwards phases come from runs on the board.
@ -273,17 +274,21 @@ function runCascades(state, rng, opts = {}) {
// Multiplier gems consumed this phase raise the global multiplier. // Multiplier gems consumed this phase raise the global multiplier.
let eventBonus = 0; let eventBonus = 0;
let timeBonus = 0;
const cleared = []; const cleared = [];
for (const k of keys) { for (const k of keys) {
const { c, r } = unkey(k); const { c, r } = unkey(k);
const gem = board[r][c]; const gem = board[r][c];
if (gem.special === SPECIAL.MULT && state.multiplier < MAX_MULTIPLIER) { if (gem.special === SPECIAL.MULT && state.multiplier < MAX_MULTIPLIER) {
state.multiplier++; state.multiplier++;
events.push({ type: 'mult', c, r, multiplier: state.multiplier }); events.push({ type: 'mult', c, r, multiplier: state.multiplier, timeBonus: TIME_BONUS.mult });
} }
if (!spawnKeys.has(k)) cleared.push({ c, r, color: gem.color, special: gem.special }); if (!spawnKeys.has(k)) cleared.push({ c, r, color: gem.color, special: gem.special });
} }
for (const e of events) eventBonus += EVENT_BONUS[e.type] ?? 0; for (const e of events) {
eventBonus += EVENT_BONUS[e.type] ?? 0;
timeBonus += TIME_BONUS[e.type] ?? 0;
}
const points = Math.round((keys.size * GEM_POINTS + runBonus + eventBonus) * cascade * state.multiplier); const points = Math.round((keys.size * GEM_POINTS + runBonus + eventBonus) * cascade * state.multiplier);
@ -308,7 +313,7 @@ function runCascades(state, rng, opts = {}) {
phases.push({ phases.push({
cascade, points, multiplier: state.multiplier, cascade, points, multiplier: state.multiplier,
cleared, spawns: placedSpawns, events, falls, refills, cleared, spawns: placedSpawns, events, falls, refills, timeBonus,
}); });
swapKeys = []; swapKeys = [];
} }