Add comprehensive shatter animations for vial destruction with evil and holy effects

This commit implements detailed shatter animations for both vial destruction effects:
- Added evil shatter animation (sin vial implodes with dark, decaying explosion) for when The Lord wins
- Added holy shatter animation (lord vial ascends with divine golden explosion) for when Sin wins
- Implemented comprehensive visual effects including explosions, particle systems, smoke, rays, and text animations
- Integrated shatter animations with victory video playback in GameScene
- Added proper timing and sequencing for dramatic victory presentation
- Maintained existing functionality while adding rich visual feedback for game endings
- Created reusable animation helper methods in VialDisplay class for consistent effects
This commit is contained in:
Brian Fertig 2026-02-28 12:30:17 -07:00
parent d30c018100
commit 5ed7efabd9
2 changed files with 377 additions and 2 deletions

View File

@ -296,4 +296,369 @@ export class VialDisplay {
scene.game.events.emit('vial-winner', { winner: this.label });
}
// ── Shatter animations ────────────────────────────────────────────────────
/**
* Evil destruction: Sin's vial implodes in a dark, decaying explosion.
* Called when The Lord wins.
*/
shatterEvil() {
const scene = this.scene;
const cx = this.baseX;
const cy = this.baseY + this.VIAL_H / 2;
const D = 80; // base depth
if (this._shakeTween) { this._shakeTween.stop(); this._shakeTween = null; }
this.container.setPosition(this.baseX, this.baseY);
// Dark red screen pulse
const overlay = scene.add.rectangle(800, 450, 1600, 900, 0x330000).setAlpha(0).setDepth(D + 20);
scene.tweens.add({
targets: overlay, alpha: { from: 0, to: 0.6 },
duration: 100, yoyo: true, repeat: 3,
onComplete: () => overlay.destroy(),
});
// Violent random shake, escalating in intensity, then explode
const proxy = { t: 0 };
scene.tweens.add({
targets: proxy, t: 1, duration: 380,
onUpdate: () => {
const amp = proxy.t * 15;
this.container.setX(this.baseX + (Math.random() - 0.5) * amp);
this.container.setY(this.baseY + (Math.random() - 0.5) * amp * 0.4);
},
onComplete: () => {
this.container.setPosition(this.baseX, this.baseY);
this._doEvilExplosion(cx, cy, D);
},
});
// Crack lines drawn at 180 ms
scene.time.delayedCall(180, () => {
const cracks = scene.add.graphics().setDepth(D + 5);
cracks.lineStyle(1.5, 0x880000, 1);
for (let i = 0; i < 7; i++) {
const a = (Math.PI * 2 / 7) * i + (Math.random() - 0.5) * 0.4;
const len = 18 + Math.random() * 28;
cracks.beginPath();
cracks.moveTo(cx, cy);
cracks.lineTo(cx + Math.cos(a) * len, cy + Math.sin(a) * len);
const bA = a + (Math.random() - 0.5) * 0.9;
cracks.moveTo(cx + Math.cos(a) * len * 0.55, cy + Math.sin(a) * len * 0.55);
cracks.lineTo(
cx + Math.cos(a) * len * 0.55 + Math.cos(bA) * len * 0.55,
cy + Math.sin(a) * len * 0.55 + Math.sin(bA) * len * 0.55,
);
cracks.strokePath();
}
scene.tweens.add({ targets: cracks, alpha: 0, delay: 220, duration: 80, onComplete: () => cracks.destroy() });
});
}
_doEvilExplosion(cx, cy, D) {
const scene = this.scene;
// Vial implodes inward
scene.tweens.add({
targets: this.container, alpha: 0, scaleX: 0, scaleY: 0,
duration: 300, ease: 'Back.easeIn',
});
// Two expanding dark rings
[{ color: 0xaa0000, r: 15, delay: 0, toScale: 9, dur: 650 },
{ color: 0x550066, r: 22, delay: 90, toScale: 6, dur: 750 }].forEach(o => {
const ring = scene.add.graphics().setDepth(D);
ring.lineStyle(5, o.color, 1);
ring.strokeCircle(cx, cy, o.r);
scene.tweens.add({
targets: ring, scaleX: o.toScale, scaleY: o.toScale, alpha: 0,
delay: o.delay, duration: o.dur, ease: 'Expo.easeOut',
onComplete: () => ring.destroy(),
});
});
// Central dark burst
const burst = scene.add.graphics().setDepth(D + 2);
burst.fillStyle(0x110000, 1);
burst.fillCircle(cx, cy, 28);
scene.tweens.add({
targets: burst, scaleX: 4.5, scaleY: 4.5, alpha: 0,
duration: 350, ease: 'Expo.easeOut', onComplete: () => burst.destroy(),
});
// Jagged dark shards radiate outward with gravity fall
const shardColors = [0x990000, 0x440022, 0x221100, 0x000000, 0x660044];
for (let i = 0; i < 14; i++) {
const angle = (Math.PI * 2 / 14) * i + (Math.random() - 0.5) * 0.35;
const dist = 110 + Math.random() * 190;
const sz = 7 + Math.random() * 13;
const shard = scene.add.graphics().setDepth(D + 1);
shard.fillStyle(shardColors[i % shardColors.length], 1);
shard.fillTriangle(0, -sz * 1.3, -sz * 0.45, sz * 0.4, sz * 0.5, sz * 0.3);
shard.setPosition(cx, cy).setRotation(Math.random() * Math.PI * 2);
scene.tweens.add({
targets: shard,
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist + dist * 0.25,
rotation: shard.rotation + (Math.random() - 0.5) * 9,
scaleX: 0.1, scaleY: 0.1, alpha: 0,
duration: 850 + Math.random() * 350,
ease: 'Cubic.easeOut', delay: Math.random() * 80,
onComplete: () => shard.destroy(),
});
}
// Dark smoke clouds rise and dissolve
const smokeColors = [0x1a0000, 0x0a000a, 0x110011, 0x001100];
for (let i = 0; i < 9; i++) {
const smoke = scene.add.graphics().setDepth(D - 1);
smoke.fillStyle(smokeColors[i % smokeColors.length], 0.78);
smoke.fillCircle(0, 0, 13 + Math.random() * 12);
smoke.setPosition(cx + (Math.random() - 0.5) * 55, cy + (Math.random() - 0.5) * 40);
smoke.setScale(0.4);
scene.tweens.add({
targets: smoke,
x: smoke.x + (Math.random() - 0.5) * 75,
y: smoke.y - 95 - Math.random() * 95,
scaleX: 3, scaleY: 3, alpha: 0,
duration: 1300 + Math.random() * 700,
delay: Math.random() * 200, ease: 'Cubic.easeOut',
onComplete: () => smoke.destroy(),
});
}
// Evil energy particles scatter (red, purple, sickly green)
const pColors = [0xff0000, 0xaa0000, 0x880055, 0x005500, 0x440000];
for (let i = 0; i < 22; i++) {
const dot = scene.add.graphics().setDepth(D + 1);
dot.fillStyle(pColors[i % pColors.length], 1);
dot.fillCircle(0, 0, 2 + Math.random() * 4);
dot.setPosition(cx + (Math.random() - 0.5) * 25, cy + (Math.random() - 0.5) * 40);
const pa = Math.random() * Math.PI * 2;
const pd = 70 + Math.random() * 160;
scene.tweens.add({
targets: dot,
x: dot.x + Math.cos(pa) * pd,
y: dot.y + Math.sin(pa) * pd + 25,
alpha: 0, scaleX: 0.1, scaleY: 0.1,
duration: 750 + Math.random() * 450,
delay: Math.random() * 150, ease: 'Cubic.easeOut',
onComplete: () => dot.destroy(),
});
}
// Skull icon rises from the wreckage
const skull = scene.add.text(cx, cy - 10, '💀', { fontSize: '60px' })
.setOrigin(0.5).setDepth(D + 6).setAlpha(0).setScale(0.2);
scene.tweens.add({
targets: skull, alpha: 1, scale: 1, duration: 250, ease: 'Back.easeOut',
onComplete: () => {
scene.tweens.add({
targets: skull, y: skull.y - 140, alpha: 0, scale: 1.9,
duration: 1900, delay: 400, ease: 'Cubic.easeOut',
onComplete: () => skull.destroy(),
});
},
});
// "CONDEMNED" label punches in then sinks
const condText = scene.add.text(cx, cy + 58, '☠ CONDEMNED ☠', {
fontSize: '18px', fontFamily: 'Georgia, serif',
color: '#cc0000', stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5).setDepth(D + 6).setAlpha(0).setScale(0.4);
scene.tweens.add({
targets: condText, alpha: 1, scale: 1, delay: 260, duration: 370, ease: 'Back.easeOut',
onComplete: () => {
scene.tweens.add({
targets: condText, alpha: 0, y: condText.y + 65,
delay: 1100, duration: 900, ease: 'Cubic.easeIn',
onComplete: () => condText.destroy(),
});
},
});
}
/**
* Holy destruction: The Lord's vial ascends in a divine golden explosion.
* Called when Sin wins.
*/
shatterHoly() {
const scene = this.scene;
const cx = this.baseX;
const cy = this.baseY + this.VIAL_H / 2;
const D = 80;
if (this._shakeTween) { this._shakeTween.stop(); this._shakeTween = null; }
this.container.setPosition(this.baseX, this.baseY);
// Blinding white flash
const overlay = scene.add.rectangle(800, 450, 1600, 900, 0xffffee).setAlpha(0).setDepth(D + 20);
scene.tweens.add({
targets: overlay, alpha: { from: 0, to: 0.85 },
duration: 200, yoyo: true, ease: 'Sine.easeInOut',
onComplete: () => overlay.destroy(),
});
// Golden glow outline surrounds the vial
const glow = scene.add.graphics().setDepth(D - 1);
glow.lineStyle(20, 0xffd700, 0.7);
glow.strokeRoundedRect(
cx - this.VIAL_W / 2 - 7, this.baseY - 7,
this.VIAL_W + 14, this.VIAL_H + 14, this.VIAL_W / 2 + 4,
);
glow.setAlpha(0);
scene.tweens.add({ targets: glow, alpha: 1, duration: 320, yoyo: true, onComplete: () => glow.destroy() });
// Gentle double-pulse of the vial before it explodes
scene.tweens.add({
targets: this.container, scaleX: 1.08, scaleY: 1.08,
duration: 200, yoyo: true, ease: 'Sine.easeInOut',
onComplete: () => {
scene.tweens.add({
targets: this.container, scaleX: 1.12, scaleY: 1.12,
duration: 200, yoyo: true, ease: 'Sine.easeInOut',
onComplete: () => this._doHolyExplosion(cx, cy, D),
});
},
});
// God rays fan out at 150 ms (before the explosion)
scene.time.delayedCall(150, () => {
const rays = scene.add.graphics().setDepth(D - 2);
rays.setPosition(cx, cy);
const numRays = 16;
for (let i = 0; i < numRays; i++) {
const angle = (Math.PI * 2 / numRays) * i;
const len = 280;
const hw = 0.12; // half-angle of each ray
rays.fillStyle(0xfff0a0, i % 2 === 0 ? 0.55 : 0.28);
rays.fillTriangle(
0, 0,
Math.cos(angle - hw) * len, Math.sin(angle - hw) * len,
Math.cos(angle + hw) * len, Math.sin(angle + hw) * len,
);
}
rays.setScale(0.05);
scene.tweens.add({
targets: rays, scaleX: 1.7, scaleY: 1.7, alpha: 0,
duration: 1100, ease: 'Cubic.easeOut',
onComplete: () => rays.destroy(),
});
});
}
_doHolyExplosion(cx, cy, D) {
const scene = this.scene;
// Vial ascends and shrinks into heaven
scene.tweens.add({
targets: this.container,
y: this.baseY - 190, alpha: 0, scaleX: 0.55, scaleY: 0.55,
duration: 1400, ease: 'Cubic.easeOut',
});
// Two golden expanding rings
[{ color: 0xffd700, r: 18, delay: 0, toScale: 9, dur: 700 },
{ color: 0xffffff, r: 12, delay: 80, toScale: 7, dur: 820 }].forEach(o => {
const ring = scene.add.graphics().setDepth(D);
ring.lineStyle(5, o.color, 1);
ring.strokeCircle(cx, cy, o.r);
scene.tweens.add({
targets: ring, scaleX: o.toScale, scaleY: o.toScale, alpha: 0,
delay: o.delay, duration: o.dur, ease: 'Expo.easeOut',
onComplete: () => ring.destroy(),
});
});
// Radiant white burst from center
const burst = scene.add.graphics().setDepth(D + 2);
burst.fillStyle(0xffffff, 1);
burst.fillCircle(cx, cy, 32);
scene.tweens.add({
targets: burst, scaleX: 5.5, scaleY: 5.5, alpha: 0,
duration: 460, ease: 'Expo.easeOut', onComplete: () => burst.destroy(),
});
// Golden diamond shards biased upward (upper-half arc + some sideways)
const shardColors = [0xffd700, 0xffffa0, 0xffffff, 0xffe066, 0xffc000];
for (let i = 0; i < 14; i++) {
const angle = (Math.PI * 1.4 / 14) * i - Math.PI * 1.1;
const dist = 100 + Math.random() * 210;
const sz = 5 + Math.random() * 11;
const shard = scene.add.graphics().setDepth(D + 1);
shard.fillStyle(shardColors[i % shardColors.length], 1);
// Diamond shape: top + bottom triangle
shard.fillTriangle(0, -sz, -sz * 0.3, 0, sz * 0.3, 0);
shard.fillTriangle(-sz * 0.3, 0, sz * 0.3, 0, 0, sz * 0.65);
shard.setPosition(cx, cy).setRotation(Math.random() * Math.PI * 2);
scene.tweens.add({
targets: shard,
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
rotation: shard.rotation + (Math.random() - 0.5) * 6,
scaleX: 0.1, scaleY: 0.1, alpha: 0,
duration: 1050 + Math.random() * 350,
ease: 'Cubic.easeOut', delay: Math.random() * 80,
onComplete: () => shard.destroy(),
});
}
// Golden sparkle particles
const sparkChars = ['✦', '★', '✧', '✶', '+'];
for (let i = 0; i < 24; i++) {
const sp = scene.add.text(
cx + (Math.random() - 0.5) * 30, cy + (Math.random() - 0.5) * 40,
sparkChars[Math.floor(Math.random() * sparkChars.length)],
{ fontSize: `${8 + Math.floor(Math.random() * 20)}px`, color: '#ffd700',
stroke: '#ffffff', strokeThickness: 1 },
).setOrigin(0.5).setDepth(D + 3).setAlpha(0);
const pa = Math.random() * Math.PI * 2;
const pd = 75 + Math.random() * 190;
scene.tweens.add({
targets: sp,
x: sp.x + Math.cos(pa) * pd,
y: sp.y + Math.sin(pa) * pd - 35,
alpha: { from: 1, to: 0 }, scale: 1.7,
duration: 1100 + Math.random() * 500,
delay: Math.random() * 180, ease: 'Cubic.easeOut',
onComplete: () => sp.destroy(),
});
}
// Large cross flashes and expands
const cross = scene.add.text(cx, cy, '✝', {
fontSize: '130px', color: '#ffffff',
stroke: '#ffd700', strokeThickness: 5,
shadow: { offsetX: 0, offsetY: 0, color: '#ffd700', blur: 30, fill: true },
}).setOrigin(0.5).setDepth(D + 5).setAlpha(0).setScale(0.2);
scene.tweens.add({
targets: cross, alpha: 0.95, scale: 1.1, duration: 280, ease: 'Back.easeOut',
onComplete: () => {
scene.tweens.add({
targets: cross, alpha: 0, scale: 2.0,
duration: 750, ease: 'Cubic.easeOut',
onComplete: () => cross.destroy(),
});
},
});
// "GLORY" text rises and fades
const gloryText = scene.add.text(cx, cy + 55, '✝ GLORY ✝', {
fontSize: '20px', fontFamily: 'Georgia, serif',
color: '#ffd700', stroke: '#ffffff', strokeThickness: 3,
shadow: { offsetX: 0, offsetY: 0, color: '#ffd700', blur: 14, fill: true },
}).setOrigin(0.5).setDepth(D + 5).setAlpha(0).setScale(0.4);
scene.tweens.add({
targets: gloryText, alpha: 1, scale: 1, delay: 260, duration: 400, ease: 'Back.easeOut',
onComplete: () => {
scene.tweens.add({
targets: gloryText, alpha: 0, y: gloryText.y - 95,
delay: 1300, duration: 1000, ease: 'Cubic.easeOut',
onComplete: () => gloryText.destroy(),
});
},
});
}
}

View File

@ -96,11 +96,21 @@ export default class GameScene extends Phaser.Scene {
// Listen for spin button events from UIScene via global event bus
this.game.events.on('spin', () => this._triggerSpin(), this);
// Victory: play the appropriate one-shot video then stop
// Victory: play the victory video and shatter the losing vial
this.game.events.on('vial-winner', ({ winner }) => {
this._gameOver = true;
const key = winner.toLowerCase().includes('lord') ? 'lord-victory' : 'sin-victory';
const lordWins = winner.toLowerCase().includes('lord');
const key = lordWins ? 'lord-victory' : 'sin-victory';
this._setVideo(key, false);
// Shatter the losing vial after a brief dramatic pause
this.time.delayedCall(550, () => {
if (lordWins) {
this.sinVial.shatterEvil();
} else {
this.lordVial.shatterHoly();
}
});
}, this);
}