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,