feat: add timed pose transitions to host portrait reactions

Introduce temporary emotional reactions for the host portrait that hold
for a duration before reverting to a resting pose. The setPose method
now accepts an optional holdMs parameter; when provided, the pose is
applied temporarily and transitions back to the base (danger-driven)
pose once the timer elapses. This prevents the host from remaining in
a reaction pose indefinitely after events like scoring or opponent
clears.
This commit is contained in:
Brian Fertig 2026-07-22 13:01:10 -06:00
parent ecfdf4c59b
commit 962466a0a3
2 changed files with 30 additions and 11 deletions

View File

@ -20,6 +20,7 @@ const BOARD_LEFT = 744;
const BOARD_TOP = 116;
const STEP_MS = 1000 / 60; // logic tick rate
const POSE_HOLD_MS = 5000; // how long a reaction pose holds before resting
export const D = { bg: -10, boardBg: 0, panels: 5, incoming: 4, cursor: 12, fx: 16, hud: 24, portrait: 20, overlay: 60, overlayUI: 62 };
@ -288,8 +289,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
fontFamily: FONT, fontSize: '34px', color, stroke: '#101018', strokeThickness: 6,
}).setOrigin(0.5).setDepth(D.fx);
this.tweens.add({ targets: t, y: t.y - 70, alpha: 0, duration: 900, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
// host reacts: player doing well → worried
this.hostPortrait?.setPose('worried');
// host reacts: player doing well → worried for a beat, then back to rest
this.hostPortrait?.setPose('worried', POSE_HOLD_MS);
}
// floating score
if (n && e.score) {
@ -419,7 +420,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
stroke: '#3a2a00', strokeThickness: 9, lineSpacing: 10,
}).setOrigin(0.5).setDepth(D.fx);
this.tweens.add({ targets: banner, scale: { from: 0.4, to: 1 }, duration: 340, ease: 'Back.easeOut' });
this.hostPortrait?.setPose('worried');
this.hostPortrait?.setPose('worried', POSE_HOLD_MS);
this.time.delayedCall(1400, () => {
this.tweens.add({

View File

@ -288,19 +288,37 @@ export function createHostPortrait(scene, x, y, round, index = scene.roundIndex)
const frameBg = scene.add.rectangle(x, y, 300, 380, 0x0e1526, 0.5).setStrokeStyle(5, 0x4a6ea8).setDepth(20);
const img = buildPortraitImage(scene, round, index, 'neutral', x, y, 1.0);
img.setDepth(21);
const apply = (pose) => {
if (scene.textures.exists('tetrisattack-characters')) {
img.setFrame(index * 3 + (POSE_OFFSET[pose] ?? 0));
} else {
// opponents fallback has no poses — pulse instead
const tint = pose === 'happy' ? 0xffd0d0 : pose === 'worried' ? 0xd0ffe0 : 0xffffff;
img.setTint(tint);
scene.tweens.add({ targets: img, scale: img.scaleX * 1.06, duration: 120, yoyo: true });
}
};
// basePose is the pose the host rests in (driven by the danger flag); a held
// pose is a momentary reaction that reverts to it once holdMs elapses.
let basePose = 'neutral';
let holdTimer = null;
const clearHold = () => { if (holdTimer) { holdTimer.remove(false); holdTimer = null; } };
return {
sprite: img,
setPose(pose) {
if (scene.textures.exists('tetrisattack-characters')) {
img.setFrame(index * 3 + (POSE_OFFSET[pose] ?? 0));
setPose(pose, holdMs = 0) {
clearHold();
if (holdMs > 0) {
apply(pose);
holdTimer = scene.time.delayedCall(holdMs, () => { holdTimer = null; apply(basePose); });
} else {
// opponents fallback has no poses — pulse instead
const tint = pose === 'happy' ? 0xffd0d0 : pose === 'worried' ? 0xd0ffe0 : 0xffffff;
img.setTint(tint);
scene.tweens.add({ targets: img, scale: img.scaleX * 1.06, duration: 120, yoyo: true });
basePose = pose;
apply(pose);
}
},
destroy() { frameBg.destroy(); img.destroy(); },
destroy() { clearHold(); frameBg.destroy(); img.destroy(); },
};
}