Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/abilities/Snow-Bunny.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,6 @@ export default (G: Game) => {
activate: function (path, args) {
const ability = this;
ability.end();
G.Phaser.camera.shake(0.01, 90, true, G.Phaser.camera.SHAKE_HORIZONTAL, true);

const hexWithTarget = path.find((hex: Hex) => {
const creature = getPointFacade().getCreaturesAt({ x: hex.x, y: hex.y })[0];
Expand Down Expand Up @@ -493,6 +492,10 @@ export default (G: Game) => {
// @ts-expect-error this refers to the animation object, _not_ the ability
this.destroy();

// Play hit sound and shake screen when projectile reaches target
G.soundsys.playSFX('sounds/swing2');
G.Phaser.camera.shake(0.01, 90, true, G.Phaser.camera.SHAKE_HORIZONTAL, true);

// Copy to not alter ability strength
const dmg = $j.extend({}, ability.damages);
dmg.crush += 3 * dist; // Add distance to crush damage
Expand Down
5 changes: 5 additions & 0 deletions src/ability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,11 @@ export class Ability {
this.setUsed(true); // Should always be here
}
game.signals.creature.dispatch('abilityend', { creature: this.creature });
// Save undo state and show undo button on first action
if (!game.undoCreatureState) {
game.saveUndoState();
game.UI.btnDelay.changeState('slideIn');
}
game.UI.btnDelay.changeState('disabled');
game.UI.selectAbility(-1);

Expand Down
5 changes: 5 additions & 0 deletions src/creature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,11 @@ export class Creature {
},
});
}
// Save undo state and show undo button on first action
if (!game.undoCreatureState) {
game.saveUndoState();
game.UI.btnDelay.changeState('slideIn');
}
game.UI.btnDelay.changeState('disabled');
args.creature.moveTo(hex, {
animation: args.creature.movementType() === 'flying' ? 'fly' : 'walk',
Expand Down
104 changes: 104 additions & 0 deletions src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ export default class Game {
freezedInput: boolean;
turnThrottle: boolean;
turn: number;
undoCreatureState: {
creatureId: number;
x: number;
y: number;
remainingMove: number;
health: number;
energy: number;
} | null;
undoUsedThisRound: boolean;
Phaser: Phaser;
msg: any; // type this properly
triggers: Record<string, RegExp>;
Expand Down Expand Up @@ -190,6 +199,8 @@ export default class Game {
this.freezedInput = false;
this.turnThrottle = false;
this.turn = 0;
this.undoCreatureState = null;
this.undoUsedThisRound = false;

// Phaser
this.Phaser = new Phaser.Game(1920, 1080, Phaser.AUTO, 'combatwrapper', {
Expand Down Expand Up @@ -827,6 +838,16 @@ export default class Game {
// Updates UI to match new creature
this.UI.updateActivebox();
this.updateQueueDisplay();
// Reset undo state for the new active creature
this.undoCreatureState = null;
this.undoUsedThisRound = false;
if (this.UI.btnDelay) {
if (this.activeCreature?.canWait && !this.queue.isCurrentEmpty()) {
this.UI.btnDelay.changeState('slideIn');
} else {
this.UI.btnDelay.changeState('disabled');
}
}
this.signals.creature.dispatch('activate', { creature: this.activeCreature });
if (this.multiplayer && this.playersReady && this.gameplay instanceof Gameplay) {
this.gameplay.updateTurn();
Expand Down Expand Up @@ -997,6 +1018,89 @@ export default class Game {
this.nextCreature();
}

/**
* Save the current creature state for potential undo
*/
saveUndoState() {
const creature = this.activeCreature;
if (!creature || this.undoUsedThisRound) {
return;
}
this.undoCreatureState = {
creatureId: creature.id,
x: creature.x,
y: creature.y,
remainingMove: creature.remainingMove,
health: creature.health,
energy: creature.energy,
};
}

/**
* Undo the last action, restoring the creature to its previous state
* Only usable once per round
*/
undoMove() {
if (this.turnThrottle || !this.undoCreatureState || this.undoUsedThisRound) {
return;
}

const state = this.undoCreatureState;
const creature = this.creatures[state.creatureId];

if (!creature || creature.dead || creature !== this.activeCreature) {
return;
}

this.turnThrottle = true;
this.undoUsedThisRound = true;

// Restore creature position
const oldHex = this.grid.hexes[state.y][state.x];
creature.x = state.x;
creature.y = state.y;
creature.pos = oldHex.pos;
creature.remainingMove = state.remainingMove;
creature.health = state.health;
creature.energy = state.energy;

// Update hex tracking
creature.cleanHex();
creature.updateHex();

// Update grid z-order
this.grid.orderCreatureZ();

// Update health/energy display
creature.updateHealth();
if (creature === this.activeCreature) {
if (this.UI.energyBar) {
this.UI.energyBar.animSize(creature.energy / creature.stats.energy);
}
if (this.UI.healthBar) {
this.UI.healthBar.animSize(creature.health / creature.stats.health);
}
}

// Hide undo button, show delay button if available
if (this.UI.btnDelay) {
if (creature.canWait && !this.queue.isCurrentEmpty()) {
this.UI.btnDelay.changeState('slideIn');
} else {
this.UI.btnDelay.changeState('disabled');
}
}

creature.hint('Undo', 'msg_effects');

this.undoCreatureState = null;

setTimeout(() => {
this.turnThrottle = false;
creature.queryMove();
}, 500);
}

startTimer() {
clearInterval(this.timeInterval);

Expand Down
27 changes: 27 additions & 0 deletions src/sound/soundsys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export class SoundSys {
this.loadSound(path);
}
}

// Unlock AudioContext on iOS - requires user gesture to resume
this.unlockAudioOnIOS();
}
}

Expand Down Expand Up @@ -143,6 +146,30 @@ export class SoundSys {
}
}

/**
* Unlock AudioContext on iOS devices.
* iOS Safari and Chrome require a user gesture to resume the AudioContext
* which starts in a suspended state. This method adds one-time listeners
* to resume the context on user interaction.
*/
private unlockAudioOnIOS() {
const unlock = () => {
if (this.context && this.context.state === 'suspended') {
this.context.resume().then(() => {
console.log('[Audio] AudioContext resumed on user interaction');
});
}
// Remove listeners after first interaction
document.removeEventListener('click', unlock);
document.removeEventListener('touchstart', unlock);
document.removeEventListener('keydown', unlock);
};

document.addEventListener('click', unlock, { once: true });
document.addEventListener('touchstart', unlock, { once: true });
document.addEventListener('keydown', unlock, { once: true });
}

private playSound(sound: AudioBuffer, node: GainNode): SoundSysAudioBufferSourceNode {
if (!this.envHasSound) {
return new NullAudioBufferSourceNode();
Expand Down
31 changes: 31 additions & 0 deletions src/ui/fullscreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,42 @@ export class Fullscreen {
this.button
.querySelectorAll('.fullscreen__title')
.forEach((el) => (el.textContent = 'Contract'));
// Lock orientation to landscape when entering fullscreen on mobile
this.lockOrientation();
} else {
this.button.classList.remove('fullscreenMode');
this.button
.querySelectorAll('.fullscreen__title')
.forEach((el) => (el.textContent = 'FullScreen'));
// Unlock orientation when exiting fullscreen
this.unlockOrientation();
}
}

/**
* Lock screen orientation to landscape using the Screen Orientation API.
* This requires fullscreen mode on most mobile browsers.
*/
private async lockOrientation() {
const screen = window.screen;
if (screen?.orientation?.lock) {
try {
await screen.orientation.lock('landscape');
} catch (error) {
// Orientation lock is not supported or blocked (e.g., not in fullscreen yet,
// or the device doesn't support locking). Silently ignore.
console.debug('Screen orientation lock not available:', error);
}
}
}

/**
* Unlock screen orientation.
*/
private unlockOrientation() {
const screen = window.screen;
if (screen?.orientation?.unlock) {
screen.orientation.unlock();
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/ui/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ export class Hotkeys {
}
}

pressZ(event) {
if (event.ctrlKey || event.metaKey) {
// Ctrl+Z: Undo Move
if (this.ui.game.undoCreatureState && !this.ui.game.undoUsedThisRound) {
if (!this.ui.game.turnThrottle) {
this.ui.game.undoMove();
}
}
}
}

pressT() {
this.ui.dashopen ? this.ui.closeDash() : this.ui.btnToggleScore.triggerClick();
}
Expand Down Expand Up @@ -217,6 +228,11 @@ export function getHotKeys(hk) {
hk.pressX(event);
},
},
KeyZ: {
onkeydown(event) {
hk.pressZ(event);
},
},
Tab: {
onkeydown(event) {
hk.pressTab(event);
Expand Down
29 changes: 28 additions & 1 deletion src/ui/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export class UI {
animationUpgradeTimeOutID: ReturnType<typeof setTimeout>;
queryUnit: string;
btnDelay: Button;
btnUndoMove: Button;
btnFlee: Button;
btnExit: Button;
materializeButton: Button;
Expand Down Expand Up @@ -228,13 +229,22 @@ export class UI {
);
this.buttons.push(this.btnSkipTurn);

// Delay Unit Button
// Delay Unit Button (also handles Undo Move when available)
this.btnDelay = new Button(
{
$button: $j('#delay.button'),
hasShortcut: true,
click: () => {
if (!this.dashopen) {
// If undo state is available and not yet used, trigger undo instead of delay
if (game.undoCreatureState && !game.undoUsedThisRound) {
if (game.turnThrottle) {
return;
}
game.undoMove();
return;
}
// Normal delay turn behavior
if (game.turnThrottle || !game.activeCreature?.canWait || game.queue.isCurrentEmpty()) {
return;
}
Expand All @@ -250,6 +260,18 @@ export class UI {
);
this.buttons.push(this.btnDelay);

// Undo Move Button - uses same DOM element as btnDelay, triggered via btnDelay's click
// This is kept for explicit state management; the actual click goes through btnDelay
this.btnUndoMove = new Button(
{
$button: $j('<div/>'),
hasShortcut: false,
click: () => {},
},
{ isAcceptingInput: this.configuration.isAcceptingInput },
);
this.buttons.push(this.btnUndoMove);

// Flee Match Button
this.btnFlee = new Button(
{
Expand Down Expand Up @@ -370,6 +392,11 @@ export class UI {
// Colored frame around selected ability
if (ability.require() == true && i != 0) {
this.selectAbility(i);
} else if (i != 0 && (ability.used || !ability.require())) {
// Show cancel icon for already used or unusable active abilities
b.$button.removeClass('cancelIcon');
b.$button.cssTransition('cancelIcon', 1000);
return;
}
// Activate Ability
game.activeCreature.abilities[i].use();
Expand Down
Loading