diff --git a/assets/icons/undo.svg b/assets/icons/undo.svg new file mode 100644 index 000000000..f2145ab20 --- /dev/null +++ b/assets/icons/undo.svg @@ -0,0 +1 @@ + diff --git a/src/ability.ts b/src/ability.ts index 56197c645..3ca5552be 100644 --- a/src/ability.ts +++ b/src/ability.ts @@ -481,6 +481,10 @@ export class Ability { } } + // Save undo state for abilities and switch button to undo mode + game.saveUndoState(); + $j('#delay.button').css('background-image', "url('assets/icons/undo.svg')"); + return this.animation2({ arg: args, }); diff --git a/src/creature.ts b/src/creature.ts index f1666550e..19f292ac5 100644 --- a/src/creature.ts +++ b/src/creature.ts @@ -680,6 +680,9 @@ export class Creature { }, }); } + // Save undo state and switch button to undo mode + game.saveUndoState(); + $j('#delay.button').css('background-image', "url('assets/icons/undo.svg')"); game.UI.btnDelay.changeState('disabled'); args.creature.moveTo(hex, { animation: args.creature.movementType() === 'flying' ? 'fly' : 'walk', diff --git a/src/game.ts b/src/game.ts index d1ca5f98c..af0035c8e 100644 --- a/src/game.ts +++ b/src/game.ts @@ -113,6 +113,8 @@ export default class Game { freezedInput: boolean; turnThrottle: boolean; turn: number; + undoUsedThisRound: boolean; + undoSnapshot: any; Phaser: Phaser; msg: any; // type this properly triggers: Record; @@ -190,6 +192,8 @@ export default class Game { this.freezedInput = false; this.turnThrottle = false; this.turn = 0; + this.undoUsedThisRound = false; + this.undoSnapshot = null; // Phaser this.Phaser = new Phaser.Game(1920, 1080, Phaser.AUTO, 'combatwrapper', { @@ -756,6 +760,8 @@ export default class Game { this.turn++; this.log(`Round ${this.turn}`, 'roundmarker', true); this.onStartOfRound(); + this.undoUsedThisRound = false; + this.undoSnapshot = null; this.nextCreature(); } @@ -997,6 +1003,106 @@ export default class Game { this.nextCreature(); } + /** + * Save the current game state for undo. + * Captures creature positions, stats, and other critical state. + */ + saveUndoState() { + // Only save if we haven't already saved for this action + if (this.undoSnapshot !== null) { + return; + } + + const snapshot: any = { + // Save last action type for verification + lastAction: this.gamelog.actions.length > 0 ? this.gamelog.actions[this.gamelog.actions.length - 1].action : null, + // Save active creature id + activeCreatureId: this.activeCreature?.id, + // Save creature states + creatures: this.creatures.map((creature) => { + if (!creature) return null; + return { + id: creature.id, + x: creature.x, + y: creature.y, + health: creature.health, + energy: creature.energy, + remainingMove: creature.remainingMove, + travelDist: creature.travelDist, + waitedTurn: (creature as any)._waitedTurn, + turnsActive: creature.turnsActive, + dead: creature.dead, + stats: { ...creature.stats }, + hexagons: creature.hexagons ? creature.hexagons.map((h) => ({ x: h.x, y: h.y })) : [], + }; + }), + turn: this.turn, + }; + + this.undoSnapshot = snapshot; + } + + /** + * Undo the last action, restoring the game to its previous state. + * Only usable once per round. + */ + undoMove() { + if (this.undoUsedThisRound || this.undoSnapshot === null) { + return; + } + + const snapshot = this.undoSnapshot; + const game = this; + + // Remove the last action from the game log + this.gamelog.actions.pop(); + + // Restore creature states + snapshot.creatures.forEach((savedCrea) => { + if (savedCrea === null) return; + const creature = game.creatures.find((c) => c && c.id === savedCrea.id); + if (!creature) return; + + // Clean old hexes first + creature.cleanHex(); + + // Restore position and stats + creature.x = savedCrea.x; + creature.y = savedCrea.y; + creature.health = savedCrea.health; + creature.energy = savedCrea.energy; + creature.remainingMove = savedCrea.remainingMove; + creature.travelDist = savedCrea.travelDist; + (creature as any)._waitedTurn = savedCrea.waitedTurn; + creature.turnsActive = savedCrea.turnsActive; + creature.dead = savedCrea.dead; + + // Restore stats + Object.assign(creature.stats, savedCrea.stats); + + // Update hexes and sprite position + creature.updateHex(); + // Teleport sprite to new position (instant, no animation) + const targetHex = creature.hexagons[creature.size - 1]; + creature.creatureSprite.setHex(targetHex, 0); + }); + + // Mark undo as used this round + this.undoUsedThisRound = true; + this.undoSnapshot = null; + + // Update UI + if (this.UI) { + this.UI.btnDelay.changeState('disabled'); + // Re-query move for the active creature + if (this.activeCreature) { + this.activeCreature.queryMove(); + } + } + + this.log('Action undone.', 'undo'); + } + startTimer() { clearInterval(this.timeInterval); @@ -1513,6 +1619,10 @@ export default class Game { opt = $j.extend(defaultOpt, opt); this.clearOncePerDamageChain(); + // Save undo state before each action (only if undo not used this round) + if (!this.undoUsedThisRound && this.gamelog.actions.length > 0) { + this.saveUndoState(); + } switch (o.action) { case 'move': this.activeCreature.moveTo(this.grid.hexes[o.target.y][o.target.x], { diff --git a/src/ui/hotkeys.ts b/src/ui/hotkeys.ts index 7f36f2e17..41cdbd6a5 100644 --- a/src/ui/hotkeys.ts +++ b/src/ui/hotkeys.ts @@ -153,6 +153,16 @@ export class Hotkeys { !this.ui.dashopen && this.ui.game.grid.confirmHex(); } + pressCtrlZ() { + // Undo the last action (Ctrl+Z) + if (this.ui.dashopen) return; + const game = this.ui.game; + if (game.undoSnapshot !== null && !game.undoUsedThisRound) { + this.ui.btnDelay.$button.css('background-image', "url('assets/icons/undo.svg')"); + game.undoMove(); + } + } + pressF11(event) { event.preventDefault(); const fullscreen = new Fullscreen(document.getElementById('fullscreen')); @@ -289,6 +299,14 @@ export function getHotKeys(hk) { hk.pressSpace(); }, }, + KeyZ: { + onkeydown(event) { + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + hk.pressCtrlZ(); + } + }, + }, F11: { onkeydown(event) { hk.pressF11(event); diff --git a/src/ui/interface.ts b/src/ui/interface.ts index 14453859e..984d7d650 100644 --- a/src/ui/interface.ts +++ b/src/ui/interface.ts @@ -227,13 +227,21 @@ export class UI { ); this.buttons.push(this.btnSkipTurn); - // Delay Unit Button + // Delay Unit Button / Undo Button this.btnDelay = new Button( { $button: $j('#delay.button'), hasShortcut: true, click: () => { if (!this.dashopen) { + // Check if undo is available + if (game.undoSnapshot !== null && !game.undoUsedThisRound) { + // Undo the last action + $j('#delay.button').css('background-image', "url('assets/icons/undo.svg')"); + game.undoMove(); + return; + } + // Normal delay behavior if (game.turnThrottle || !game.activeCreature?.canWait || game.queue.isCurrentEmpty()) { return; } diff --git a/src/ui/queue.ts b/src/ui/queue.ts index 24ee04d6b..31fa732b7 100644 --- a/src/ui/queue.ts +++ b/src/ui/queue.ts @@ -482,6 +482,25 @@ class CreatureVignette extends Vignette { animateUpdate(queuePosition: number, x: number) { const scale = this.isActiveCreature ? 1.25 : 1.0; + // When the creature is delayed, it should leap/jump into its new queue position + // rather than just sliding, making the position change more intuitive + if (this.creature.isDelayed && this.turnNumberIsCurrentTurn) { + const JUMP_HEIGHT = 60; + const BOUNCE_MS = 500; + const restingKeyframe = { transform: `translateX(${x}px) translateY(0px) scale(${scale})` }; + const keyframes = [ + restingKeyframe, + { transform: `translateX(${x}px) translateY(${-JUMP_HEIGHT}px) scale(${scale})` }, + restingKeyframe, + ]; + const animation = this.el.animate(keyframes, { + duration: BOUNCE_MS, + easing: 'ease-out', + fill: 'forwards', + }); + animation.commitStyles(); + return animation; + } const keyframes = [{ transform: `translateX(${x}px) translateY(0px) scale(${scale})` }]; const animation = this.el.animate(keyframes, { duration: CONST.animDurationMS,