diff --git a/src/__tests__/utility/hexgrid-xray.ts b/src/__tests__/utility/hexgrid-xray.ts new file mode 100644 index 000000000..b0ff46d0a --- /dev/null +++ b/src/__tests__/utility/hexgrid-xray.ts @@ -0,0 +1,42 @@ +import { describe, expect, jest, test } from '@jest/globals'; + +jest.mock('phaser-ce', () => ({})); +jest.mock('../../creature', () => ({ + Creature: class Creature {}, +})); + +import { Creature } from '../../creature'; +import { HexGrid } from '../../utility/hexgrid'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +function makeCreature(hexagons: Array<{ ghostOverlap: jest.Mock }>) { + const CreatureCtor = Creature as unknown as { new (): Creature }; + const creature = new CreatureCtor() as any; + creature.hexagons = hexagons; + creature.xray = jest.fn(); + return creature; +} + +describe('HexGrid.xray', () => { + test('checks every preview footprint hex for obstructing creatures', () => { + const activeHex = { ghostOverlap: jest.fn() }; + const activeCreature = makeCreature([activeHex]); + const otherCreature = makeCreature([]); + const frontPreviewHex = { creature: undefined, ghostOverlap: jest.fn() }; + const backPreviewHex = { creature: undefined, ghostOverlap: jest.fn() }; + + const grid = Object.create(HexGrid.prototype) as any; + grid.game = { + creatures: [activeCreature, otherCreature], + activeCreature, + }; + + grid.xray(frontPreviewHex as never, [frontPreviewHex as never, backPreviewHex as never]); + + expect(frontPreviewHex.ghostOverlap).toHaveBeenCalledTimes(1); + expect(backPreviewHex.ghostOverlap).toHaveBeenCalledTimes(1); + expect(activeHex.ghostOverlap).toHaveBeenCalledWith(activeCreature); + expect(activeCreature.xray).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/src/__tests__/utility/query_footprint.ts b/src/__tests__/utility/query_footprint.ts new file mode 100644 index 000000000..41515c322 --- /dev/null +++ b/src/__tests__/utility/query_footprint.ts @@ -0,0 +1,58 @@ +import { describe, expect, jest, test } from '@jest/globals'; +import { getQueryFootprintHexes } from '../../utility/query_footprint'; + +type TestHex = { + x: number; + y: number; + isWalkable: jest.Mock; +}; + +function buildGrid(width: number, height = 1) { + const hexes: TestHex[][] = []; + + for (let y = 0; y < height; y++) { + const row: TestHex[] = []; + for (let x = 0; x < width; x++) { + row.push({ + x, + y, + isWalkable: jest.fn((size: number) => x - size + 1 >= 0), + }); + } + hexes.push(row); + } + + return { hexes }; +} + +describe('getQueryFootprintHexes', () => { + test('returns every occupied hex for a non-flipped multi-hex preview', () => { + const grid = buildGrid(10); + const footprint = getQueryFootprintHexes( + grid as never, + grid.hexes[0][6] as never, + 3, + false, + 12, + ); + + expect(footprint.map(({ x, y }) => [x, y])).toEqual([ + [6, 0], + [5, 0], + [4, 0], + ]); + expect(grid.hexes[0][6].isWalkable).toHaveBeenCalledWith(3, 12); + }); + + test('offsets the footprint when the active player is flipped', () => { + const grid = buildGrid(10); + const footprint = getQueryFootprintHexes(grid as never, grid.hexes[0][6] as never, 3, true, 12); + + expect(footprint.map(({ x, y }) => [x, y])).toEqual([ + [8, 0], + [7, 0], + [6, 0], + ]); + expect(grid.hexes[0][8].isWalkable).toHaveBeenCalledWith(3, 12); + }); +}); diff --git a/src/utility/hexgrid.ts b/src/utility/hexgrid.ts index 2a191f38c..2b847faf8 100644 --- a/src/utility/hexgrid.ts +++ b/src/utility/hexgrid.ts @@ -1,2479 +1,2504 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import * as $j from 'jquery'; -import { Direction, Hex } from './hex'; -import { Creature } from '../creature'; -import { search } from './pathfinding'; -import * as matrices from './matrices'; -import { Team, isTeam } from './team'; -import * as arrayUtils from './arrayUtils'; -import Game from '../game'; -import { DEBUG } from '../debug'; -import { HEX_WIDTH_PX } from './const'; -import { Point } from './pointfacade'; -import { AugmentedMatrix } from './matrices'; -import { PierceThroughBehavior } from '../ability'; - -const ROW_DEPTH_STRIDE = 100; - -const DEPTH_BAND = { - TRAP_GROUND: 0, - EFFECT_UNDER_UNITS: 20, - UNITS: 40, - EFFECT_OVER_UNITS: 80, - DROPS: 85, - TRAP_VOLUMETRIC: 90, -} as const; - -export type DepthBand = keyof typeof DEPTH_BAND; - -interface GridDefinition { - numRows: number; - numCols: number; - isFirstRowFull: boolean; -} - -export interface QueryOptions { - /** - * Target team. - */ - team: Team; - - /** - * Disable a choice if it does not contain a creature matching the team argument. - */ - requireCreature: boolean; - id: number; - flipped: boolean; - x: number; - y: number; - hexesDashed: Hex[]; - dashedHexesUnderCreature: boolean; - fillOnlyHoveredCreature: boolean; - shrunkenHexes: Hex[]; - hexesDeadZone: Hex[]; - directions: number[]; - includeCreature: boolean; - stopOnCreature: boolean; - pierceNumber: number; - pierceThroughBehavior: string; - - /** - * If defined, maximum distance of query in hexes. - */ - distance: number; - - /** - * If defined, minimum distance of query, 1 = 1 hex gap required. - */ - minDistance: number; - - isDirectionsQuery: boolean; - - /** - * After this distance, the direction choice will be be visualised by shrunken hexes. - * This visual state represents the ability having its effectiveness being reduced - * in some way (falling off). - */ - distanceFalloff: number; - - /** - * If a choice line stops on a creature via @param stopOnCreature, display - * dashed hexes after the creature up until the next obstacle - */ - dashedHexesAfterCreatureStop: boolean; - - /** - * Limit the length of dashed hexes added by @param dashedHexesAfterCreatureStop - */ - dashedHexesDistance: number; - - sourceCreature: Creature; - choices: Hex[][]; - - /** - * Object given to the events function (to easily pass variables for these functions). - */ - arg: any; - - optTest: (arg: Creature) => boolean; - ignoreCreatureTest?: (arg: Creature) => boolean; - - /** - * Function applied when clicking on one of the available hexes. - */ - fnOnSelect: () => void; - - /** - * Function applied when clicking again on the same hex. - */ - fnOnConfirm: () => void; - - /** - * Function applied when clicking a non reachable hex - */ - fnOnCancel: () => void; -} - -/** - * Object containing grid and methods concerning the whole grid. - * Should only have one instance during the game. - */ -export class HexGrid { - game: Game; - - /** - * Contain all hexes in row arrays (hexes[y][x]). - */ - hexes: Hex[][]; - - /** - * Last hex clicked! - */ - lastClickedHex: Hex; - - /** - * Prevents multiple shouts at the same time when a unit is clicked. - */ - onShoutCooldown: boolean; - - /** - * Last hovered creature. - */ - hoveredCreature: Creature | null = null; - - /** - * Last hex passed to xray(). Used to reapply the effect on tab focus. - */ - lastXrayHex: Hex | null = null; - - /** - * The hex the physical mouse pointer is currently over, updated before any - * freezedInput guard so it remains accurate during ability animations. - * Distinct from selectedHex which the keyboard cursor and queryHexes() reset. - */ - lastMouseHex: Hex | undefined = undefined; - - /** - * True while refreshHoverState() is replaying hover behavior programmatically. - * Used to avoid recursive query rebuilds caused by movement hover callbacks. - */ - isRefreshingHoverState = false; - - /** - * One-shot guard used to skip the next hover replay after a turn handoff. - */ - suppressNextHoverRefresh = false; - - /** - * Deferred clear for active-creature dashed hex visuals. This avoids - * toggling dashed->normal->dashed while the cursor crosses adjacent hexes. - */ - activeHexDashedClearTimeout: ReturnType | null = null; - - display: Phaser.Group; - gridGroup: Phaser.Group; - trapGroup: Phaser.Group; - hexesGroup: Phaser.Group; - displayHexesGroup: Phaser.Group; - overlayHexesGroup: Phaser.Group; - inputHexesGroup: Phaser.Group; - dropGroup: Phaser.Group; - creatureGroup: Phaser.Group; - // Health indicators rendered above all creature sprites so they are never occluded - healthIndicatorUiGroup: Phaser.Group; - trapOverGroup: Phaser.Group; - selectedHex: Hex; - _executionMode: boolean; - materialize_overlay: any; - secondary_overlay: any; - lastQueryOpt: any; - _flickerTween: Phaser.Tween | undefined; - _flickerTweenSecondary: Phaser.Tween | undefined; - - get allhexes(): Hex[] { - return this.hexes.flat(1); - } - - /** - * Create attributes and populate JS grid with Hex objects - * @param {Partial} gridDefinition - specifies a number of columns in the grid. - * The resulting grid has jagged, symmetrical edges. - * Only "full" rows have the specified number of columns. - * @param {Game} game - * @example - * // {numRows:5, numCols:4, isFirstRowFull: true} - * // - * // x x x x - full row - * // x x x - partial row - * // x x x x - full row - * // x x x - partial row - * // x x x x - full row - * @constructor - */ - constructor(gridDefinition: Partial, game: Game) { - const defaultGridDefinition = { - numRows: 9, - numCols: 16, - isFirstRowFull: false, - }; - - gridDefinition = { ...defaultGridDefinition, ...gridDefinition }; - const numRows = gridDefinition.numRows; - const numCols = gridDefinition.numCols; - const isFirstRowFull = gridDefinition.isFirstRowFull; - - this.game = game; - this.hexes = []; // Hex Array - this.lastClickedHex = undefined; - - this.display = game.Phaser.add.group(undefined, 'displayGroup'); - this.display.x = 230; - this.display.y = 380; - - this.gridGroup = game.Phaser.add.group(this.display, 'gridGroup'); - this.gridGroup.scale.set(1, 0.75); - - this.trapGroup = game.Phaser.add.group(this.gridGroup, 'trapGrp'); - this.hexesGroup = game.Phaser.add.group(this.gridGroup, 'hexesGroup'); - this.displayHexesGroup = game.Phaser.add.group(this.gridGroup, 'displayHexesGroup'); - this.overlayHexesGroup = game.Phaser.add.group(this.gridGroup, 'overlayHexesGroup'); - this.dropGroup = game.Phaser.add.group(this.display, 'dropGrp'); - this.creatureGroup = game.Phaser.add.group(this.display, 'creaturesGrp'); - // Health indicators sit above all creature sprites so they're never occluded - this.healthIndicatorUiGroup = game.Phaser.add.group(this.display, 'healthIndicatorUiGrp'); - // Parts of traps displayed over creatures - this.trapOverGroup = game.Phaser.add.group(this.display, 'trapOverGrp'); - this.trapOverGroup.scale.set(1, 0.75); - - // Populate grid - for (let row = 0; row < numRows; row++) { - this.hexes.push([]); - for (let hex = 0, len = numCols; hex < len; hex++) { - if (hex == numCols - 1) { - if ((row % 2 == 0 && !isFirstRowFull) || (row % 2 == 1 && isFirstRowFull)) { - continue; - } - } - - this.hexes[row][hex] = new Hex(hex, row, this); - } - } - - this.selectedHex = this.hexes[0][0]; - - // If true, clicking on a unit won't shout its name. - this.onShoutCooldown = false; - - // If true, clicking a monster will instantly kill it. - this._executionMode = this.game.metaPowersState.executeMonster; - - // Events - this.game.signals.metaPowers.add(this.handleMetaPowerEvent, this); - this.game.signals.ui.add(this.handleUIEvent, this); - } - - get traps() { - return this.game.traps; - } - - hexAt(x: number, y: number): Hex | undefined { - if (y < 0 || y >= this.hexes.length) return; - const row = this.hexes[y]; - if (x < 0 || x >= row.length) return; - return row[x]; - } - - handleMetaPowerEvent(message, payload) { - if (message === 'toggleExecuteMonster') { - this._executionMode = payload; - } - } - - handleUIEvent(message, _payload) { - if (message === 'onOpenDash' || message === 'onCloseDash') { - // When the dash opens or closes, creatures can remain in a "hovered" state - // (e.g. bounce animation stuck). Reset all bounces to ensure a clean state. - this.forEachHex((hex) => { - const creature = hex.creature; - if (creature instanceof Creature) { - creature.resetBounce(); - } - }); - } - } - - isInBounds({ x, y }: Point) { - return y < this.hexes.length && y >= 0 && x < this.hexes[y].length && x >= 0; - } - - querySelf(o) { - const game = this.game; - const defaultOpt = { - fnOnConfirm: () => { - // No-op function. - }, - fnOnSelect: (creature: Creature) => { - creature.hexagons.forEach((hex) => { - hex.overlayVisualState('creature selected player' + hex.creature.team); - }); - }, - fnOnCancel: () => { - this.game.activeCreature?.queryMove(); - }, - args: {}, - confirmText: 'Confirm', - id: game.activeCreature.id, - }; - - o = { ...defaultOpt, ...o }; - - game.activeCreature.hint(o.confirmText, 'confirm'); - - this.queryHexes({ - fnOnConfirm: (hex, args) => { - args.opt.fnOnConfirm(game.activeCreature, args.opt.args, { queryOptions: o }); - }, - fnOnSelect: (hex, args) => { - args.opt.fnOnSelect(game.activeCreature, args.opt.args); - }, - fnOnCancel: (hex, args) => { - args.opt.fnOnCancel(game.activeCreature, args.opt.args); - }, - args: { - opt: o, - }, - hexes: game.activeCreature.hexagons, - hideNonTarget: true, - id: o.id, - }); - } - - /** - * Shortcut to queryChoice with specific directions. - * @param {QueryOptions} o - */ - queryDirection(o: Partial) { - o.isDirectionsQuery = true; - const defaultOpt = { - team: Team.Enemy, - id: 0, - flipped: false, - x: 0, - y: 0, - directions: [1, 1, 1, 1, 1, 1], - includeCreature: true, - stopOnCreature: true, - pierceNumber: 1, - pierceThroughBehavior: 'stop', - distance: 0, - minDistance: 0, - distanceFalloff: 0, - dashedHexesAfterCreatureStop: true, - dashedHexesDistance: 0, - dashedHexesUnderCreature: true, - sourceCreature: undefined, - isDirectionsQuery: true, - }; - - o = { ...defaultOpt, ...o }; - - o = this.getDirectionChoices(o); - this.queryChoice(o); - - return true; - } - - /** - * Get an object that contains the choices and hexesDashed for a direction query. - * @param {QueryOptions} o Options. - * @returns {QueryOptions} Altered options. - */ - getDirectionChoices(o: Partial) { - const defaultOpt = { - team: Team.Enemy, - requireCreature: true, - id: 0, - flipped: false, - x: 0, - y: 0, - hexesDashed: [], - shrunkenHexes: [], - hexesDeadZone: [], - directions: [1, 1, 1, 1, 1, 1], - includeCreature: true, - stopOnCreature: true, - pierceNumber: 1, - pierceThroughBehavior: 'stop', - distance: 0, - minDistance: 0, - distanceFalloff: 0, - dashedHexesAfterCreatureStop: true, - dashedHexesDistance: 0, - dashedHexesUnderCreature: true, - sourceCreature: undefined, - choices: [], - optTest: () => true, - ignoreCreatureTest: undefined, - fillOnlyHoveredCreature: false, - }; - - const options = { ...defaultOpt, ...o }; - - // Clean Direction - this.forEachHex((hex) => { - hex.direction = Direction.None; - }); - - options.choices = []; - - for (let i = 0, len = options.directions.length; i < len; i++) { - if (!options.directions[i]) { - continue; - } - - const direction = i as Direction; - let dir: Hex[] = []; - let fx = 0; - - if (options.sourceCreature instanceof Creature) { - const flipped = options.sourceCreature.player.flipped; - if ( - (!flipped && direction > Direction.DownRight) || - (flipped && direction < Direction.DownLeft) - ) { - fx = -1 * (options.sourceCreature.size - 1); - } - } - - dir = this.getHexLine(options.x + fx, options.y, direction, options.flipped); - - // Limit hexes based on distance - if (options.distance > 0) { - dir = dir.slice(0, options.distance + 1); - } - - // The untargetable area between the unit and the minimum distance. - let deadzone = []; - if (options.minDistance > 0) { - deadzone = dir.slice(0, options.minDistance); - deadzone = arrayUtils.filterCreature( - deadzone, - options.includeCreature, - options.stopOnCreature, - options.id, - ); - - dir = dir.slice( - // 1 greater than expected to exclude current (source creature) hex. - options.minDistance, - ); - } - - const hexesDeadZone = []; - deadzone.forEach((element) => { - hexesDeadZone.push(element); - }); - - /* If the ability has a minimum distance and units should block LOS, this - direction cannot be used if there is a unit in the deadzone. */ - if (options.stopOnCreature && deadzone.length && this.atLeastOneTarget(deadzone, options)) { - continue; - } - - let hexesDashed = []; - dir.forEach((item) => { - item.direction = options.flipped ? 5 - direction : direction; - - if (options.stopOnCreature && options.dashedHexesAfterCreatureStop) { - hexesDashed.push(item); - } - }); - - arrayUtils.filterCreature( - dir, - options.includeCreature, - options.stopOnCreature, - options.id, - options.sourceCreature, - options.pierceNumber, - options.pierceThroughBehavior as PierceThroughBehavior, - options.team, - options.ignoreCreatureTest, - ); - - if (dir.length === 0) { - continue; - } - - if (options.requireCreature && !this.atLeastOneTarget(dir, options)) { - continue; - } - - if ( - options.stopOnCreature && - options.includeCreature && - // Only straight direction. - (direction === Direction.Right || direction === Direction.Left) - ) { - if (arrayUtils.last(dir).creature instanceof Creature) { - // Add all creature hexes. - const creature = arrayUtils.last(dir).creature; - dir.pop(); - dir = arrayUtils.sortByDirection(dir.concat(creature.hexagons), direction); - } - } - - dir.forEach((item) => { - arrayUtils.removePos(hexesDashed, item); - }); - - /* For some reason hexesDashed can contain source creature hexagons. Rather - than risk changing that logic, create a new list without the source creature. */ - const hexesDashedWithoutSourceCreature = arrayUtils.filterCreature( - hexesDashed, - true, - false, - options.id, - ); - - if (hexesDashed.length && options.dashedHexesDistance) { - hexesDashed = hexesDashedWithoutSourceCreature.slice(0, options.dashedHexesDistance); - } - - let shrunkenHexes: Hex[] = []; - if (options.distanceFalloff) { - /* Shrunken hexes do not replace existing hexes, instead they modify them. - With that in mind, regular AND dashed hexes after the falloff distance - can be shrunk. */ - shrunkenHexes = [...dir, ...hexesDashedWithoutSourceCreature].slice( - options.distanceFalloff, - ); - } - - // Deadzone hexes are also part of direction, so they should be clickable - deadzone.forEach((element) => { - dir.push(element); - }); - - options.hexesDashed = [...options.hexesDashed, ...hexesDashed]; - options.shrunkenHexes = [...options.shrunkenHexes, ...shrunkenHexes]; - options.hexesDeadZone = [...options.hexesDeadZone, ...hexesDeadZone]; - options.choices.push(dir); - } - - return options; - } - - /** - * Return whether there is at least one creature in the hexes that satisfies - * various conditions, e.g. team. - * - * @param {} dir ? - * @param {Object} o - * @return {boolean} At least one valid target. - */ - atLeastOneTarget(dir, o) { - const defaultOpt = { - team: Team.Both, - optTest: function () { - return true; - }, - }; - - const options = { ...defaultOpt, ...o }; - - let validChoice = false; - - // Search each hex for a creature that matches the team argument. - for (let j = 0; j < dir.length; j++) { - const targetCreature = dir[j].creature; - - if (targetCreature instanceof Creature && targetCreature.id !== options.id) { - const sourceCreature = this.game.creatures[options.id]; - - if ( - isTeam(sourceCreature, targetCreature, options.team) && - options.optTest(targetCreature) - ) { - validChoice = true; - break; - } - } - } - - if (validChoice) { - return true; - } - - return false; - } - - /** - * fnOnSelect : Function : Function applied when clicking on one of the available hexes. - * fnOnConfirm : Function : Function applied when clicking again on the same hex. - * fnOnCancel : Function : Function applied when clicking a non reachable hex - * requireCreature : Boolean : Disable a choice if it does not contain a creature matching the team argument - * args : Object : Object given to the events function (to easily pass variable for these function) - */ - queryChoice(o) { - const game = this.game; - const defaultOpt = { - fnOnConfirm: () => { - game.activeCreature?.queryMove(); - }, - fnOnSelect: (choice) => { - // When only filling the hovered creature - if (o.fillOnlyHoveredCreature) { - choice.forEach((item, index) => { - if (item.creature instanceof Creature && item.creature === this.hoveredCreature) { - item.displayVisualState('creature selected player' + item.creature.team); - } else if (item.creature instanceof Creature) { - item.displayVisualState('adj'); - } else { - // Split the choice into two parts, before and after the empty hex - const beforeEmpty = choice.slice(0, index); - const afterEmpty = choice.slice(index + 1); - // Check conditions - if (beforeEmpty.some((hex) => hex.creature instanceof Creature)) { - item.displayVisualState('dashed'); - } else if (afterEmpty.some((hex) => hex.creature instanceof Creature)) { - item.displayVisualState('adj'); - } - } - }); - } - // Normal behavior - else { - // Reset all choices to base state so only the hovered one is emphasised. - o.choices.forEach((otherChoice) => { - otherChoice.forEach((item) => { - item.cleanDisplayVisualState('adj creature player0 player1 player2 player3'); - }); - }); - choice.forEach((item) => { - if (item.creature instanceof Creature) { - item.displayVisualState('creature selected player' + item.creature.team); - } else { - item.displayVisualState('adj'); - } - }); - } - }, - fnOnCancel: () => { - game.activeCreature?.queryMove(); - }, - fnOnHoverOutside: (() => { - // Restore all choices to base/light state when pointer leaves the valid area. - o.choices.forEach((choice) => { - choice.forEach((item) => { - item.cleanDisplayVisualState('adj creature player0 player1 player2 player3'); - }); - }); - }) as (() => void) | undefined, - team: Team.Enemy, - requireCreature: 1, - id: 0, - args: {}, - flipped: false, - choices: [], - hexesDashed: [], - hexesDeadZone: [], - shrunkenHexes: [], - isDirectionsQuery: false, - hideNonTarget: true, - dashedHexesUnderCreature: false, - fillOnlyHoveredCreature: false, - }; - - // Overwrite any default options with options passed in through `o` - o = { ...defaultOpt, ...o }; - - let hexes = []; - for (let i = 0, len = o.choices.length; i < len; i++) { - let validChoice = true; - - if (o.requireCreature) { - validChoice = false; - // Search each hex for a creature that matches the team argument - for (let j = 0; j < o.choices[i].length; j++) { - if (o.choices[i][j].creature instanceof Creature && o.choices[i][j].creature != o.id) { - const creaSource = game.creatures[o.id]; - const creaTarget = o.choices[i][j].creature; - - if (isTeam(creaSource, creaTarget, o.team)) { - validChoice = true; - } - } - } - } - - if (validChoice) { - hexes = hexes.concat(o.choices[i]); - if (!(o as any).preserveDashedHexesInChoices) { - o.choices[i].forEach((hex) => { - arrayUtils.removePos(o.hexesDashed, hex); - }); - } - } else if (o.isDirectionsQuery) { - this.forEachHex((hex) => { - if (o.choices[i][0].direction == hex.direction) { - arrayUtils.removePos(o.hexesDashed, hex); - } - }); - } - } - - o.hexesDashed = o.dashedHexesUnderCreature - ? o.hexesDashed - : o.hexesDashed.filter((hexDash) => !hexDash.creature); - - this.queryHexes({ - fnOnConfirm: (hex, args) => { - // Determine which set of hexes (choice) the hex is part of - for (let i = 0, len = args.opt.choices.length; i < len; i++) { - for (let j = 0, lenj = args.opt.choices[i].length; j < lenj; j++) { - if (hex.pos == args.opt.choices[i][j].pos) { - args.opt.args.direction = hex.direction; - args.opt.fnOnConfirm(args.opt.choices[i], args.opt.args, { queryOptions: o }); - return; - } - } - } - }, - fnOnSelect: (hex, args) => { - // Determine which set of hexes (choice) the hex is part of - for (let i = 0, len = args.opt.choices.length; i < len; i++) { - for (let j = 0, lenj = args.opt.choices[i].length; j < lenj; j++) { - if (hex.pos == args.opt.choices[i][j].pos) { - args.opt.args.direction = hex.direction; - args.opt.args.hex = hex; - args.opt.args.choiceIndex = i; - args.opt.fnOnSelect(args.opt.choices[i], args.opt.args, { queryOptions: o }); - return; - } - } - } - }, - fnOnCancel: o.fnOnCancel, - fnOnHoverOutside: o.fnOnHoverOutside, - args: { - opt: o, - }, - hexes: hexes, - hexesDashed: o.hexesDashed, - shrunkenHexes: o.shrunkenHexes, - hexesDeadZone: o.hexesDeadZone, - flipped: o.flipped, - hideNonTarget: o.hideNonTarget, - id: o.id, - fillHexOnHover: false, - fillOnlyHoveredCreature: o.fillOnlyHoveredCreature, - targeting: o.targeting !== undefined ? o.targeting : true, - callbackAfterQueryHexes: o.callbackAfterQueryHexes, - }); - } - - /** - * @param {object} o Object given to the events function (to easily pass variable for these function) - * @param {function} o.fnOnSelect Function applied when clicking on one of the available hexes. - * @param {function} o.fnOnConfirm Function applied when clicking again on the same hex. - * @param {function} o.fnOnCancel Function applied when clicking a non reachable hex. - * @param {Team} o.team The targetable team. - * @param {number} o.id Creature ID - * @param {boolean} o.replaceEmptyHexesWithDashed Replace all non targetable, empty hexes with dashed hexes. - * o.hexesDashed will override this option. - */ - queryCreature(o) { - const game = this.game; - const defaultOpt = { - fnOnConfirm: () => { - game.activeCreature?.queryMove(); - }, - fnOnSelect: (creature) => { - creature.tracePosition({ - overlayClass: 'creature selected player' + creature.team, - }); - }, - fnOnCancel: () => { - game.activeCreature?.queryMove(); - }, - optTest: () => true, - args: {}, - hexes: [], - hexesDashed: [], - hexesDeadZone: [], - flipped: false, - id: 0, - team: Team.Enemy, - replaceEmptyHexesWithDashed: false, - }; - - o = { ...defaultOpt, ...o }; - - /* Divide hexes into: - - containing valid targets - - empty (no possible target) - Hexes containing invalid targets (wrong team, o.optTest, etc) are discard. */ - const { targetHexes, emptyHexes } = o.hexes.reduce( - (acc, hex) => { - const sourceCreature = game.creatures[o.id]; - const targetCreature = hex.creature; - - const acceptTargetHex = () => { - return { - ...acc, - targetHexes: [...acc.targetHexes, hex], - }; - }; - - const acceptEmptyHex = () => { - return { - ...acc, - emptyHexes: [...acc.emptyHexes, hex], - }; - }; - - const discardHex = () => { - return acc; - }; - - if (!targetCreature) { - return acceptEmptyHex(); - } - - if (targetCreature instanceof Creature && targetCreature.id !== o.id) { - if (!o.optTest(hex.creature)) { - return discardHex(); - } - - if (isTeam(sourceCreature, targetCreature, o.team)) { - return acceptTargetHex(); - } - } - - return discardHex(); - }, - { targetHexes: [], emptyHexes: [] }, - ); - - o.hexes = targetHexes; - - if (o.replaceEmptyHexesWithDashed && !o.hexesDashed.length) { - o.hexesDashed = emptyHexes; - } - - let extended = []; - /* Add creature hexes that extend out of the range of the source hexes, so the - entire creature can be highlighted. */ - o.hexes.forEach((hex) => { - extended = extended.concat(hex.creature.hexagons); - }); - - o.hexes = extended; - - // Xray: make obstructors of all valid ability targets semi-transparent so - // the player can see every targetable unit clearly before hovering. - const abilityActiveCreature = this.game.activeCreature; - const seenTargets = new Set(); - o.hexes.forEach((hex) => { - const c = hex.creature; - if (c instanceof Creature && c !== abilityActiveCreature && !seenTargets.has(c)) { - seenTargets.add(c); - c.hexagons.forEach((h) => h.ghostOverlap(c)); - c.xray(false); // target itself must remain fully opaque - } - }); - - // Active creature (attacker) must never be xrayed — ghostOverlap for a - // target's hexes can pick it up as a same-row or adjacent-row candidate. - if (abilityActiveCreature instanceof Creature) { - abilityActiveCreature.xray(false); - } - - this.queryHexes({ - fnOnConfirm: (hex, args) => { - const { creature } = hex; - if (!creature) return; - args.opt.fnOnConfirm(creature, args.opt.args, { queryOptions: o }); - }, - fnOnSelect: (hex, args) => { - const { creature } = hex; - if (!creature) return; - args.opt.fnOnSelect(creature, args.opt.args); - }, - fnOnCancel: o.fnOnCancel, - args: { - opt: o, - }, - hexes: o.hexes, - hexesDashed: o.hexesDashed, - hexesDeadZone: o.hexesDeadZone, - flipped: o.flipped, - hideNonTarget: true, - id: o.id, - }); - } - - redoLastQuery() { - this.queryHexes(this.lastQueryOpt); - } - - /** - * Re-evaluate hover state for the hex currently under the pointer. - * Call this whenever freezedInput transitions to false so the cursor and - * visual highlights update without requiring mouse movement. - */ - refreshHoverState() { - if (this.suppressNextHoverRefresh) { - this.suppressNextHoverRefresh = false; - return; - } - if (this.game.botController?.isBotTurn()) { - return; - } - const hex = this.lastMouseHex; - if (!hex || this.game.freezedInput || this.isRefreshingHoverState) return; - this.cancelDeferredActiveHexDashedClear(); - // Replicate what onInputOver does so cursor, unit preview and xray all update. - if (hex.reachable && this.game.activeCreature) { - this.game.activeCreature.highlightCurrentHexesAsDashed(); - } - this.game.signals.hex.dispatch('over', { hex }); - this.selectedHex = hex; - this.isRefreshingHoverState = true; - try { - hex.onSelectFn(hex); - } finally { - this.isRefreshingHoverState = false; - } - } - - cancelDeferredActiveHexDashedClear() { - if (this.activeHexDashedClearTimeout) { - clearTimeout(this.activeHexDashedClearTimeout); - this.activeHexDashedClearTimeout = null; - } - } - - scheduleDeferredActiveHexDashedClear() { - this.cancelDeferredActiveHexDashedClear(); - this.activeHexDashedClearTimeout = setTimeout(() => { - this.activeHexDashedClearTimeout = null; - this.game.activeCreature?.clearDashedOverlayOnHexes(); - }, 0); - } - - /** - * fnOnSelect : Function : Function applied when clicking on one of the available hexes. - * fnOnConfirm : Function : Function applied when clicking again on the same hex. - * fnOnCancel : Function : Function applied when clicking a non reachable hex - * args : Object : Object given to the events function (to easily pass variable for these function) - * hexes : Array : Reachable hexes - * callbackAfterQueryHexes : Function : empty function to be overridden with custom logic to execute after queryHexes - */ - queryHexes(o) { - const game = this.game; - const getCreatureDisplayName = (creature: Creature) => { - const withoutPrefix = creature.name.replace(/^object[_-]/i, ''); - const spacedName = withoutPrefix.replace(/[_-]+/g, ' ').trim(); - if (!spacedName) { - return creature.name; - } - - return spacedName.charAt(0).toUpperCase() + spacedName.slice(1); - }; - // Detect whether this is a fresh query or a redo of the last query. - // redoLastQuery() passes the same lastQueryOpt reference, so reference - // equality distinguishes the two cases. - const isFreshQuery = o !== this.lastQueryOpt; - const defaultOpt = { - fnOnConfirm: () => { - game.activeCreature?.queryMove(); - }, - fnOnSelect: (hex: Hex) => { - game.activeCreature.faceHex(hex); - hex.overlayVisualState('creature selected player' + game.activeCreature.team); - }, - fnOnCancel: () => { - game.activeCreature?.queryMove(); - }, - fnOnHoverOutside: undefined as (() => void) | undefined, - callbackAfterQueryHexes: () => { - // empty function to be overridden with custom logic to execute after queryHexes - }, - args: {}, - hexes: [], - hexesDashed: [], - shrunkenHexes: [], - hexesDeadZone: [], - size: 1, - id: 0, - flipped: false, - hideNonTarget: false, - ownCreatureHexShade: false, - targeting: true, - fillHexOnHover: true, - fillOnlyHoveredCreature: false, - }; - - o = { ...defaultOpt, ...o }; - - this.lastClickedHex = undefined; - - // Save the last Query - this.lastQueryOpt = { ...o }; - - const clearPreviewOverlay = (preview, secondary = false) => { - if (!preview) { - return; - } - - if (secondary) { - if (this._flickerTweenSecondary) { - this._flickerTweenSecondary.stop(true); - this._flickerTweenSecondary = undefined; - } - } else { - if (this._flickerTween) { - this._flickerTween.stop(true); - this._flickerTween = undefined; - } - } - - preview.alpha = 0; - - if (preview._previewPos === undefined) { - return; - } - - for (let i = 0; i < preview._previewSize; i++) { - const prevHex = this.hexes[preview._previewPos.y]?.[preview._previewPos.x - i]; - if (!prevHex || prevHex.creature === game.activeCreature) { - continue; - } - - this.cleanHex(prevHex); - this.restoreReachableHexVisual(prevHex); - } - - preview._previewPos = undefined; - }; - - // Reset keyboard cursor to the active creature's front hexagon so that - // arrow-key navigation always starts from a consistent, tactically useful - // position rather than wherever the cursor happened to be last. - // Only do this for fresh queries; redoLastQuery() must not move the cursor - // mid-flight (it is called from clearHexViewAlterations inside selectHex*). - if (isFreshQuery) { - const activeCreature = game.activeCreature; - if (activeCreature?.hexagons?.length) { - const frontHex = activeCreature.player.flipped - ? activeCreature.hexagons[activeCreature.size - 1] - : activeCreature.hexagons[0]; - if (frontHex) { - this.selectedHex = frontHex; - } - } - } - - this.updateDisplay(); - // Block all hexes - this.forEachHex((hex) => { - hex.unsetReachable(); - - if (o.hideNonTarget) { - hex.setNotTarget(); - } else { - hex.unsetNotTarget(); - } - - if (o.hexesDashed.indexOf(hex) !== -1) { - hex.displayVisualState('dashed'); - } else { - hex.cleanDisplayVisualState('dashed'); - } - - if (o.hexesDeadZone.indexOf(hex) !== -1) { - hex.displayVisualState('deadzone'); - } else { - hex.cleanDisplayVisualState('deadzone'); - } - - if (o.shrunkenHexes.includes(hex)) { - hex.displayVisualState('shrunken'); - } else { - hex.cleanDisplayVisualState('shrunken'); - } - }); - - // Cleanup - clearPreviewOverlay(this.materialize_overlay); - clearPreviewOverlay(this.secondary_overlay, true); - - if (this._flickerTween) { - this._flickerTween.stop(true); - } - if (this._flickerTweenSecondary) { - this._flickerTweenSecondary.stop(true); - } - - if (!o.ownCreatureHexShade) { - if (o.id instanceof Array) { - o.id.forEach((id) => { - game.creatures[id].hexagons.forEach((hex) => { - hex.overlayVisualState('ownCreatureHexShade'); - }); - }); - } else { - if (o.id != 0) { - game.creatures[o.id].hexagons.forEach((hex) => { - hex.overlayVisualState('ownCreatureHexShade'); - }); - } - } - } - - // Function to find the path of a given hex - const findDirectionalPathOfHex = (hex) => { - let startIndex = o.hexes.indexOf(hex); - let endIndex = startIndex; - // Find the start of the path - while (startIndex > 0 && o.hexes[startIndex - 1].direction === hex.direction) { - startIndex--; - } - // Find the end of the path - while (endIndex < o.hexes.length - 1 && o.hexes[endIndex + 1].direction === hex.direction) { - endIndex++; - } - // Extract the path - return o.hexes.slice(startIndex, endIndex + 1); - }; - - // Function to determine if an empty hex is before or after the first creature in path - const emptyHexBeforeCreature = (hex) => { - const path = findDirectionalPathOfHex(hex); - const index = path.findIndex((h) => h === hex); - const beforeEmpty = path.slice(0, index); - const afterEmpty = path.slice(index + 1); - // Check conditions - if (beforeEmpty.some((hex) => hex.creature instanceof Creature)) { - return false; - } else if (afterEmpty.some((hex) => hex.creature instanceof Creature)) { - return true; - } - }; - // Set reachable the given hexes - o.hexes.forEach((hex) => { - hex.setReachable(); - if (o.hideNonTarget) { - hex.unsetNotTarget(); - } - if (o.targeting) { - if (hex.creature instanceof Creature) { - if (hex.creature.id != this.game.activeCreature.id) { - hex.overlayVisualState('reachable h_player' + hex.creature.team); - // Add dashed hexagons under targets for ranged abilities with team color - hex.displayVisualState('dashed player' + hex.creature.team); - // Ensure dashed hexagons are on top for better visibility - hex.grid.displayHexesGroup.bringToTop(hex.display); - } - } else { - if (o.fillOnlyHoveredCreature && !emptyHexBeforeCreature(hex)) { - hex.displayVisualState('dashed'); - } else { - hex.overlayVisualState('reachable h_player' + this.game.activeCreature.team); - } - } - } - }); - - if (o.callbackAfterQueryHexes) { - o.callbackAfterQueryHexes(); - } - - const onCreatureHover = (creature: Creature, queueEffect, hex: Hex) => { - this.hoveredCreature = creature; - if (creature.isDarkPriest()) { - if (creature === game.activeCreature) { - if (creature.hasCreaturePlayerGotPlasma()) { - creature.displayPlasmaShield(); - } - } else { - creature.displayHealthStats(); - } - } - creature.hexagons.forEach((h) => { - // Flashing outline - h.overlayVisualState('hover h_player' + creature.team); - // Keep the dashed hexagons visible under targets with team color - if (h.displayClasses.indexOf('dashed') === -1) { - h.displayVisualState('dashed player' + creature.team); - } - // Make sure the display hexagon is brought to the top for better visibility - h.grid.displayHexesGroup.bringToTop(h.display); - }); - if (creature !== game.activeCreature) { - if (!hex.reachable) { - $j('canvas').css('cursor', 'n-resize'); - } else { - // Filled hex with color - hex.displayVisualState('creature player' + hex.creature.team); - } - } else if (game.activeCreature.noActionPossible) { - $j('canvas').css('cursor', 'progress'); - } - queueEffect(creature.id); - }; - - // Once a reachable hex is confirmed, ignore any late hover callbacks tied to - // this query instance until queryHexes() installs a fresh set of handlers. - let isQueryLockedAfterConfirm = false; - - // ONCLICK - const onConfirmFn = (hex: Hex) => { - if (isQueryLockedAfterConfirm) { - return; - } - - // Debugger - const y = hex.y; - let x = hex.x; - - // Clear display and overlay - $j('canvas').css('cursor', 'pointer'); - - if (this._executionMode && hex.creature instanceof Creature) { - hex.creature.die({ player: game.players[0] }); - return; - } - - // Not reachable hex - if (!hex.reachable) { - this.lastClickedHex = undefined; - - if (hex.creature instanceof Creature) { - // If creature - onCreatureHover( - hex.creature, - game.activeCreature !== hex.creature - ? game.UI.bouncexrayQueue.bind(game.UI) - : game.UI.xrayQueue.bind(game.UI), - hex, - ); - - // Shout unit's name, show tooltip with unit's name and start a cooldown. - if (!this.onShoutCooldown) { - this.onShoutCooldown = true; - const shoutName = hex.creature.name; - const displayName = getCreatureDisplayName(hex.creature); - game.soundsys.playShout(shoutName); - hex.creature.hint(displayName, 'creature_name'); - - setTimeout(() => { - this.onShoutCooldown = false; - }, 1200); - } - } else { - // If nothing - if (game.activeCreature.noActionPossible) { - game.skipTurn(); - } else { - o.fnOnCancel(hex, o.args); // ON CANCEL - } - } - } else { - // Reachable hex - // Offset Pos - const offset = o.flipped ? o.size - 1 : 0; - const mult = o.flipped ? 1 : -1; // For flipped player - - // If only filling hovered creatures hexes, cancel if player clicks on empty hex after first creature - if (o.fillOnlyHoveredCreature && !emptyHexBeforeCreature(hex)) { - if (!(hex.creature instanceof Creature)) { - o.fnOnCancel(hex, o.args); // ON CANCEL - return; - } - } - - // If hex is reachable & creature, reset target bounce. - // Preserve active creature bounce (bot can confirm without a hover-off cycle). - if (hex.creature instanceof Creature && hex.creature !== game.activeCreature) { - hex.creature.resetBounce(); - } - - for (let i = 0, size = o.size; i < size; i++) { - // Try next hexagons to see if they fits - if (x + offset - i * mult >= this.hexes[y].length || x + offset - i * mult < 0) { - continue; - } - - if (this.hexes[y][x + offset - i * mult].isWalkable(o.size, o.id)) { - x += offset - i * mult; - break; - } - } - - hex = this.hexes[y][x]; // New coords - game.activeCreature.faceHex(hex); - isQueryLockedAfterConfirm = true; - this.lastMouseHex = undefined; - - if (game.activeCreature === hex.creature && hex.creature.noActionPossible) { - game.UI.hoveringNoActionCreature = false; - // Clear noActionPossible before fading so any cleanup queryMove(null) - // during turn-end deactivation does not re-trigger querySelf and spawn - // a second Skip turn marker on top of the fading one. - hex.creature.noActionPossible = false; - hex.creature.fadeOutNoActionHints(); - } - - if (hex !== this.lastClickedHex) { - this.lastClickedHex = hex; - } - - // Keep primary materialize preview alive for summon handoff: - // Creature.summon() uses fadeOutTempCreature() for a smooth transition. - clearPreviewOverlay(this.secondary_overlay, true); - - o.fnOnConfirm(hex, o.args, { queryOptions: o }); - } - }; - - const onHoverOffFn = (hex: Hex) => { - if (isQueryLockedAfterConfirm) { - return; - } - - const { creature } = hex; - - if (creature instanceof Creature) { - this.hoveredCreature = null; - creature.resetBounce(); - if (creature === game.activeCreature) { - creature.startBounce(); - } - // toggle hover off event - if (creature.isDarkPriest()) { - // the plasma would have been displayed so now display the health again - creature.updateHealth(); - } - - if (game.activeCreature === hex.creature && hex.creature.noActionPossible) { - game.UI.hoveringNoActionCreature = false; - game.UI.btnSkipTurn.$button.removeClass('hidden'); - // Stop bouncing no-action hint when cursor leaves, but keep it visible. - creature.stopNoActionHintBounce(); - } - } - game.UI.chat.hideExpanded(); - $j('canvas').css('cursor', 'default'); - }; - - // ONMOUSEOVER - const onSelectFn = (hex: Hex) => { - if (isQueryLockedAfterConfirm) { - return; - } - - let { x } = hex; - const { y } = hex; - - // Xray - this.xray(hex); - - // Clear display and overlay - game.UI.xrayQueue(-1); - $j('canvas').css('cursor', 'pointer'); - $j('body').css('cursor', 'default'); - - if (hex.creature instanceof Creature) { - if (!game.botController?.isBotTurn()) { - game.UI.chat.showExpanded(hex.creature); - } - // Keep reference - onCreatureHover(hex.creature, game.UI.xrayQueue.bind(game.UI), hex); - - hex.creature.startBounce(); - - if (game.activeCreature === hex.creature && hex.creature.noActionPossible) { - game.UI.hoveringNoActionCreature = true; - game.UI.btnSkipTurn.$button.removeClass('bounce'); - game.UI.btnSkipTurn.$button.removeClass('hidden'); - // Show "Skip Turn" icon - hex.creature.hint('Skip turn', 'no_action'); - } - game.UI.chat.isOverCreature = true; - } - - if (hex.reachable) { - if (o.fillOnlyHoveredCreature && !(hex.creature instanceof Creature)) { - if (!emptyHexBeforeCreature(hex)) { - $j('canvas').css('cursor', 'not-allowed'); - hex.overlayVisualState('hover'); - } else { - const index = o.hexes.indexOf(hex); - const afterEmpty = o.hexes.slice(index + 1); - // Find the next creature after the empty hex - const nextCreature = afterEmpty.find( - (hex) => hex.creature instanceof Creature, - )?.creature; - if (nextCreature) { - // Apply the desired behavior to all hexes the next creature occupies - nextCreature.hexagons.forEach((creatureHex) => { - creatureHex.displayVisualState('creature selected player' + nextCreature.team); - }); - } - } - } - - if (o.fillHexOnHover) { - this.cleanHex(hex); - hex.displayVisualState('creature player' + this.game.activeCreature.team); - } - - // Offset Pos - const offset = o.flipped ? o.size - 1 : 0; - const mult = o.flipped ? 1 : -1; // For flipped player - - for (let i = 0, size = o.size; i < size; i++) { - // Try next hexagons to see if they fit - if (x + offset - i * mult >= this.hexes[y].length || x + offset - i * mult < 0) { - continue; - } - if (this.hexes[y][x + offset - i * mult].isWalkable(o.size, o.id)) { - x += offset - i * mult; - break; - } - } - - hex = this.hexes[y][x]; // New coords - o.fnOnSelect(hex, o.args); - } else if (!hex.reachable) { - // Clean the last preview position's hex overlays and reset tracking so - // that re-entering the spawn range doesn't leave a ghost outline behind. - clearPreviewOverlay(this.materialize_overlay); - clearPreviewOverlay(this.secondary_overlay, true); - hex.overlayVisualState('hover'); - - $j('canvas').css( - 'cursor', - game.activeCreature.noActionPossible ? 'progress' : 'not-allowed', - ); - - if (o.fnOnHoverOutside) { - o.fnOnHoverOutside(); - } - - // If creature and inactive - if (hex.creature instanceof Creature && hex.creature !== game.activeCreature) { - $j('canvas').css('cursor', 's-resize'); - } - } - }; - - // ONRIGHTCLICK - const onRightClickFn = (hex: Hex) => { - if (isQueryLockedAfterConfirm) { - return; - } - - if (hex.creature instanceof Creature) { - if (hex.creature.name.startsWith('object_')) { - game.UI.showCreature(game.activeCreature.type, game.activeCreature.player.id, 'emptyHex'); - } else { - game.UI.showCreature(hex.creature.type, hex.creature.player.id, 'grid'); - } - } else { - if (game.activeCreature.isDarkPriest()) { - if (game.UI.selectedCreatureObj) { - game.UI.toggleDash(false); - } else { - game.UI.showCreature( - game.activeCreature.type, - game.activeCreature.player.id, - 'emptyHex', - ); - } - } else { - game.UI.showCreature(game.activeCreature.type, game.activeCreature.player.id, 'emptyHex'); - } - } - }; - - this.forEachHex((hex) => { - hex.onSelectFn = onSelectFn; - hex.onHoverOffFn = onHoverOffFn; - hex.onConfirmFn = onConfirmFn; - hex.onRightClickFn = onRightClickFn; - }); - - // All hex handlers are now up to date; refresh only for fresh queries. - // redoLastQuery() is called from hover preview flows and must not retrigger - // onSelectFn here, or it can recurse into queryHexes and freeze the game. - if (isFreshQuery) { - this.refreshHoverState(); - } - - if (this.game.botController?.shouldAutoResolveQuery()) { - this.game.botController.resolveQuery(o, { - onSelect: onSelectFn, - onConfirm: onConfirmFn, - }); - } - } - - /** - * @param {Hex} hex - Hexagon to emphasize. - * - * If hex contain creature call ghostOverlap for each creature hexes - */ - xray(hex: Hex) { - if (this.game.animations?.xraySuppressed) { - return; - } - this.lastXrayHex = hex; - this.game.creatures.forEach((creature) => { - if (creature instanceof Creature) { - creature.xray(false); - } - }); - - const { activeCreature } = this.game; - if (!(activeCreature instanceof Creature)) { - return; - } - - const noAbilitySelected = this.game.UI?.selectedAbility === -1; - const hoveredCreature = - hex.creature instanceof Creature ? (hex.creature as Creature) : undefined; - const hoveredTrapSprites = this.game.traps - .filter((trap) => trap.x === hex.x && trap.y === hex.y) - .flatMap((trap) => trap.getVisualSprites()) - .filter((sprite) => sprite.exists && typeof sprite.getBounds === 'function'); - const hoveredTrap = - hoveredTrapSprites.length > 0 || - this.game.traps.some((trap) => trap.x === hex.x && trap.y === hex.y); - const hoveredDropSprites = (this.game.drops ?? []) - .filter((drop) => drop.x === hex.x && drop.y === hex.y && !drop.pickedUp) - .map((drop) => drop.display) - .filter((sprite) => sprite.exists && typeof sprite.getBounds === 'function'); - const hoveredDrop = hoveredDropSprites.length > 0 || Boolean(hex.drop); - const hoveredRevealSprites = [...hoveredTrapSprites, ...hoveredDropSprites]; - const hoveredRevealCreature = hoveredCreature instanceof Creature ? hoveredCreature : undefined; - const hoveredNonActiveCreature = - hoveredCreature && hoveredCreature !== activeCreature ? hoveredCreature : undefined; - - // Exception 1/2: reveal hovered non-active target when no ability is selected. - // Skip when a trap/drop is also on the hex — Exception 3 handles that case - // and must also xray the creature standing on the trap. - if (hoveredNonActiveCreature && noAbilitySelected && !hoveredTrap && !hoveredDrop) { - hoveredNonActiveCreature.hexagons.forEach((hoveredHex) => - hoveredHex.ghostOverlap(hoveredNonActiveCreature), - ); - hoveredNonActiveCreature.xray(false); - return; - } - - // Exception 3: reveal hovered traps and drops regardless of movement reachability. - if (hoveredTrap || hoveredDrop) { - if (hoveredRevealCreature) { - hoveredRevealCreature.hexagons.forEach((hoveredHex) => - hoveredHex.ghostOverlap(hoveredRevealCreature), - ); - hoveredRevealCreature.xray(false); - } - - if (hoveredRevealSprites.length === 0) { - hex.ghostOverlap(); - return; - } - - const revealReferences = hoveredRevealSprites.map((sprite) => { - return { - sprite, - grp: this.creatureGroup, - } as unknown as Creature; - }); - if (hoveredRevealCreature) { - revealReferences.push(hoveredRevealCreature); - } - - this.game.creatures.forEach((candidate) => { - if (!(candidate instanceof Creature)) { - return; - } - if (candidate === hoveredRevealCreature || candidate === activeCreature) { - return; - } - if (!candidate.sprite || typeof candidate.sprite.getBounds !== 'function') { - return; - } - - // Always xray a creature sitting directly on the hovered trap/drop hex - // (its sprite bounds may not intersect the trap sprite, e.g. tall Abolished - // standing on a small bonfire flame). - const isOnTrapHex = - candidate !== activeCreature && - candidate.hexagons.some((h) => h.x === hex.x && h.y === hex.y); - - if (!isOnTrapHex) { - const candidateBounds = candidate.sprite.getBounds(); - const overlapsReveal = hoveredRevealSprites.some((sprite) => { - const revealBounds = sprite.getBounds(); - return !( - candidateBounds.right <= revealBounds.left || - candidateBounds.left >= revealBounds.right || - candidateBounds.bottom <= revealBounds.top || - candidateBounds.top >= revealBounds.bottom - ); - }); - if (!overlapsReveal) { - return; - } - } - - candidate.xray( - true, - revealReferences.length === 1 ? revealReferences[0] : revealReferences, - ); - }); - return; - } - - // Default: keep active creature visible through obstructions. - activeCreature.hexagons.forEach((activeHex) => activeHex.ghostOverlap(activeCreature)); - activeCreature.xray(false); - } - - /** - * Gets a line of hexes given a start point and a direction The result is an array - * of hexes, starting from the start point's hex, and extending out in a straight line. - * If the coordinate is erroneous, returns an empty array. - * - * @param {number} x - Coordinate of start hex. - * @param {number} y - Coordinate of start hex. - * @param {Direction} dir - Direction of the line. - * @param {boolean} flipped - Flip the direction. - * @returns {Hex[]} Hexes in the line. - */ - getHexLine(x: number, y: number, dir: Direction, flipped: boolean): Hex[] { - switch (dir) { - case Direction.UpRight: - return this.getHexMap(x, y - 8, 0, flipped, matrices.diagonalup).reverse(); - case Direction.Right: - return this.getHexMap(x, y, 0, flipped, matrices.straitrow); - case Direction.DownRight: - return this.getHexMap(x, y, 0, flipped, matrices.diagonaldown); - case Direction.DownLeft: - return this.getHexMap(x, y, -4, flipped, matrices.diagonalup); - case Direction.Left: - return this.getHexMap(x, y, 0, !flipped, matrices.straitrow); - case Direction.UpLeft: - return this.getHexMap(x, y - 8, -4, flipped, matrices.diagonaldown).reverse(); - default: - return []; - } - } - - cleanHex(hex: Hex) { - const activeCreature = this.game.activeCreature; - const isActiveCreatureHex = - activeCreature && hex.creature instanceof Creature && hex.creature.id === activeCreature.id; - - if (isActiveCreatureHex) { - // Preserve the active creature's persistent display/overlay classes so any - // hover preview or path cleanup cannot restart the glow state. - hex.cleanDisplayVisualState('adj hover dashed shrunken deadzone hidden'); - hex.cleanOverlayVisualState( - 'reachable weakDmg moveto selected hover ownCreatureHexShade h_player0 h_player1 h_player2 h_player3', - ); - return; - } - - hex.cleanDisplayVisualState(); - hex.cleanOverlayVisualState(); - } - - restoreReachableHexVisual(hex: Hex) { - if (!hex.reachable || !this.game.activeCreature) { - return; - } - - if (this.lastQueryOpt?.targeting) { - hex.overlayVisualState('reachable h_player' + this.game.activeCreature.team); - } - } - - /** - * Clear transient hover visuals for a single hex without rebuilding the whole - * query state. This is used during mouse transitions between adjacent hexes. - */ - clearTransientHexHoverVisual(hex: Hex) { - if (!this.lastQueryOpt) { - return; - } - - const activeCreature = this.game.activeCreature; - const isActiveCreatureHex = - activeCreature && hex.creature instanceof Creature && hex.creature.id === activeCreature.id; - - if (isActiveCreatureHex) { - // Clean only transient display state so the creature's persistent `creature` - // and `playerN` classes stay intact. That avoids reintroducing outline hexes - // while still removing hover-added dashed/path state. - hex.cleanDisplayVisualState('adj hover dashed shrunken deadzone hidden'); - hex.cleanOverlayVisualState('hover h_player0 h_player1 h_player2 h_player3'); - return; - } - - this.cleanHex(hex); - this.restoreReachableHexVisual(hex); - - // Restore the base query display class after transient hover cleanup so - // sideways cursor movement does not leave a lighter/transparent-looking hex. - const queryOpt = this.lastQueryOpt as any; - if (hex.creature instanceof Creature) { - return; - } - - if (Array.isArray(queryOpt.hexesDashed) && queryOpt.hexesDashed.indexOf(hex) !== -1) { - hex.displayVisualState('dashed'); - hex.grid.displayHexesGroup.bringToTop(hex.display); - return; - } - - if ( - queryOpt.restoreAdjOnTransientCleanup && - Array.isArray(queryOpt.hexes) && - queryOpt.hexes.indexOf(hex) !== -1 - ) { - hex.displayVisualState('adj'); - } - } - - clearTransientCreatureHoverVisual(creature: Creature) { - if (!this.lastQueryOpt) { - return; - } - - creature.hexagons.forEach((hex) => { - this.clearTransientHexHoverVisual(hex); - }); - } - - /** - * Update overlay hexes with creature positions - */ - updateDisplay() { - const activeCreature = this.game.activeCreature; - this.allhexes.forEach((hex) => { - const isActiveHex = - activeCreature && hex.creature instanceof Creature && hex.creature.id === activeCreature.id; - if (isActiveHex) { - // Preserve 'active creature playerN' to keep the glowInterval phase - // stable; only strip transient hover/query classes. - hex.cleanDisplayVisualState('adj hover dashed shrunken deadzone hidden'); - hex.cleanOverlayVisualState( - 'hover selected reachable weakDmg moveto ownCreatureHexShade h_player0 h_player1 h_player2 h_player3', - ); - } else { - hex.cleanDisplayVisualState(); - hex.cleanOverlayVisualState(); - } - }); - - this.hexes.forEach((hex) => { - hex.forEach((item) => { - if (item.creature instanceof Creature) { - if (item.creature.id == activeCreature.id) { - // Only add if not already present to avoid stacking duplicates - // and to avoid resetting overlay.alpha via updateStyle(). - if (!item.overlayClasses.includes('active')) { - item.overlayVisualState(`active creature player${item.creature.team}`); - } - } - } - }); - }); - } - - /** - * Test if hex exists - * @param {{x:number, y:number}} position - Coordinates to test - */ - hexExists({ x, y }: { x: number; y: number }): boolean { - if (y >= 0 && y < this.hexes.length) { - if (x >= 0 && x < this.hexes[y].length) { - return true; - } - } - - return false; - } - - /** - * Test if hex exists inside array of hexes - * @param {Hex} hex - Hex to look for - * @param {Hex[]} hexArray - Array of hexes to look for hex in - */ - isHexIn(hex, hexArray) { - for (let i = 0, len = hexArray.length; i < len; i++) { - if (hexArray[i].x == hex.x && hexArray[i].y == hex.y) { - return true; - } - } - - return false; - } - - /** - * @param {number} x - Integer: Start position - * @param {number} y - Integer: Start position - * @param {number} distance - Integer: Distance from the start position - * @param {number} size - Integer: Creature size - * @param {number} id - Integer: Creature ID - * @returns {Hex[]} Set of the reachable hexes - */ - getMovementRange(x, y, distance, size, id) { - // Populate distance (hex.g) in hexes by asking an impossible - // destination to test all hexagons - this.cleanReachable(); // If not pathfinding will bug - this.cleanPathAttr(true); // Erase all pathfinding data - search(this.hexes[y][x], new Hex(-2, -2, null, this.game), size, id, this.game.grid); - - // Gather all the reachable hexes - const hexes: Hex[] = []; - this.forEachHex((hex) => { - // If not Too far or Impossible to reach - if (hex.g <= distance && hex.g != 0) { - hexes.push(this.hexes[hex.y][hex.x]); - } - }); - - return arrayUtils.extendToLeft(hexes, size, this.game.grid); - } - - /** - * @param {number} x - Integer : Start position - * @param {number} y - Integer : Start position - * @param {number} distance - Integer : Distance from the start position - * @param {number} size - Integer : Creature size - * @param {number} id - Integer : Creature ID - * @returns {Hex[]} Set of the reachable hexes - */ - getFlyingRange(x, y, distance, size, id) { - // Gather all the reachable hexes - let hexes = this.hexes[y][x].adjacentHex(distance); - - hexes = hexes.filter((hex) => hex.isWalkable(size, id, true)); - - return arrayUtils.extendToLeft(hexes, size, this.game.grid); - } - - /** - * @param {number} originx - Integer : Position of the array on the grid - * @param {number} originy - Integer : Position of the array on the grid - * @param {number} offsetx - Integer : offset flipped for flipped players - * @param {boolean} flipped - If player is flipped or not - * @param {number[]} array - 2-dimensions Array containing 0 or 1 (boolean) - * @returns {Hex[]} Set of corresponding hexes - */ - getHexMap( - originx: number, - originy: number, - offsetx: number, - flipped: boolean, - array: AugmentedMatrix, - ): Hex[] { - // Heavy logic in here - const hexes: Hex[] = []; - - array = array.slice(0) as AugmentedMatrix; // Copy to not modify original - originx += flipped ? 1 - array[0].length - offsetx : -1 + offsetx; - - for (let y = 0, len = array.length; y < len; y++) { - array[y] = array[y].slice(0); // Copy row - - // Translating to flipped pattern - if (flipped && y % 2 != 0) { - // Odd rows - array[y].push(0); - } - - // Translating even to odd row pattern - array[y].unshift(0); - if (originy % 2 != 0 && y % 2 != 0) { - // Even rows - if (flipped) { - array[y].pop(); // Remove last element as the array will be parse backward - } else { - array[y].splice(0, 1); // Remove first element - } - } - - // Gathering hexes - for (let x = 0; x < array[y].length; x++) { - if (array[y][x]) { - const xfinal = flipped ? array[y].length - 1 - x : x; // Parse the array backward for flipped player - if (this.hexExists({ y: originy + y, x: originx + xfinal })) { - hexes.push(this.hexes[originy + y][originx + xfinal]); - } - } - } - } - - return hexes; - } - - showGrid(val) { - this.forEachHex((hex) => { - if (hex.creature) { - hex.creature.xray(val); - } - - if (hex.drop) { - return; - } - - if (val) { - hex.displayVisualState('showGrid'); - } else { - hex.cleanDisplayVisualState('showGrid'); - } - }); - } - - showMovementRange(creature) { - const hexes = this.findCreatureMovementHexes(creature); - - // Block all hexes - this.forEachHex((hex) => { - hex.unsetReachable(); - }); - - // Set reachable the given hexes - hexes.forEach((hex) => { - hex.setReachable(); - // Show smaller outlined hexagons for movement range visualization - hex.displayVisualState('dashed shrunken'); - }); - } - - showCurrentCreatureMovementInOverlay(creature) { - if (!creature) { - return; - } - //lastQueryOpt is same thing as used in redoQuery - this.lastQueryOpt?.hexes?.forEach((hex) => { - hex.overlayVisualState('reachable h_player' + creature.team); - }); - } - - findCreatureMovementHexes(creature) { - if (creature.movementType() === 'flying') { - return this.getFlyingRange( - creature.x, - creature.y, - creature.stats.movement, - creature.size, - creature.id, - ); - } else { - return this.getMovementRange( - creature.x, - creature.y, - creature.stats.movement, - creature.size, - creature.id, - ); - } - } - - selectHexUp() { - if (!this.hexExists({ y: this.selectedHex.y - 1, x: this.selectedHex.x })) { - return; - } - - if (this.selectedHex) { - this.clearHexViewAlterations(); - this.selectedHex.onHoverOffFn(this.selectedHex); - } - - const hex = this.hexes[this.selectedHex.y - 1][this.selectedHex.x]; - this.selectedHex = hex; - hex.onSelectFn(hex); - } - - selectHexDown() { - if (!this.hexExists({ y: this.selectedHex.y + 1, x: this.selectedHex.x })) { - return; - } - - if (this.selectedHex) { - this.clearHexViewAlterations(); - this.selectedHex.onHoverOffFn(this.selectedHex); - } - - const hex = this.hexes[this.selectedHex.y + 1][this.selectedHex.x]; - this.selectedHex = hex; - hex.onSelectFn(hex); - } - - selectHexLeft() { - if (!this.hexExists({ y: this.selectedHex.y, x: this.selectedHex.x - 1 })) { - return; - } - - if (this.selectedHex) { - this.clearHexViewAlterations(); - this.selectedHex.onHoverOffFn(this.selectedHex); - } - - const hex = this.hexes[this.selectedHex.y][this.selectedHex.x - 1]; - this.selectedHex = hex; - hex.onSelectFn(hex); - } - - selectHexRight() { - if (!this.hexExists({ y: this.selectedHex.y, x: this.selectedHex.x + 1 })) { - return; - } - - if (this.selectedHex) { - this.clearHexViewAlterations(); - this.selectedHex.onHoverOffFn(this.selectedHex); - } - - const hex = this.hexes[this.selectedHex.y][this.selectedHex.x + 1]; - this.selectedHex = hex; - hex.onSelectFn(hex); - } - - confirmHex() { - if (this.game.freezedInput) { - return; - } - - this.selectedHex.onConfirmFn(this.selectedHex); - } - - /** - * Reset the visual state for hexes that might have been hovered, dashed, etc. - * Note: I'm not entirely sure what this code is doing. - */ - clearHexViewAlterations() { - this.cancelDeferredActiveHexDashedClear(); - - if (!this.selectedHex) { - return; - } - - this.redoLastQuery(); - // Clear Xray. - this.xray(new Hex(-1, -1, null, this.game)); - // Clear Xray Queue. - this.game.UI.xrayQueue(-1); - } - - private _rowDepthBaseIndex(y: number) { - // Leave room within each row for shared layer bands instead of forcing - // every renderable to compete in a single linear ordering. - return y * ROW_DEPTH_STRIDE; - } - - getDepthAtBand(y: number, band: DepthBand, slot = 0) { - return this._rowDepthBaseIndex(y) + DEPTH_BAND[band] + slot; - } - - assignSpriteDepthBand(sprite: Phaser.Sprite | undefined, y: number, band: DepthBand, slot = 0) { - if (!sprite) { - return; - } - - sprite.z = this.getDepthAtBand(y, band, slot); - } - - orderCreatureZ() { - const creatures = this.game.creatures; - const traps = this.game.traps; - const drops = this.game.drops; - - for (let y = 0, leny = this.hexes.length; y < leny; y++) { - let groundTrapIndex = 0; - let underEffectIndex = 0; - let unitIndex = 0; - let dropIndex = 0; - let overEffectIndex = 0; - let volumetricTrapIndex = 0; - - for (let i = 0, len = creatures.length; i < len; i++) { - if (creatures[i] && creatures[i].y == y) { - creatures[i].grp.z = this.getDepthAtBand(y, 'UNITS', unitIndex++); - } - } - - for (let i = 0, len = traps.length; i < len; i++) { - const trap = traps[i]; - if (!trap || trap.y != y) { - continue; - } - - const occupyingCreature = creatures.find((candidate) => { - if (!(candidate instanceof Creature)) { - return false; - } - return candidate.hexagons?.some( - (hexagon) => hexagon.x === trap.x && hexagon.y === trap.y, - ); - }) as Creature | undefined; - const occupiedByOwnerCreature = - occupyingCreature instanceof Creature && occupyingCreature === trap.ownerCreature; - const shouldRenderOverUnits = Boolean( - trap.typeOver || (trap.type === 'bonfire-spring' && occupiedByOwnerCreature), - ); - if (typeof trap.syncTypeOverVisual === 'function') { - trap.syncTypeOverVisual(shouldRenderOverUnits); - } else if (typeof trap.setTypeOver === 'function') { - trap.setTypeOver(shouldRenderOverUnits, false); - } - - const visualSprites = - typeof trap.getVisualSprites === 'function' ? trap.getVisualSprites() : []; - for (let j = 0, visualLen = visualSprites.length; j < visualLen; j++) { - const sprite = visualSprites[j]; - if (!sprite) { - continue; - } - const isVolumetricParent = - sprite.parent === this.trapOverGroup || sprite.parent === this.creatureGroup; - const isCreatureLayerVolumetric = sprite.parent === this.creatureGroup; - if (isCreatureLayerVolumetric) { - const zReferenceCreature = - (occupyingCreature as Creature | undefined) ?? - ((trap.typeOver && trap.ownerCreature instanceof Creature && trap.ownerCreature) || - undefined); - if (zReferenceCreature?.grp && typeof zReferenceCreature.grp.z === 'number') { - // Keep feet-volumetric tightly coupled to the occupied creature instead of - // jumping to a global volumetric slot that can overlap unrelated units. - sprite.z = zReferenceCreature.grp.z + (0.5 + volumetricTrapIndex++ * 0.01); - } else { - sprite.z = this.getDepthAtBand(y, 'TRAP_VOLUMETRIC', volumetricTrapIndex++); - } - continue; - } - - if (sprite === trap.display) { - const band = isVolumetricParent ? 'TRAP_VOLUMETRIC' : 'TRAP_GROUND'; - const slot = band === 'TRAP_VOLUMETRIC' ? volumetricTrapIndex++ : groundTrapIndex++; - this.assignSpriteDepthBand(sprite, y, band, slot); - continue; - } - - const band = isVolumetricParent ? 'TRAP_VOLUMETRIC' : 'EFFECT_UNDER_UNITS'; - const slot = band === 'TRAP_VOLUMETRIC' ? volumetricTrapIndex++ : underEffectIndex++; - this.assignSpriteDepthBand(sprite, y, band, slot); - } - - if (trap.displayOver) { - this.assignSpriteDepthBand(trap.displayOver, y, 'TRAP_VOLUMETRIC', volumetricTrapIndex++); - } - } - - for (let i = 0, len = drops.length; i < len; i++) { - if (drops[i] && drops[i].y == y && drops[i].display) { - this.assignSpriteDepthBand(drops[i].display, y, 'DROPS', dropIndex++); - } - } - - if (this.materialize_overlay && this.materialize_overlay.posy == y) { - this.assignSpriteDepthBand( - this.materialize_overlay, - y, - 'EFFECT_OVER_UNITS', - overEffectIndex++, - ); - } - - if (this.secondary_overlay && this.secondary_overlay.posy == y) { - this.assignSpriteDepthBand( - this.secondary_overlay, - y, - 'EFFECT_OVER_UNITS', - overEffectIndex++, - ); - } - } - - this.trapGroup.sort('z', -1); - this.creatureGroup.sort('z', -1); - this.dropGroup.sort('z', -1); - this.trapOverGroup.sort('z', -1); - } - - /** - * Immediately removes all xray effects without re-applying ghostOverlap for - * any creature. Use this at turn boundaries so the old active creature's - * obstructors fade to zero cleanly before the next unit's ghostOverlap runs. - */ - clearAllXray(immediate = false) { - this.lastXrayHex = null; - this.game.creatures.forEach((c) => { - if (!(c instanceof Creature)) { - return; - } - if (immediate) { - c.clearXrayImmediately(); - return; - } - c.xray(false); - }); - } - - /** - * Re-evaluate which creatures visually obstruct the active creature and - * update their xray state accordingly. Called after every hex step during - * movement so the effect stays correct as the unit changes rows. - */ - refreshActiveCreatureXray() { - if (this.game.animations?.xraySuppressed) return; - const { activeCreature } = this.game; - if (!(activeCreature instanceof Creature)) return; - this.game.creatures.forEach((c) => { - if (c instanceof Creature) c.xray(false); - }); - activeCreature.hexagons.forEach((h) => h.ghostOverlap(activeCreature)); - } - - //******************// - //Shortcut functions// - //******************// - - /** - * Execute f for each hexes - * @param {function} func - Function to execute - * @deprecated use this.allhexes.forEach(fn) - */ - forEachHex(func: (hex: Hex) => void) { - this.hexes.forEach((hex) => { - hex.forEach(func); - }); - } - - /** - * Execute hex.cleanPathAttr() function for all the grid. Refer to the Hex class for more info - * @param {boolean} includeG - Include hex.g attribute - * @deprecated use this.allhexes.forEach(hex => hex.cleanPathAttr(includeG)) - */ - cleanPathAttr(includeG) { - this.allhexes.forEach((hex) => hex.cleanPathAttr(includeG)); - } - - /** - * Execute hex.setReachable() function for all the grid. Refer to the Hex class for more info - * @deprecated use this.allhexes.forEach(hex => hex.setReachable()) - */ - cleanReachable() { - this.allhexes.forEach((hex) => hex.setReachable()); - } - - /** - * Draw a preview of the creature at the given coordinates - * @param {{x:number, y:number}} pos - Coordinates {x,y} - * @param {object} creatureData - Object containing info from the database (game.retrieveCreatureStats) - */ - previewCreature(pos, creatureData, player, secondary = false) { - const game = this.game; - const clearPreviewOverlay = (preview, isSecondary = false) => { - if (!preview) { - return; - } - - if (isSecondary) { - if (this._flickerTweenSecondary) { - this._flickerTweenSecondary.stop(true); - this._flickerTweenSecondary = undefined; - } - } else { - if (this._flickerTween) { - this._flickerTween.stop(true); - this._flickerTween = undefined; - } - } - - preview.alpha = 0; - - if (preview._previewPos === undefined) { - return; - } - - for (let i = 0, prevSize = preview._previewSize; i < prevSize; i++) { - const prevHex = this.hexes[preview._previewPos.y]?.[preview._previewPos.x - i]; - if (prevHex && prevHex.creature !== game.activeCreature) { - this.cleanHex(prevHex); - this.restoreReachableHexVisual(prevHex); - } - } - - preview._previewPos = undefined; - }; - - const shouldShowPlacementPreview = - !game.isReplayInProgress && - !game.botController?.isBotTurn() && - !!game.activeCreature && - game.activePlayer === game.activeCreature.player; - - if (!shouldShowPlacementPreview) { - clearPreviewOverlay(secondary ? this.secondary_overlay : this.materialize_overlay, secondary); - return; - } - - const targetHex = this.hexes[pos.y]?.[pos.x]; - const queryHexes = this.lastQueryOpt?.hexes; - if ( - !targetHex || - (Array.isArray(queryHexes) && - queryHexes.length > 0 && - (!targetHex.reachable || queryHexes.indexOf(targetHex) === -1)) - ) { - clearPreviewOverlay(secondary ? this.secondary_overlay : this.materialize_overlay, secondary); - return; - } - - const hex = this.hexes[pos.y][pos.x - (creatureData.size - 1)]; - const cardboard = - creatureData.type == '--' ? creatureData.name + ' ' + player.color : creatureData.name; - - if (!secondary) { - if (!this.materialize_overlay) { - // If sprite does not exist - // Adding sprite - this.materialize_overlay = this.creatureGroup.create(0, 0, cardboard); - this.materialize_overlay.anchor.setTo(0.5, 1); - this.materialize_overlay.posy = pos.y; - } else { - this.materialize_overlay.loadTexture(cardboard); - if (this.materialize_overlay.posy != pos.y) { - this.materialize_overlay.posy = pos.y; - this.orderCreatureZ(); - } - } - } else { - if (!this.secondary_overlay) { - // If sprite does not exists - // Adding sprite - this.secondary_overlay = this.creatureGroup.create(0, 0, cardboard); - this.secondary_overlay.anchor.setTo(0.5, 1); - this.secondary_overlay.posy = pos.y; - } else { - this.secondary_overlay.loadTexture(cardboard); - if (this.secondary_overlay.posy != pos.y) { - this.secondary_overlay.posy = pos.y; - this.orderCreatureZ(); - } - } - } - - const preview = secondary ? this.secondary_overlay : this.materialize_overlay; - - // Placing sprite - preview.x = - hex.displayPos.x + - (!player.flipped - ? creatureData.display['offset-x'] - : HEX_WIDTH_PX * creatureData.size - - preview.texture.width - - creatureData.display['offset-x']) + - preview.texture.width / 2; - preview.y = hex.displayPos.y + creatureData.display['offset-y'] + preview.texture.height; - preview.alpha = 0.5; - - if (player.flipped) { - preview.scale.setTo(-1, 1); - } else { - preview.scale.setTo(1, 1); - } - - const flickering = game.Phaser.add - .tween(preview) - .to( - { - alpha: 0.15, - }, - 777, - Phaser.Easing.Linear.None, - ) - .yoyo(true) - .repeat(-1) - .start(); - if (!secondary) { - if (this._flickerTween) { - // Stop animations that are about to be orphaned #2698 - this._flickerTween.stop(true); - } - this._flickerTween = flickering; - } else { - if (this._flickerTweenSecondary) { - this._flickerTweenSecondary.stop(true); - } - this._flickerTweenSecondary = flickering; - } - - // Clean overlay from the previous preview position before painting the new one. - // Without this, every hex the cursor passes over accumulates the creature-selected - // overlay, making it look like multiple creatures have been materialized at once. - // After cleaning, restore the reachable visual state so the hex stays filled like - // the rest of the spawn-range hexes (redoLastQuery may have already restored it). - if (preview._previewPos !== undefined) { - for (let i = 0, prevSize = preview._previewSize; i < prevSize; i++) { - const prevHex = this.hexes[preview._previewPos.y]?.[preview._previewPos.x - i]; - if (prevHex) { - if (prevHex.creature === game.activeCreature) { - continue; - } - this.cleanHex(prevHex); - this.restoreReachableHexVisual(prevHex); - } - } - } - - for (let i = 0, size = creatureData.size; i < size; i++) { - const hexInstance = this.hexes[pos.y][pos.x - i]; - this.cleanHex(hexInstance); - hexInstance.overlayVisualState('creature selected player' + game.activeCreature.team); - } - - preview._previewPos = { x: pos.x, y: pos.y }; - preview._previewSize = creatureData.size; - } - - /** - * Internal debugging method to log and visually highlight (in blue) an array - * of hexes. - * @param {Hex[]} hexes - Hexes to log and visually highlight. - */ - __debugHexes(hexes: Hex[]) { - if (DEBUG) { - console.debug({ hexes }, hexes.map((hex) => hex.coord).join(', ')); - hexes.forEach((hex) => hex.displayVisualState('creature selected player1')); - } - } - - fadeOutTempCreature(target = this.materialize_overlay, durationMs = 500) { - // TODO: factor out this function. Use either Creature.creatureSprite - // or the existing temp creature created by /src/abilities/Dark-Priest.js - if (target) { - target.alpha = 0.5; - this.game.Phaser.add - .tween(target) - .to( - { - alpha: 0, - }, - durationMs, - Phaser.Easing.Linear.None, - ) - .start(); - } - } -} +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as $j from 'jquery'; +import { Direction, Hex } from './hex'; +import { Creature } from '../creature'; +import { search } from './pathfinding'; +import * as matrices from './matrices'; +import { Team, isTeam } from './team'; +import * as arrayUtils from './arrayUtils'; +import Game from '../game'; +import { DEBUG } from '../debug'; +import { HEX_WIDTH_PX } from './const'; +import { Point } from './pointfacade'; +import { AugmentedMatrix } from './matrices'; +import { PierceThroughBehavior } from '../ability'; +import { getQueryFootprintHexes } from './query_footprint'; + +const ROW_DEPTH_STRIDE = 100; + +const DEPTH_BAND = { + TRAP_GROUND: 0, + EFFECT_UNDER_UNITS: 20, + UNITS: 40, + EFFECT_OVER_UNITS: 80, + DROPS: 85, + TRAP_VOLUMETRIC: 90, +} as const; + +export type DepthBand = keyof typeof DEPTH_BAND; + +interface GridDefinition { + numRows: number; + numCols: number; + isFirstRowFull: boolean; +} + +export interface QueryOptions { + /** + * Target team. + */ + team: Team; + + /** + * Disable a choice if it does not contain a creature matching the team argument. + */ + requireCreature: boolean; + id: number; + flipped: boolean; + x: number; + y: number; + hexesDashed: Hex[]; + dashedHexesUnderCreature: boolean; + fillOnlyHoveredCreature: boolean; + shrunkenHexes: Hex[]; + hexesDeadZone: Hex[]; + directions: number[]; + includeCreature: boolean; + stopOnCreature: boolean; + pierceNumber: number; + pierceThroughBehavior: string; + + /** + * If defined, maximum distance of query in hexes. + */ + distance: number; + + /** + * If defined, minimum distance of query, 1 = 1 hex gap required. + */ + minDistance: number; + + isDirectionsQuery: boolean; + + /** + * After this distance, the direction choice will be be visualised by shrunken hexes. + * This visual state represents the ability having its effectiveness being reduced + * in some way (falling off). + */ + distanceFalloff: number; + + /** + * If a choice line stops on a creature via @param stopOnCreature, display + * dashed hexes after the creature up until the next obstacle + */ + dashedHexesAfterCreatureStop: boolean; + + /** + * Limit the length of dashed hexes added by @param dashedHexesAfterCreatureStop + */ + dashedHexesDistance: number; + + sourceCreature: Creature; + choices: Hex[][]; + + /** + * Object given to the events function (to easily pass variables for these functions). + */ + arg: any; + + optTest: (arg: Creature) => boolean; + ignoreCreatureTest?: (arg: Creature) => boolean; + + /** + * Function applied when clicking on one of the available hexes. + */ + fnOnSelect: () => void; + + /** + * Function applied when clicking again on the same hex. + */ + fnOnConfirm: () => void; + + /** + * Function applied when clicking a non reachable hex + */ + fnOnCancel: () => void; +} + +/** + * Object containing grid and methods concerning the whole grid. + * Should only have one instance during the game. + */ +export class HexGrid { + game: Game; + + /** + * Contain all hexes in row arrays (hexes[y][x]). + */ + hexes: Hex[][]; + + /** + * Last hex clicked! + */ + lastClickedHex: Hex; + + /** + * Prevents multiple shouts at the same time when a unit is clicked. + */ + onShoutCooldown: boolean; + + /** + * Last hovered creature. + */ + hoveredCreature: Creature | null = null; + + /** + * Last hex passed to xray(). Used to reapply the effect on tab focus. + */ + lastXrayHex: Hex | null = null; + lastXrayHexes: Hex[] | null = null; + + /** + * The hex the physical mouse pointer is currently over, updated before any + * freezedInput guard so it remains accurate during ability animations. + * Distinct from selectedHex which the keyboard cursor and queryHexes() reset. + */ + lastMouseHex: Hex | undefined = undefined; + + /** + * True while refreshHoverState() is replaying hover behavior programmatically. + * Used to avoid recursive query rebuilds caused by movement hover callbacks. + */ + isRefreshingHoverState = false; + + /** + * One-shot guard used to skip the next hover replay after a turn handoff. + */ + suppressNextHoverRefresh = false; + + /** + * Deferred clear for active-creature dashed hex visuals. This avoids + * toggling dashed->normal->dashed while the cursor crosses adjacent hexes. + */ + activeHexDashedClearTimeout: ReturnType | null = null; + + display: Phaser.Group; + gridGroup: Phaser.Group; + trapGroup: Phaser.Group; + hexesGroup: Phaser.Group; + displayHexesGroup: Phaser.Group; + overlayHexesGroup: Phaser.Group; + inputHexesGroup: Phaser.Group; + dropGroup: Phaser.Group; + creatureGroup: Phaser.Group; + // Health indicators rendered above all creature sprites so they are never occluded + healthIndicatorUiGroup: Phaser.Group; + trapOverGroup: Phaser.Group; + selectedHex: Hex; + _executionMode: boolean; + materialize_overlay: any; + secondary_overlay: any; + lastQueryOpt: any; + _flickerTween: Phaser.Tween | undefined; + _flickerTweenSecondary: Phaser.Tween | undefined; + + get allhexes(): Hex[] { + return this.hexes.flat(1); + } + + /** + * Create attributes and populate JS grid with Hex objects + * @param {Partial} gridDefinition - specifies a number of columns in the grid. + * The resulting grid has jagged, symmetrical edges. + * Only "full" rows have the specified number of columns. + * @param {Game} game + * @example + * // {numRows:5, numCols:4, isFirstRowFull: true} + * // + * // x x x x - full row + * // x x x - partial row + * // x x x x - full row + * // x x x - partial row + * // x x x x - full row + * @constructor + */ + constructor(gridDefinition: Partial, game: Game) { + const defaultGridDefinition = { + numRows: 9, + numCols: 16, + isFirstRowFull: false, + }; + + gridDefinition = { ...defaultGridDefinition, ...gridDefinition }; + const numRows = gridDefinition.numRows; + const numCols = gridDefinition.numCols; + const isFirstRowFull = gridDefinition.isFirstRowFull; + + this.game = game; + this.hexes = []; // Hex Array + this.lastClickedHex = undefined; + + this.display = game.Phaser.add.group(undefined, 'displayGroup'); + this.display.x = 230; + this.display.y = 380; + + this.gridGroup = game.Phaser.add.group(this.display, 'gridGroup'); + this.gridGroup.scale.set(1, 0.75); + + this.trapGroup = game.Phaser.add.group(this.gridGroup, 'trapGrp'); + this.hexesGroup = game.Phaser.add.group(this.gridGroup, 'hexesGroup'); + this.displayHexesGroup = game.Phaser.add.group(this.gridGroup, 'displayHexesGroup'); + this.overlayHexesGroup = game.Phaser.add.group(this.gridGroup, 'overlayHexesGroup'); + this.dropGroup = game.Phaser.add.group(this.display, 'dropGrp'); + this.creatureGroup = game.Phaser.add.group(this.display, 'creaturesGrp'); + // Health indicators sit above all creature sprites so they're never occluded + this.healthIndicatorUiGroup = game.Phaser.add.group(this.display, 'healthIndicatorUiGrp'); + // Parts of traps displayed over creatures + this.trapOverGroup = game.Phaser.add.group(this.display, 'trapOverGrp'); + this.trapOverGroup.scale.set(1, 0.75); + + // Populate grid + for (let row = 0; row < numRows; row++) { + this.hexes.push([]); + for (let hex = 0, len = numCols; hex < len; hex++) { + if (hex == numCols - 1) { + if ((row % 2 == 0 && !isFirstRowFull) || (row % 2 == 1 && isFirstRowFull)) { + continue; + } + } + + this.hexes[row][hex] = new Hex(hex, row, this); + } + } + + this.selectedHex = this.hexes[0][0]; + + // If true, clicking on a unit won't shout its name. + this.onShoutCooldown = false; + + // If true, clicking a monster will instantly kill it. + this._executionMode = this.game.metaPowersState.executeMonster; + + // Events + this.game.signals.metaPowers.add(this.handleMetaPowerEvent, this); + this.game.signals.ui.add(this.handleUIEvent, this); + } + + get traps() { + return this.game.traps; + } + + hexAt(x: number, y: number): Hex | undefined { + if (y < 0 || y >= this.hexes.length) return; + const row = this.hexes[y]; + if (x < 0 || x >= row.length) return; + return row[x]; + } + + handleMetaPowerEvent(message, payload) { + if (message === 'toggleExecuteMonster') { + this._executionMode = payload; + } + } + + handleUIEvent(message, _payload) { + if (message === 'onOpenDash' || message === 'onCloseDash') { + // When the dash opens or closes, creatures can remain in a "hovered" state + // (e.g. bounce animation stuck). Reset all bounces to ensure a clean state. + this.forEachHex((hex) => { + const creature = hex.creature; + if (creature instanceof Creature) { + creature.resetBounce(); + } + }); + } + } + + isInBounds({ x, y }: Point) { + return y < this.hexes.length && y >= 0 && x < this.hexes[y].length && x >= 0; + } + + querySelf(o) { + const game = this.game; + const defaultOpt = { + fnOnConfirm: () => { + // No-op function. + }, + fnOnSelect: (creature: Creature) => { + creature.hexagons.forEach((hex) => { + hex.overlayVisualState('creature selected player' + hex.creature.team); + }); + }, + fnOnCancel: () => { + this.game.activeCreature?.queryMove(); + }, + args: {}, + confirmText: 'Confirm', + id: game.activeCreature.id, + }; + + o = { ...defaultOpt, ...o }; + + game.activeCreature.hint(o.confirmText, 'confirm'); + + this.queryHexes({ + fnOnConfirm: (hex, args) => { + args.opt.fnOnConfirm(game.activeCreature, args.opt.args, { queryOptions: o }); + }, + fnOnSelect: (hex, args) => { + args.opt.fnOnSelect(game.activeCreature, args.opt.args); + }, + fnOnCancel: (hex, args) => { + args.opt.fnOnCancel(game.activeCreature, args.opt.args); + }, + args: { + opt: o, + }, + hexes: game.activeCreature.hexagons, + hideNonTarget: true, + id: o.id, + }); + } + + /** + * Shortcut to queryChoice with specific directions. + * @param {QueryOptions} o + */ + queryDirection(o: Partial) { + o.isDirectionsQuery = true; + const defaultOpt = { + team: Team.Enemy, + id: 0, + flipped: false, + x: 0, + y: 0, + directions: [1, 1, 1, 1, 1, 1], + includeCreature: true, + stopOnCreature: true, + pierceNumber: 1, + pierceThroughBehavior: 'stop', + distance: 0, + minDistance: 0, + distanceFalloff: 0, + dashedHexesAfterCreatureStop: true, + dashedHexesDistance: 0, + dashedHexesUnderCreature: true, + sourceCreature: undefined, + isDirectionsQuery: true, + }; + + o = { ...defaultOpt, ...o }; + + o = this.getDirectionChoices(o); + this.queryChoice(o); + + return true; + } + + /** + * Get an object that contains the choices and hexesDashed for a direction query. + * @param {QueryOptions} o Options. + * @returns {QueryOptions} Altered options. + */ + getDirectionChoices(o: Partial) { + const defaultOpt = { + team: Team.Enemy, + requireCreature: true, + id: 0, + flipped: false, + x: 0, + y: 0, + hexesDashed: [], + shrunkenHexes: [], + hexesDeadZone: [], + directions: [1, 1, 1, 1, 1, 1], + includeCreature: true, + stopOnCreature: true, + pierceNumber: 1, + pierceThroughBehavior: 'stop', + distance: 0, + minDistance: 0, + distanceFalloff: 0, + dashedHexesAfterCreatureStop: true, + dashedHexesDistance: 0, + dashedHexesUnderCreature: true, + sourceCreature: undefined, + choices: [], + optTest: () => true, + ignoreCreatureTest: undefined, + fillOnlyHoveredCreature: false, + }; + + const options = { ...defaultOpt, ...o }; + + // Clean Direction + this.forEachHex((hex) => { + hex.direction = Direction.None; + }); + + options.choices = []; + + for (let i = 0, len = options.directions.length; i < len; i++) { + if (!options.directions[i]) { + continue; + } + + const direction = i as Direction; + let dir: Hex[] = []; + let fx = 0; + + if (options.sourceCreature instanceof Creature) { + const flipped = options.sourceCreature.player.flipped; + if ( + (!flipped && direction > Direction.DownRight) || + (flipped && direction < Direction.DownLeft) + ) { + fx = -1 * (options.sourceCreature.size - 1); + } + } + + dir = this.getHexLine(options.x + fx, options.y, direction, options.flipped); + + // Limit hexes based on distance + if (options.distance > 0) { + dir = dir.slice(0, options.distance + 1); + } + + // The untargetable area between the unit and the minimum distance. + let deadzone = []; + if (options.minDistance > 0) { + deadzone = dir.slice(0, options.minDistance); + deadzone = arrayUtils.filterCreature( + deadzone, + options.includeCreature, + options.stopOnCreature, + options.id, + ); + + dir = dir.slice( + // 1 greater than expected to exclude current (source creature) hex. + options.minDistance, + ); + } + + const hexesDeadZone = []; + deadzone.forEach((element) => { + hexesDeadZone.push(element); + }); + + /* If the ability has a minimum distance and units should block LOS, this + direction cannot be used if there is a unit in the deadzone. */ + if (options.stopOnCreature && deadzone.length && this.atLeastOneTarget(deadzone, options)) { + continue; + } + + let hexesDashed = []; + dir.forEach((item) => { + item.direction = options.flipped ? 5 - direction : direction; + + if (options.stopOnCreature && options.dashedHexesAfterCreatureStop) { + hexesDashed.push(item); + } + }); + + arrayUtils.filterCreature( + dir, + options.includeCreature, + options.stopOnCreature, + options.id, + options.sourceCreature, + options.pierceNumber, + options.pierceThroughBehavior as PierceThroughBehavior, + options.team, + options.ignoreCreatureTest, + ); + + if (dir.length === 0) { + continue; + } + + if (options.requireCreature && !this.atLeastOneTarget(dir, options)) { + continue; + } + + if ( + options.stopOnCreature && + options.includeCreature && + // Only straight direction. + (direction === Direction.Right || direction === Direction.Left) + ) { + if (arrayUtils.last(dir).creature instanceof Creature) { + // Add all creature hexes. + const creature = arrayUtils.last(dir).creature; + dir.pop(); + dir = arrayUtils.sortByDirection(dir.concat(creature.hexagons), direction); + } + } + + dir.forEach((item) => { + arrayUtils.removePos(hexesDashed, item); + }); + + /* For some reason hexesDashed can contain source creature hexagons. Rather + than risk changing that logic, create a new list without the source creature. */ + const hexesDashedWithoutSourceCreature = arrayUtils.filterCreature( + hexesDashed, + true, + false, + options.id, + ); + + if (hexesDashed.length && options.dashedHexesDistance) { + hexesDashed = hexesDashedWithoutSourceCreature.slice(0, options.dashedHexesDistance); + } + + let shrunkenHexes: Hex[] = []; + if (options.distanceFalloff) { + /* Shrunken hexes do not replace existing hexes, instead they modify them. + With that in mind, regular AND dashed hexes after the falloff distance + can be shrunk. */ + shrunkenHexes = [...dir, ...hexesDashedWithoutSourceCreature].slice( + options.distanceFalloff, + ); + } + + // Deadzone hexes are also part of direction, so they should be clickable + deadzone.forEach((element) => { + dir.push(element); + }); + + options.hexesDashed = [...options.hexesDashed, ...hexesDashed]; + options.shrunkenHexes = [...options.shrunkenHexes, ...shrunkenHexes]; + options.hexesDeadZone = [...options.hexesDeadZone, ...hexesDeadZone]; + options.choices.push(dir); + } + + return options; + } + + /** + * Return whether there is at least one creature in the hexes that satisfies + * various conditions, e.g. team. + * + * @param {} dir ? + * @param {Object} o + * @return {boolean} At least one valid target. + */ + atLeastOneTarget(dir, o) { + const defaultOpt = { + team: Team.Both, + optTest: function () { + return true; + }, + }; + + const options = { ...defaultOpt, ...o }; + + let validChoice = false; + + // Search each hex for a creature that matches the team argument. + for (let j = 0; j < dir.length; j++) { + const targetCreature = dir[j].creature; + + if (targetCreature instanceof Creature && targetCreature.id !== options.id) { + const sourceCreature = this.game.creatures[options.id]; + + if ( + isTeam(sourceCreature, targetCreature, options.team) && + options.optTest(targetCreature) + ) { + validChoice = true; + break; + } + } + } + + if (validChoice) { + return true; + } + + return false; + } + + /** + * fnOnSelect : Function : Function applied when clicking on one of the available hexes. + * fnOnConfirm : Function : Function applied when clicking again on the same hex. + * fnOnCancel : Function : Function applied when clicking a non reachable hex + * requireCreature : Boolean : Disable a choice if it does not contain a creature matching the team argument + * args : Object : Object given to the events function (to easily pass variable for these function) + */ + queryChoice(o) { + const game = this.game; + const defaultOpt = { + fnOnConfirm: () => { + game.activeCreature?.queryMove(); + }, + fnOnSelect: (choice) => { + // When only filling the hovered creature + if (o.fillOnlyHoveredCreature) { + choice.forEach((item, index) => { + if (item.creature instanceof Creature && item.creature === this.hoveredCreature) { + item.displayVisualState('creature selected player' + item.creature.team); + } else if (item.creature instanceof Creature) { + item.displayVisualState('adj'); + } else { + // Split the choice into two parts, before and after the empty hex + const beforeEmpty = choice.slice(0, index); + const afterEmpty = choice.slice(index + 1); + // Check conditions + if (beforeEmpty.some((hex) => hex.creature instanceof Creature)) { + item.displayVisualState('dashed'); + } else if (afterEmpty.some((hex) => hex.creature instanceof Creature)) { + item.displayVisualState('adj'); + } + } + }); + } + // Normal behavior + else { + // Reset all choices to base state so only the hovered one is emphasised. + o.choices.forEach((otherChoice) => { + otherChoice.forEach((item) => { + item.cleanDisplayVisualState('adj creature player0 player1 player2 player3'); + }); + }); + choice.forEach((item) => { + if (item.creature instanceof Creature) { + item.displayVisualState('creature selected player' + item.creature.team); + } else { + item.displayVisualState('adj'); + } + }); + } + }, + fnOnCancel: () => { + game.activeCreature?.queryMove(); + }, + fnOnHoverOutside: (() => { + // Restore all choices to base/light state when pointer leaves the valid area. + o.choices.forEach((choice) => { + choice.forEach((item) => { + item.cleanDisplayVisualState('adj creature player0 player1 player2 player3'); + }); + }); + }) as (() => void) | undefined, + team: Team.Enemy, + requireCreature: 1, + id: 0, + args: {}, + flipped: false, + choices: [], + hexesDashed: [], + hexesDeadZone: [], + shrunkenHexes: [], + isDirectionsQuery: false, + hideNonTarget: true, + dashedHexesUnderCreature: false, + fillOnlyHoveredCreature: false, + }; + + // Overwrite any default options with options passed in through `o` + o = { ...defaultOpt, ...o }; + + let hexes = []; + for (let i = 0, len = o.choices.length; i < len; i++) { + let validChoice = true; + + if (o.requireCreature) { + validChoice = false; + // Search each hex for a creature that matches the team argument + for (let j = 0; j < o.choices[i].length; j++) { + if (o.choices[i][j].creature instanceof Creature && o.choices[i][j].creature != o.id) { + const creaSource = game.creatures[o.id]; + const creaTarget = o.choices[i][j].creature; + + if (isTeam(creaSource, creaTarget, o.team)) { + validChoice = true; + } + } + } + } + + if (validChoice) { + hexes = hexes.concat(o.choices[i]); + if (!(o as any).preserveDashedHexesInChoices) { + o.choices[i].forEach((hex) => { + arrayUtils.removePos(o.hexesDashed, hex); + }); + } + } else if (o.isDirectionsQuery) { + this.forEachHex((hex) => { + if (o.choices[i][0].direction == hex.direction) { + arrayUtils.removePos(o.hexesDashed, hex); + } + }); + } + } + + o.hexesDashed = o.dashedHexesUnderCreature + ? o.hexesDashed + : o.hexesDashed.filter((hexDash) => !hexDash.creature); + + this.queryHexes({ + fnOnConfirm: (hex, args) => { + // Determine which set of hexes (choice) the hex is part of + for (let i = 0, len = args.opt.choices.length; i < len; i++) { + for (let j = 0, lenj = args.opt.choices[i].length; j < lenj; j++) { + if (hex.pos == args.opt.choices[i][j].pos) { + args.opt.args.direction = hex.direction; + args.opt.fnOnConfirm(args.opt.choices[i], args.opt.args, { queryOptions: o }); + return; + } + } + } + }, + fnOnSelect: (hex, args) => { + // Determine which set of hexes (choice) the hex is part of + for (let i = 0, len = args.opt.choices.length; i < len; i++) { + for (let j = 0, lenj = args.opt.choices[i].length; j < lenj; j++) { + if (hex.pos == args.opt.choices[i][j].pos) { + args.opt.args.direction = hex.direction; + args.opt.args.hex = hex; + args.opt.args.choiceIndex = i; + args.opt.fnOnSelect(args.opt.choices[i], args.opt.args, { queryOptions: o }); + return; + } + } + } + }, + fnOnCancel: o.fnOnCancel, + fnOnHoverOutside: o.fnOnHoverOutside, + args: { + opt: o, + }, + hexes: hexes, + hexesDashed: o.hexesDashed, + shrunkenHexes: o.shrunkenHexes, + hexesDeadZone: o.hexesDeadZone, + flipped: o.flipped, + hideNonTarget: o.hideNonTarget, + id: o.id, + fillHexOnHover: false, + fillOnlyHoveredCreature: o.fillOnlyHoveredCreature, + targeting: o.targeting !== undefined ? o.targeting : true, + callbackAfterQueryHexes: o.callbackAfterQueryHexes, + }); + } + + /** + * @param {object} o Object given to the events function (to easily pass variable for these function) + * @param {function} o.fnOnSelect Function applied when clicking on one of the available hexes. + * @param {function} o.fnOnConfirm Function applied when clicking again on the same hex. + * @param {function} o.fnOnCancel Function applied when clicking a non reachable hex. + * @param {Team} o.team The targetable team. + * @param {number} o.id Creature ID + * @param {boolean} o.replaceEmptyHexesWithDashed Replace all non targetable, empty hexes with dashed hexes. + * o.hexesDashed will override this option. + */ + queryCreature(o) { + const game = this.game; + const defaultOpt = { + fnOnConfirm: () => { + game.activeCreature?.queryMove(); + }, + fnOnSelect: (creature) => { + creature.tracePosition({ + overlayClass: 'creature selected player' + creature.team, + }); + }, + fnOnCancel: () => { + game.activeCreature?.queryMove(); + }, + optTest: () => true, + args: {}, + hexes: [], + hexesDashed: [], + hexesDeadZone: [], + flipped: false, + id: 0, + team: Team.Enemy, + replaceEmptyHexesWithDashed: false, + }; + + o = { ...defaultOpt, ...o }; + + /* Divide hexes into: + - containing valid targets + - empty (no possible target) + Hexes containing invalid targets (wrong team, o.optTest, etc) are discard. */ + const { targetHexes, emptyHexes } = o.hexes.reduce( + (acc, hex) => { + const sourceCreature = game.creatures[o.id]; + const targetCreature = hex.creature; + + const acceptTargetHex = () => { + return { + ...acc, + targetHexes: [...acc.targetHexes, hex], + }; + }; + + const acceptEmptyHex = () => { + return { + ...acc, + emptyHexes: [...acc.emptyHexes, hex], + }; + }; + + const discardHex = () => { + return acc; + }; + + if (!targetCreature) { + return acceptEmptyHex(); + } + + if (targetCreature instanceof Creature && targetCreature.id !== o.id) { + if (!o.optTest(hex.creature)) { + return discardHex(); + } + + if (isTeam(sourceCreature, targetCreature, o.team)) { + return acceptTargetHex(); + } + } + + return discardHex(); + }, + { targetHexes: [], emptyHexes: [] }, + ); + + o.hexes = targetHexes; + + if (o.replaceEmptyHexesWithDashed && !o.hexesDashed.length) { + o.hexesDashed = emptyHexes; + } + + let extended = []; + /* Add creature hexes that extend out of the range of the source hexes, so the + entire creature can be highlighted. */ + o.hexes.forEach((hex) => { + extended = extended.concat(hex.creature.hexagons); + }); + + o.hexes = extended; + + // Xray: make obstructors of all valid ability targets semi-transparent so + // the player can see every targetable unit clearly before hovering. + const abilityActiveCreature = this.game.activeCreature; + const seenTargets = new Set(); + o.hexes.forEach((hex) => { + const c = hex.creature; + if (c instanceof Creature && c !== abilityActiveCreature && !seenTargets.has(c)) { + seenTargets.add(c); + c.hexagons.forEach((h) => h.ghostOverlap(c)); + c.xray(false); // target itself must remain fully opaque + } + }); + + // Active creature (attacker) must never be xrayed ? ghostOverlap for a + // target's hexes can pick it up as a same-row or adjacent-row candidate. + if (abilityActiveCreature instanceof Creature) { + abilityActiveCreature.xray(false); + } + + this.queryHexes({ + fnOnConfirm: (hex, args) => { + const { creature } = hex; + if (!creature) return; + args.opt.fnOnConfirm(creature, args.opt.args, { queryOptions: o }); + }, + fnOnSelect: (hex, args) => { + const { creature } = hex; + if (!creature) return; + args.opt.fnOnSelect(creature, args.opt.args); + }, + fnOnCancel: o.fnOnCancel, + args: { + opt: o, + }, + hexes: o.hexes, + hexesDashed: o.hexesDashed, + hexesDeadZone: o.hexesDeadZone, + flipped: o.flipped, + hideNonTarget: true, + id: o.id, + }); + } + + redoLastQuery() { + this.queryHexes(this.lastQueryOpt); + } + + /** + * Re-evaluate hover state for the hex currently under the pointer. + * Call this whenever freezedInput transitions to false so the cursor and + * visual highlights update without requiring mouse movement. + */ + refreshHoverState() { + if (this.suppressNextHoverRefresh) { + this.suppressNextHoverRefresh = false; + return; + } + if (this.game.botController?.isBotTurn()) { + return; + } + const hex = this.lastMouseHex; + if (!hex || this.game.freezedInput || this.isRefreshingHoverState) return; + this.cancelDeferredActiveHexDashedClear(); + // Replicate what onInputOver does so cursor, unit preview and xray all update. + if (hex.reachable && this.game.activeCreature) { + this.game.activeCreature.highlightCurrentHexesAsDashed(); + } + this.game.signals.hex.dispatch('over', { hex }); + this.selectedHex = hex; + this.isRefreshingHoverState = true; + try { + hex.onSelectFn(hex); + } finally { + this.isRefreshingHoverState = false; + } + } + + cancelDeferredActiveHexDashedClear() { + if (this.activeHexDashedClearTimeout) { + clearTimeout(this.activeHexDashedClearTimeout); + this.activeHexDashedClearTimeout = null; + } + } + + scheduleDeferredActiveHexDashedClear() { + this.cancelDeferredActiveHexDashedClear(); + this.activeHexDashedClearTimeout = setTimeout(() => { + this.activeHexDashedClearTimeout = null; + this.game.activeCreature?.clearDashedOverlayOnHexes(); + }, 0); + } + + /** + * fnOnSelect : Function : Function applied when clicking on one of the available hexes. + * fnOnConfirm : Function : Function applied when clicking again on the same hex. + * fnOnCancel : Function : Function applied when clicking a non reachable hex + * args : Object : Object given to the events function (to easily pass variable for these function) + * hexes : Array : Reachable hexes + * callbackAfterQueryHexes : Function : empty function to be overridden with custom logic to execute after queryHexes + */ + queryHexes(o) { + const game = this.game; + const getCreatureDisplayName = (creature: Creature) => { + const withoutPrefix = creature.name.replace(/^object[_-]/i, ''); + const spacedName = withoutPrefix.replace(/[_-]+/g, ' ').trim(); + if (!spacedName) { + return creature.name; + } + + return spacedName.charAt(0).toUpperCase() + spacedName.slice(1); + }; + // Detect whether this is a fresh query or a redo of the last query. + // redoLastQuery() passes the same lastQueryOpt reference, so reference + // equality distinguishes the two cases. + const isFreshQuery = o !== this.lastQueryOpt; + const defaultOpt = { + fnOnConfirm: () => { + game.activeCreature?.queryMove(); + }, + fnOnSelect: (hex: Hex) => { + game.activeCreature.faceHex(hex); + hex.overlayVisualState('creature selected player' + game.activeCreature.team); + }, + fnOnCancel: () => { + game.activeCreature?.queryMove(); + }, + fnOnHoverOutside: undefined as (() => void) | undefined, + callbackAfterQueryHexes: () => { + // empty function to be overridden with custom logic to execute after queryHexes + }, + args: {}, + hexes: [], + hexesDashed: [], + shrunkenHexes: [], + hexesDeadZone: [], + size: 1, + id: 0, + flipped: false, + hideNonTarget: false, + ownCreatureHexShade: false, + targeting: true, + fillHexOnHover: true, + fillOnlyHoveredCreature: false, + }; + + o = { ...defaultOpt, ...o }; + + this.lastClickedHex = undefined; + + // Save the last Query + this.lastQueryOpt = { ...o }; + + const clearPreviewOverlay = (preview, secondary = false) => { + if (!preview) { + return; + } + + if (secondary) { + if (this._flickerTweenSecondary) { + this._flickerTweenSecondary.stop(true); + this._flickerTweenSecondary = undefined; + } + } else { + if (this._flickerTween) { + this._flickerTween.stop(true); + this._flickerTween = undefined; + } + } + + preview.alpha = 0; + + if (preview._previewPos === undefined) { + return; + } + + for (let i = 0; i < preview._previewSize; i++) { + const prevHex = this.hexes[preview._previewPos.y]?.[preview._previewPos.x - i]; + if (!prevHex || prevHex.creature === game.activeCreature) { + continue; + } + + this.cleanHex(prevHex); + this.restoreReachableHexVisual(prevHex); + } + + preview._previewPos = undefined; + }; + + // Reset keyboard cursor to the active creature's front hexagon so that + // arrow-key navigation always starts from a consistent, tactically useful + // position rather than wherever the cursor happened to be last. + // Only do this for fresh queries; redoLastQuery() must not move the cursor + // mid-flight (it is called from clearHexViewAlterations inside selectHex*). + if (isFreshQuery) { + const activeCreature = game.activeCreature; + if (activeCreature?.hexagons?.length) { + const frontHex = activeCreature.player.flipped + ? activeCreature.hexagons[activeCreature.size - 1] + : activeCreature.hexagons[0]; + if (frontHex) { + this.selectedHex = frontHex; + } + } + } + + this.updateDisplay(); + // Block all hexes + this.forEachHex((hex) => { + hex.unsetReachable(); + + if (o.hideNonTarget) { + hex.setNotTarget(); + } else { + hex.unsetNotTarget(); + } + + if (o.hexesDashed.indexOf(hex) !== -1) { + hex.displayVisualState('dashed'); + } else { + hex.cleanDisplayVisualState('dashed'); + } + + if (o.hexesDeadZone.indexOf(hex) !== -1) { + hex.displayVisualState('deadzone'); + } else { + hex.cleanDisplayVisualState('deadzone'); + } + + if (o.shrunkenHexes.includes(hex)) { + hex.displayVisualState('shrunken'); + } else { + hex.cleanDisplayVisualState('shrunken'); + } + }); + + // Cleanup + clearPreviewOverlay(this.materialize_overlay); + clearPreviewOverlay(this.secondary_overlay, true); + + if (this._flickerTween) { + this._flickerTween.stop(true); + } + if (this._flickerTweenSecondary) { + this._flickerTweenSecondary.stop(true); + } + + if (!o.ownCreatureHexShade) { + if (o.id instanceof Array) { + o.id.forEach((id) => { + game.creatures[id].hexagons.forEach((hex) => { + hex.overlayVisualState('ownCreatureHexShade'); + }); + }); + } else { + if (o.id != 0) { + game.creatures[o.id].hexagons.forEach((hex) => { + hex.overlayVisualState('ownCreatureHexShade'); + }); + } + } + } + + // Function to find the path of a given hex + const findDirectionalPathOfHex = (hex) => { + let startIndex = o.hexes.indexOf(hex); + let endIndex = startIndex; + // Find the start of the path + while (startIndex > 0 && o.hexes[startIndex - 1].direction === hex.direction) { + startIndex--; + } + // Find the end of the path + while (endIndex < o.hexes.length - 1 && o.hexes[endIndex + 1].direction === hex.direction) { + endIndex++; + } + // Extract the path + return o.hexes.slice(startIndex, endIndex + 1); + }; + + // Function to determine if an empty hex is before or after the first creature in path + const emptyHexBeforeCreature = (hex) => { + const path = findDirectionalPathOfHex(hex); + const index = path.findIndex((h) => h === hex); + const beforeEmpty = path.slice(0, index); + const afterEmpty = path.slice(index + 1); + // Check conditions + if (beforeEmpty.some((hex) => hex.creature instanceof Creature)) { + return false; + } else if (afterEmpty.some((hex) => hex.creature instanceof Creature)) { + return true; + } + }; + // Set reachable the given hexes + o.hexes.forEach((hex) => { + hex.setReachable(); + if (o.hideNonTarget) { + hex.unsetNotTarget(); + } + if (o.targeting) { + if (hex.creature instanceof Creature) { + if (hex.creature.id != this.game.activeCreature.id) { + hex.overlayVisualState('reachable h_player' + hex.creature.team); + // Add dashed hexagons under targets for ranged abilities with team color + hex.displayVisualState('dashed player' + hex.creature.team); + // Ensure dashed hexagons are on top for better visibility + hex.grid.displayHexesGroup.bringToTop(hex.display); + } + } else { + if (o.fillOnlyHoveredCreature && !emptyHexBeforeCreature(hex)) { + hex.displayVisualState('dashed'); + } else { + hex.overlayVisualState('reachable h_player' + this.game.activeCreature.team); + } + } + } + }); + + if (o.callbackAfterQueryHexes) { + o.callbackAfterQueryHexes(); + } + + const onCreatureHover = (creature: Creature, queueEffect, hex: Hex) => { + this.hoveredCreature = creature; + if (creature.isDarkPriest()) { + if (creature === game.activeCreature) { + if (creature.hasCreaturePlayerGotPlasma()) { + creature.displayPlasmaShield(); + } + } else { + creature.displayHealthStats(); + } + } + creature.hexagons.forEach((h) => { + // Flashing outline + h.overlayVisualState('hover h_player' + creature.team); + // Keep the dashed hexagons visible under targets with team color + if (h.displayClasses.indexOf('dashed') === -1) { + h.displayVisualState('dashed player' + creature.team); + } + // Make sure the display hexagon is brought to the top for better visibility + h.grid.displayHexesGroup.bringToTop(h.display); + }); + if (creature !== game.activeCreature) { + if (!hex.reachable) { + $j('canvas').css('cursor', 'n-resize'); + } else { + // Filled hex with color + hex.displayVisualState('creature player' + hex.creature.team); + } + } else if (game.activeCreature.noActionPossible) { + $j('canvas').css('cursor', 'progress'); + } + queueEffect(creature.id); + }; + + // Once a reachable hex is confirmed, ignore any late hover callbacks tied to + // this query instance until queryHexes() installs a fresh set of handlers. + let isQueryLockedAfterConfirm = false; + + // ONCLICK + const onConfirmFn = (hex: Hex) => { + if (isQueryLockedAfterConfirm) { + return; + } + + // Debugger + const y = hex.y; + let x = hex.x; + + // Clear display and overlay + $j('canvas').css('cursor', 'pointer'); + + if (this._executionMode && hex.creature instanceof Creature) { + hex.creature.die({ player: game.players[0] }); + return; + } + + // Not reachable hex + if (!hex.reachable) { + this.lastClickedHex = undefined; + + if (hex.creature instanceof Creature) { + // If creature + onCreatureHover( + hex.creature, + game.activeCreature !== hex.creature + ? game.UI.bouncexrayQueue.bind(game.UI) + : game.UI.xrayQueue.bind(game.UI), + hex, + ); + + // Shout unit's name, show tooltip with unit's name and start a cooldown. + if (!this.onShoutCooldown) { + this.onShoutCooldown = true; + const shoutName = hex.creature.name; + const displayName = getCreatureDisplayName(hex.creature); + game.soundsys.playShout(shoutName); + hex.creature.hint(displayName, 'creature_name'); + + setTimeout(() => { + this.onShoutCooldown = false; + }, 1200); + } + } else { + // If nothing + if (game.activeCreature.noActionPossible) { + game.skipTurn(); + } else { + o.fnOnCancel(hex, o.args); // ON CANCEL + } + } + } else { + // Reachable hex + // Offset Pos + const offset = o.flipped ? o.size - 1 : 0; + const mult = o.flipped ? 1 : -1; // For flipped player + + // If only filling hovered creatures hexes, cancel if player clicks on empty hex after first creature + if (o.fillOnlyHoveredCreature && !emptyHexBeforeCreature(hex)) { + if (!(hex.creature instanceof Creature)) { + o.fnOnCancel(hex, o.args); // ON CANCEL + return; + } + } + + // If hex is reachable & creature, reset target bounce. + // Preserve active creature bounce (bot can confirm without a hover-off cycle). + if (hex.creature instanceof Creature && hex.creature !== game.activeCreature) { + hex.creature.resetBounce(); + } + + for (let i = 0, size = o.size; i < size; i++) { + // Try next hexagons to see if they fits + if (x + offset - i * mult >= this.hexes[y].length || x + offset - i * mult < 0) { + continue; + } + + if (this.hexes[y][x + offset - i * mult].isWalkable(o.size, o.id)) { + x += offset - i * mult; + break; + } + } + + hex = this.hexes[y][x]; // New coords + game.activeCreature.faceHex(hex); + isQueryLockedAfterConfirm = true; + this.lastMouseHex = undefined; + + if (game.activeCreature === hex.creature && hex.creature.noActionPossible) { + game.UI.hoveringNoActionCreature = false; + // Clear noActionPossible before fading so any cleanup queryMove(null) + // during turn-end deactivation does not re-trigger querySelf and spawn + // a second Skip turn marker on top of the fading one. + hex.creature.noActionPossible = false; + hex.creature.fadeOutNoActionHints(); + } + + if (hex !== this.lastClickedHex) { + this.lastClickedHex = hex; + } + + // Keep primary materialize preview alive for summon handoff: + // Creature.summon() uses fadeOutTempCreature() for a smooth transition. + clearPreviewOverlay(this.secondary_overlay, true); + + o.fnOnConfirm(hex, o.args, { queryOptions: o }); + } + }; + + const onHoverOffFn = (hex: Hex) => { + if (isQueryLockedAfterConfirm) { + return; + } + + const { creature } = hex; + + if (creature instanceof Creature) { + this.hoveredCreature = null; + creature.resetBounce(); + if (creature === game.activeCreature) { + creature.startBounce(); + } + // toggle hover off event + if (creature.isDarkPriest()) { + // the plasma would have been displayed so now display the health again + creature.updateHealth(); + } + + if (game.activeCreature === hex.creature && hex.creature.noActionPossible) { + game.UI.hoveringNoActionCreature = false; + game.UI.btnSkipTurn.$button.removeClass('hidden'); + // Stop bouncing no-action hint when cursor leaves, but keep it visible. + creature.stopNoActionHintBounce(); + } + } + game.UI.chat.hideExpanded(); + $j('canvas').css('cursor', 'default'); + }; + + // ONMOUSEOVER + const onSelectFn = (hex: Hex) => { + if (isQueryLockedAfterConfirm) { + return; + } + + let { x } = hex; + const { y } = hex; + + // Xray + const xrayHexes = + hex.reachable && !(hex.creature instanceof Creature) + ? getQueryFootprintHexes(this, hex, o.size, o.flipped, o.id) + : undefined; + this.xray(hex, xrayHexes); + + // Clear display and overlay + game.UI.xrayQueue(-1); + $j('canvas').css('cursor', 'pointer'); + $j('body').css('cursor', 'default'); + + if (hex.creature instanceof Creature) { + if (!game.botController?.isBotTurn()) { + game.UI.chat.showExpanded(hex.creature); + } + // Keep reference + onCreatureHover(hex.creature, game.UI.xrayQueue.bind(game.UI), hex); + + hex.creature.startBounce(); + + if (game.activeCreature === hex.creature && hex.creature.noActionPossible) { + game.UI.hoveringNoActionCreature = true; + game.UI.btnSkipTurn.$button.removeClass('bounce'); + game.UI.btnSkipTurn.$button.removeClass('hidden'); + // Show "Skip Turn" icon + hex.creature.hint('Skip turn', 'no_action'); + } + game.UI.chat.isOverCreature = true; + } + + if (hex.reachable) { + if (o.fillOnlyHoveredCreature && !(hex.creature instanceof Creature)) { + if (!emptyHexBeforeCreature(hex)) { + $j('canvas').css('cursor', 'not-allowed'); + hex.overlayVisualState('hover'); + } else { + const index = o.hexes.indexOf(hex); + const afterEmpty = o.hexes.slice(index + 1); + // Find the next creature after the empty hex + const nextCreature = afterEmpty.find( + (hex) => hex.creature instanceof Creature, + )?.creature; + if (nextCreature) { + // Apply the desired behavior to all hexes the next creature occupies + nextCreature.hexagons.forEach((creatureHex) => { + creatureHex.displayVisualState('creature selected player' + nextCreature.team); + }); + } + } + } + + if (o.fillHexOnHover) { + this.cleanHex(hex); + hex.displayVisualState('creature player' + this.game.activeCreature.team); + } + + // Offset Pos + const offset = o.flipped ? o.size - 1 : 0; + const mult = o.flipped ? 1 : -1; // For flipped player + + for (let i = 0, size = o.size; i < size; i++) { + // Try next hexagons to see if they fit + if (x + offset - i * mult >= this.hexes[y].length || x + offset - i * mult < 0) { + continue; + } + if (this.hexes[y][x + offset - i * mult].isWalkable(o.size, o.id)) { + x += offset - i * mult; + break; + } + } + + hex = this.hexes[y][x]; // New coords + o.fnOnSelect(hex, o.args); + } else if (!hex.reachable) { + // Clean the last preview position's hex overlays and reset tracking so + // that re-entering the spawn range doesn't leave a ghost outline behind. + clearPreviewOverlay(this.materialize_overlay); + clearPreviewOverlay(this.secondary_overlay, true); + hex.overlayVisualState('hover'); + + $j('canvas').css( + 'cursor', + game.activeCreature.noActionPossible ? 'progress' : 'not-allowed', + ); + + if (o.fnOnHoverOutside) { + o.fnOnHoverOutside(); + } + + // If creature and inactive + if (hex.creature instanceof Creature && hex.creature !== game.activeCreature) { + $j('canvas').css('cursor', 's-resize'); + } + } + }; + + // ONRIGHTCLICK + const onRightClickFn = (hex: Hex) => { + if (isQueryLockedAfterConfirm) { + return; + } + + if (hex.creature instanceof Creature) { + if (hex.creature.name.startsWith('object_')) { + game.UI.showCreature(game.activeCreature.type, game.activeCreature.player.id, 'emptyHex'); + } else { + game.UI.showCreature(hex.creature.type, hex.creature.player.id, 'grid'); + } + } else { + if (game.activeCreature.isDarkPriest()) { + if (game.UI.selectedCreatureObj) { + game.UI.toggleDash(false); + } else { + game.UI.showCreature( + game.activeCreature.type, + game.activeCreature.player.id, + 'emptyHex', + ); + } + } else { + game.UI.showCreature(game.activeCreature.type, game.activeCreature.player.id, 'emptyHex'); + } + } + }; + + this.forEachHex((hex) => { + hex.onSelectFn = onSelectFn; + hex.onHoverOffFn = onHoverOffFn; + hex.onConfirmFn = onConfirmFn; + hex.onRightClickFn = onRightClickFn; + }); + + // All hex handlers are now up to date; refresh only for fresh queries. + // redoLastQuery() is called from hover preview flows and must not retrigger + // onSelectFn here, or it can recurse into queryHexes and freeze the game. + if (isFreshQuery) { + this.refreshHoverState(); + } + + if (this.game.botController?.shouldAutoResolveQuery()) { + this.game.botController.resolveQuery(o, { + onSelect: onSelectFn, + onConfirm: onConfirmFn, + }); + } + } + + /** + * @param {Hex} hex - Hexagon to emphasize. + * + * If hex contain creature call ghostOverlap for each creature hexes + */ + xray(hex: Hex, referenceHexes?: Hex[]) { + if (this.game.animations?.xraySuppressed) { + return; + } + const shouldReuseLastFootprint = + !referenceHexes && this.lastXrayHex === hex && this.lastXrayHexes?.length; + this.lastXrayHex = hex; + if (referenceHexes?.length) { + this.lastXrayHexes = referenceHexes; + } else if (shouldReuseLastFootprint) { + referenceHexes = this.lastXrayHexes; + } else { + this.lastXrayHexes = null; + } + + // Clear previous ghost + this.game.creatures.forEach((creature) => { + if (creature instanceof Creature) { + creature.xray(false); + } + }); + + const { activeCreature } = this.game; + if (!(activeCreature instanceof Creature)) { + return; + } + + const noAbilitySelected = this.game.UI?.selectedAbility === -1; + const hoveredCreature = + hex.creature instanceof Creature ? (hex.creature as Creature) : undefined; + const traps = this.game.traps ?? []; + const drops = this.game.drops ?? []; + const hoveredTrapSprites = traps + .filter((trap) => trap.x === hex.x && trap.y === hex.y) + .flatMap((trap) => trap.getVisualSprites()) + .filter((sprite) => sprite.exists && typeof sprite.getBounds === 'function'); + const hoveredTrap = + hoveredTrapSprites.length > 0 || traps.some((trap) => trap.x === hex.x && trap.y === hex.y); + const hoveredDropSprites = drops + .filter((drop) => drop.x === hex.x && drop.y === hex.y && !drop.pickedUp) + .map((drop) => drop.display) + .filter((sprite) => sprite.exists && typeof sprite.getBounds === 'function'); + const hoveredDrop = hoveredDropSprites.length > 0 || Boolean(hex.drop); + const hoveredRevealSprites = [...hoveredTrapSprites, ...hoveredDropSprites]; + const hoveredRevealCreature = hoveredCreature instanceof Creature ? hoveredCreature : undefined; + const hoveredNonActiveCreature = + hoveredCreature && hoveredCreature !== activeCreature ? hoveredCreature : undefined; + + // Exception 1/2: reveal hovered non-active target when no ability is selected. + // Skip when a trap/drop is also on the hex ? Exception 3 handles that case + // and must also xray the creature standing on the trap. + if (hoveredNonActiveCreature && noAbilitySelected && !hoveredTrap && !hoveredDrop) { + hoveredNonActiveCreature.hexagons.forEach((hoveredHex) => + hoveredHex.ghostOverlap(hoveredNonActiveCreature), + ); + hoveredNonActiveCreature.xray(false); + return; + } + + // Exception 3: reveal hovered traps and drops regardless of movement reachability. + if (hoveredTrap || hoveredDrop) { + if (hoveredRevealCreature) { + hoveredRevealCreature.hexagons.forEach((hoveredHex) => + hoveredHex.ghostOverlap(hoveredRevealCreature), + ); + hoveredRevealCreature.xray(false); + } + + if (hoveredRevealSprites.length === 0) { + hex.ghostOverlap(); + return; + } + + const revealReferences = hoveredRevealSprites.map((sprite) => { + return { + sprite, + grp: this.creatureGroup, + } as unknown as Creature; + }); + if (hoveredRevealCreature) { + revealReferences.push(hoveredRevealCreature); + } + + this.game.creatures.forEach((candidate) => { + if (!(candidate instanceof Creature)) { + return; + } + if (candidate === hoveredRevealCreature || candidate === activeCreature) { + return; + } + if (!candidate.sprite || typeof candidate.sprite.getBounds !== 'function') { + return; + } + + // Always xray a creature sitting directly on the hovered trap/drop hex + // (its sprite bounds may not intersect the trap sprite, e.g. tall Abolished + // standing on a small bonfire flame). + const isOnTrapHex = + candidate !== activeCreature && + candidate.hexagons.some((h) => h.x === hex.x && h.y === hex.y); + + if (!isOnTrapHex) { + const candidateBounds = candidate.sprite.getBounds(); + const overlapsReveal = hoveredRevealSprites.some((sprite) => { + const revealBounds = sprite.getBounds(); + return !( + candidateBounds.right <= revealBounds.left || + candidateBounds.left >= revealBounds.right || + candidateBounds.bottom <= revealBounds.top || + candidateBounds.top >= revealBounds.bottom + ); + }); + if (!overlapsReveal) { + return; + } + } + + candidate.xray( + true, + revealReferences.length === 1 ? revealReferences[0] : revealReferences, + ); + }); + return; + } + + if (referenceHexes?.length) { + referenceHexes.forEach((item) => item.ghostOverlap()); + } else { + hex.ghostOverlap(); + } + + // Default: keep active creature visible through obstructions. + activeCreature.hexagons.forEach((activeHex) => activeHex.ghostOverlap(activeCreature)); + activeCreature.xray(false); + } + + /** + * Gets a line of hexes given a start point and a direction The result is an array + * of hexes, starting from the start point's hex, and extending out in a straight line. + * If the coordinate is erroneous, returns an empty array. + * + * @param {number} x - Coordinate of start hex. + * @param {number} y - Coordinate of start hex. + * @param {Direction} dir - Direction of the line. + * @param {boolean} flipped - Flip the direction. + * @returns {Hex[]} Hexes in the line. + */ + getHexLine(x: number, y: number, dir: Direction, flipped: boolean): Hex[] { + switch (dir) { + case Direction.UpRight: + return this.getHexMap(x, y - 8, 0, flipped, matrices.diagonalup).reverse(); + case Direction.Right: + return this.getHexMap(x, y, 0, flipped, matrices.straitrow); + case Direction.DownRight: + return this.getHexMap(x, y, 0, flipped, matrices.diagonaldown); + case Direction.DownLeft: + return this.getHexMap(x, y, -4, flipped, matrices.diagonalup); + case Direction.Left: + return this.getHexMap(x, y, 0, !flipped, matrices.straitrow); + case Direction.UpLeft: + return this.getHexMap(x, y - 8, -4, flipped, matrices.diagonaldown).reverse(); + default: + return []; + } + } + + cleanHex(hex: Hex) { + const activeCreature = this.game.activeCreature; + const isActiveCreatureHex = + activeCreature && hex.creature instanceof Creature && hex.creature.id === activeCreature.id; + + if (isActiveCreatureHex) { + // Preserve the active creature's persistent display/overlay classes so any + // hover preview or path cleanup cannot restart the glow state. + hex.cleanDisplayVisualState('adj hover dashed shrunken deadzone hidden'); + hex.cleanOverlayVisualState( + 'reachable weakDmg moveto selected hover ownCreatureHexShade h_player0 h_player1 h_player2 h_player3', + ); + return; + } + + hex.cleanDisplayVisualState(); + hex.cleanOverlayVisualState(); + } + + restoreReachableHexVisual(hex: Hex) { + if (!hex.reachable || !this.game.activeCreature) { + return; + } + + if (this.lastQueryOpt?.targeting) { + hex.overlayVisualState('reachable h_player' + this.game.activeCreature.team); + } + } + + /** + * Clear transient hover visuals for a single hex without rebuilding the whole + * query state. This is used during mouse transitions between adjacent hexes. + */ + clearTransientHexHoverVisual(hex: Hex) { + if (!this.lastQueryOpt) { + return; + } + + const activeCreature = this.game.activeCreature; + const isActiveCreatureHex = + activeCreature && hex.creature instanceof Creature && hex.creature.id === activeCreature.id; + + if (isActiveCreatureHex) { + // Clean only transient display state so the creature's persistent `creature` + // and `playerN` classes stay intact. That avoids reintroducing outline hexes + // while still removing hover-added dashed/path state. + hex.cleanDisplayVisualState('adj hover dashed shrunken deadzone hidden'); + hex.cleanOverlayVisualState('hover h_player0 h_player1 h_player2 h_player3'); + return; + } + + this.cleanHex(hex); + this.restoreReachableHexVisual(hex); + + // Restore the base query display class after transient hover cleanup so + // sideways cursor movement does not leave a lighter/transparent-looking hex. + const queryOpt = this.lastQueryOpt as any; + if (hex.creature instanceof Creature) { + return; + } + + if (Array.isArray(queryOpt.hexesDashed) && queryOpt.hexesDashed.indexOf(hex) !== -1) { + hex.displayVisualState('dashed'); + hex.grid.displayHexesGroup.bringToTop(hex.display); + return; + } + + if ( + queryOpt.restoreAdjOnTransientCleanup && + Array.isArray(queryOpt.hexes) && + queryOpt.hexes.indexOf(hex) !== -1 + ) { + hex.displayVisualState('adj'); + } + } + + clearTransientCreatureHoverVisual(creature: Creature) { + if (!this.lastQueryOpt) { + return; + } + + creature.hexagons.forEach((hex) => { + this.clearTransientHexHoverVisual(hex); + }); + } + + /** + * Update overlay hexes with creature positions + */ + updateDisplay() { + const activeCreature = this.game.activeCreature; + this.allhexes.forEach((hex) => { + const isActiveHex = + activeCreature && hex.creature instanceof Creature && hex.creature.id === activeCreature.id; + if (isActiveHex) { + // Preserve 'active creature playerN' to keep the glowInterval phase + // stable; only strip transient hover/query classes. + hex.cleanDisplayVisualState('adj hover dashed shrunken deadzone hidden'); + hex.cleanOverlayVisualState( + 'hover selected reachable weakDmg moveto ownCreatureHexShade h_player0 h_player1 h_player2 h_player3', + ); + } else { + hex.cleanDisplayVisualState(); + hex.cleanOverlayVisualState(); + } + }); + + this.hexes.forEach((hex) => { + hex.forEach((item) => { + if (item.creature instanceof Creature) { + if (item.creature.id == activeCreature.id) { + // Only add if not already present to avoid stacking duplicates + // and to avoid resetting overlay.alpha via updateStyle(). + if (!item.overlayClasses.includes('active')) { + item.overlayVisualState(`active creature player${item.creature.team}`); + } + } + } + }); + }); + } + + /** + * Test if hex exists + * @param {{x:number, y:number}} position - Coordinates to test + */ + hexExists({ x, y }: { x: number; y: number }): boolean { + if (y >= 0 && y < this.hexes.length) { + if (x >= 0 && x < this.hexes[y].length) { + return true; + } + } + + return false; + } + + /** + * Test if hex exists inside array of hexes + * @param {Hex} hex - Hex to look for + * @param {Hex[]} hexArray - Array of hexes to look for hex in + */ + isHexIn(hex, hexArray) { + for (let i = 0, len = hexArray.length; i < len; i++) { + if (hexArray[i].x == hex.x && hexArray[i].y == hex.y) { + return true; + } + } + + return false; + } + + /** + * @param {number} x - Integer: Start position + * @param {number} y - Integer: Start position + * @param {number} distance - Integer: Distance from the start position + * @param {number} size - Integer: Creature size + * @param {number} id - Integer: Creature ID + * @returns {Hex[]} Set of the reachable hexes + */ + getMovementRange(x, y, distance, size, id) { + // Populate distance (hex.g) in hexes by asking an impossible + // destination to test all hexagons + this.cleanReachable(); // If not pathfinding will bug + this.cleanPathAttr(true); // Erase all pathfinding data + search(this.hexes[y][x], new Hex(-2, -2, null, this.game), size, id, this.game.grid); + + // Gather all the reachable hexes + const hexes: Hex[] = []; + this.forEachHex((hex) => { + // If not Too far or Impossible to reach + if (hex.g <= distance && hex.g != 0) { + hexes.push(this.hexes[hex.y][hex.x]); + } + }); + + return arrayUtils.extendToLeft(hexes, size, this.game.grid); + } + + /** + * @param {number} x - Integer : Start position + * @param {number} y - Integer : Start position + * @param {number} distance - Integer : Distance from the start position + * @param {number} size - Integer : Creature size + * @param {number} id - Integer : Creature ID + * @returns {Hex[]} Set of the reachable hexes + */ + getFlyingRange(x, y, distance, size, id) { + // Gather all the reachable hexes + let hexes = this.hexes[y][x].adjacentHex(distance); + + hexes = hexes.filter((hex) => hex.isWalkable(size, id, true)); + + return arrayUtils.extendToLeft(hexes, size, this.game.grid); + } + + /** + * @param {number} originx - Integer : Position of the array on the grid + * @param {number} originy - Integer : Position of the array on the grid + * @param {number} offsetx - Integer : offset flipped for flipped players + * @param {boolean} flipped - If player is flipped or not + * @param {number[]} array - 2-dimensions Array containing 0 or 1 (boolean) + * @returns {Hex[]} Set of corresponding hexes + */ + getHexMap( + originx: number, + originy: number, + offsetx: number, + flipped: boolean, + array: AugmentedMatrix, + ): Hex[] { + // Heavy logic in here + const hexes: Hex[] = []; + + array = array.slice(0) as AugmentedMatrix; // Copy to not modify original + originx += flipped ? 1 - array[0].length - offsetx : -1 + offsetx; + + for (let y = 0, len = array.length; y < len; y++) { + array[y] = array[y].slice(0); // Copy row + + // Translating to flipped pattern + if (flipped && y % 2 != 0) { + // Odd rows + array[y].push(0); + } + + // Translating even to odd row pattern + array[y].unshift(0); + if (originy % 2 != 0 && y % 2 != 0) { + // Even rows + if (flipped) { + array[y].pop(); // Remove last element as the array will be parse backward + } else { + array[y].splice(0, 1); // Remove first element + } + } + + // Gathering hexes + for (let x = 0; x < array[y].length; x++) { + if (array[y][x]) { + const xfinal = flipped ? array[y].length - 1 - x : x; // Parse the array backward for flipped player + if (this.hexExists({ y: originy + y, x: originx + xfinal })) { + hexes.push(this.hexes[originy + y][originx + xfinal]); + } + } + } + } + + return hexes; + } + + showGrid(val) { + this.forEachHex((hex) => { + if (hex.creature) { + hex.creature.xray(val); + } + + if (hex.drop) { + return; + } + + if (val) { + hex.displayVisualState('showGrid'); + } else { + hex.cleanDisplayVisualState('showGrid'); + } + }); + } + + showMovementRange(creature) { + const hexes = this.findCreatureMovementHexes(creature); + + // Block all hexes + this.forEachHex((hex) => { + hex.unsetReachable(); + }); + + // Set reachable the given hexes + hexes.forEach((hex) => { + hex.setReachable(); + // Show smaller outlined hexagons for movement range visualization + hex.displayVisualState('dashed shrunken'); + }); + } + + showCurrentCreatureMovementInOverlay(creature) { + if (!creature) { + return; + } + //lastQueryOpt is same thing as used in redoQuery + this.lastQueryOpt?.hexes?.forEach((hex) => { + hex.overlayVisualState('reachable h_player' + creature.team); + }); + } + + findCreatureMovementHexes(creature) { + if (creature.movementType() === 'flying') { + return this.getFlyingRange( + creature.x, + creature.y, + creature.stats.movement, + creature.size, + creature.id, + ); + } else { + return this.getMovementRange( + creature.x, + creature.y, + creature.stats.movement, + creature.size, + creature.id, + ); + } + } + + selectHexUp() { + if (!this.hexExists({ y: this.selectedHex.y - 1, x: this.selectedHex.x })) { + return; + } + + if (this.selectedHex) { + this.clearHexViewAlterations(); + this.selectedHex.onHoverOffFn(this.selectedHex); + } + + const hex = this.hexes[this.selectedHex.y - 1][this.selectedHex.x]; + this.selectedHex = hex; + hex.onSelectFn(hex); + } + + selectHexDown() { + if (!this.hexExists({ y: this.selectedHex.y + 1, x: this.selectedHex.x })) { + return; + } + + if (this.selectedHex) { + this.clearHexViewAlterations(); + this.selectedHex.onHoverOffFn(this.selectedHex); + } + + const hex = this.hexes[this.selectedHex.y + 1][this.selectedHex.x]; + this.selectedHex = hex; + hex.onSelectFn(hex); + } + + selectHexLeft() { + if (!this.hexExists({ y: this.selectedHex.y, x: this.selectedHex.x - 1 })) { + return; + } + + if (this.selectedHex) { + this.clearHexViewAlterations(); + this.selectedHex.onHoverOffFn(this.selectedHex); + } + + const hex = this.hexes[this.selectedHex.y][this.selectedHex.x - 1]; + this.selectedHex = hex; + hex.onSelectFn(hex); + } + + selectHexRight() { + if (!this.hexExists({ y: this.selectedHex.y, x: this.selectedHex.x + 1 })) { + return; + } + + if (this.selectedHex) { + this.clearHexViewAlterations(); + this.selectedHex.onHoverOffFn(this.selectedHex); + } + + const hex = this.hexes[this.selectedHex.y][this.selectedHex.x + 1]; + this.selectedHex = hex; + hex.onSelectFn(hex); + } + + confirmHex() { + if (this.game.freezedInput) { + return; + } + + this.selectedHex.onConfirmFn(this.selectedHex); + } + + /** + * Reset the visual state for hexes that might have been hovered, dashed, etc. + * Note: I'm not entirely sure what this code is doing. + */ + clearHexViewAlterations() { + this.cancelDeferredActiveHexDashedClear(); + + if (!this.selectedHex) { + return; + } + + this.redoLastQuery(); + // Clear Xray. + this.xray(new Hex(-1, -1, null, this.game)); + // Clear Xray Queue. + this.game.UI.xrayQueue(-1); + } + + private _rowDepthBaseIndex(y: number) { + // Leave room within each row for shared layer bands instead of forcing + // every renderable to compete in a single linear ordering. + return y * ROW_DEPTH_STRIDE; + } + + getDepthAtBand(y: number, band: DepthBand, slot = 0) { + return this._rowDepthBaseIndex(y) + DEPTH_BAND[band] + slot; + } + + assignSpriteDepthBand(sprite: Phaser.Sprite | undefined, y: number, band: DepthBand, slot = 0) { + if (!sprite) { + return; + } + + sprite.z = this.getDepthAtBand(y, band, slot); + } + + orderCreatureZ() { + const creatures = this.game.creatures; + const traps = this.game.traps; + const drops = this.game.drops; + + for (let y = 0, leny = this.hexes.length; y < leny; y++) { + let groundTrapIndex = 0; + let underEffectIndex = 0; + let unitIndex = 0; + let dropIndex = 0; + let overEffectIndex = 0; + let volumetricTrapIndex = 0; + + for (let i = 0, len = creatures.length; i < len; i++) { + if (creatures[i] && creatures[i].y == y) { + creatures[i].grp.z = this.getDepthAtBand(y, 'UNITS', unitIndex++); + } + } + + for (let i = 0, len = traps.length; i < len; i++) { + const trap = traps[i]; + if (!trap || trap.y != y) { + continue; + } + + const occupyingCreature = creatures.find((candidate) => { + if (!(candidate instanceof Creature)) { + return false; + } + return candidate.hexagons?.some( + (hexagon) => hexagon.x === trap.x && hexagon.y === trap.y, + ); + }) as Creature | undefined; + const occupiedByOwnerCreature = + occupyingCreature instanceof Creature && occupyingCreature === trap.ownerCreature; + const shouldRenderOverUnits = Boolean( + trap.typeOver || (trap.type === 'bonfire-spring' && occupiedByOwnerCreature), + ); + if (typeof trap.syncTypeOverVisual === 'function') { + trap.syncTypeOverVisual(shouldRenderOverUnits); + } else if (typeof trap.setTypeOver === 'function') { + trap.setTypeOver(shouldRenderOverUnits, false); + } + + const visualSprites = + typeof trap.getVisualSprites === 'function' ? trap.getVisualSprites() : []; + for (let j = 0, visualLen = visualSprites.length; j < visualLen; j++) { + const sprite = visualSprites[j]; + if (!sprite) { + continue; + } + const isVolumetricParent = + sprite.parent === this.trapOverGroup || sprite.parent === this.creatureGroup; + const isCreatureLayerVolumetric = sprite.parent === this.creatureGroup; + if (isCreatureLayerVolumetric) { + const zReferenceCreature = + (occupyingCreature as Creature | undefined) ?? + ((trap.typeOver && trap.ownerCreature instanceof Creature && trap.ownerCreature) || + undefined); + if (zReferenceCreature?.grp && typeof zReferenceCreature.grp.z === 'number') { + // Keep feet-volumetric tightly coupled to the occupied creature instead of + // jumping to a global volumetric slot that can overlap unrelated units. + sprite.z = zReferenceCreature.grp.z + (0.5 + volumetricTrapIndex++ * 0.01); + } else { + sprite.z = this.getDepthAtBand(y, 'TRAP_VOLUMETRIC', volumetricTrapIndex++); + } + continue; + } + + if (sprite === trap.display) { + const band = isVolumetricParent ? 'TRAP_VOLUMETRIC' : 'TRAP_GROUND'; + const slot = band === 'TRAP_VOLUMETRIC' ? volumetricTrapIndex++ : groundTrapIndex++; + this.assignSpriteDepthBand(sprite, y, band, slot); + continue; + } + + const band = isVolumetricParent ? 'TRAP_VOLUMETRIC' : 'EFFECT_UNDER_UNITS'; + const slot = band === 'TRAP_VOLUMETRIC' ? volumetricTrapIndex++ : underEffectIndex++; + this.assignSpriteDepthBand(sprite, y, band, slot); + } + + if (trap.displayOver) { + this.assignSpriteDepthBand(trap.displayOver, y, 'TRAP_VOLUMETRIC', volumetricTrapIndex++); + } + } + + for (let i = 0, len = drops.length; i < len; i++) { + if (drops[i] && drops[i].y == y && drops[i].display) { + this.assignSpriteDepthBand(drops[i].display, y, 'DROPS', dropIndex++); + } + } + + if (this.materialize_overlay && this.materialize_overlay.posy == y) { + this.assignSpriteDepthBand( + this.materialize_overlay, + y, + 'EFFECT_OVER_UNITS', + overEffectIndex++, + ); + } + + if (this.secondary_overlay && this.secondary_overlay.posy == y) { + this.assignSpriteDepthBand( + this.secondary_overlay, + y, + 'EFFECT_OVER_UNITS', + overEffectIndex++, + ); + } + } + + this.trapGroup.sort('z', -1); + this.creatureGroup.sort('z', -1); + this.dropGroup.sort('z', -1); + this.trapOverGroup.sort('z', -1); + } + + /** + * Immediately removes all xray effects without re-applying ghostOverlap for + * any creature. Use this at turn boundaries so the old active creature's + * obstructors fade to zero cleanly before the next unit's ghostOverlap runs. + */ + clearAllXray(immediate = false) { + this.lastXrayHex = null; + this.lastXrayHexes = null; + this.game.creatures.forEach((c) => { + if (!(c instanceof Creature)) { + return; + } + if (immediate) { + c.clearXrayImmediately(); + return; + } + c.xray(false); + }); + } + + /** + * Re-evaluate which creatures visually obstruct the active creature and + * update their xray state accordingly. Called after every hex step during + * movement so the effect stays correct as the unit changes rows. + */ + refreshActiveCreatureXray() { + if (this.game.animations?.xraySuppressed) return; + const { activeCreature } = this.game; + if (!(activeCreature instanceof Creature)) return; + this.game.creatures.forEach((c) => { + if (c instanceof Creature) c.xray(false); + }); + activeCreature.hexagons.forEach((h) => h.ghostOverlap(activeCreature)); + } + + //******************// + //Shortcut functions// + //******************// + + /** + * Execute f for each hexes + * @param {function} func - Function to execute + * @deprecated use this.allhexes.forEach(fn) + */ + forEachHex(func: (hex: Hex) => void) { + this.hexes.forEach((hex) => { + hex.forEach(func); + }); + } + + /** + * Execute hex.cleanPathAttr() function for all the grid. Refer to the Hex class for more info + * @param {boolean} includeG - Include hex.g attribute + * @deprecated use this.allhexes.forEach(hex => hex.cleanPathAttr(includeG)) + */ + cleanPathAttr(includeG) { + this.allhexes.forEach((hex) => hex.cleanPathAttr(includeG)); + } + + /** + * Execute hex.setReachable() function for all the grid. Refer to the Hex class for more info + * @deprecated use this.allhexes.forEach(hex => hex.setReachable()) + */ + cleanReachable() { + this.allhexes.forEach((hex) => hex.setReachable()); + } + + /** + * Draw a preview of the creature at the given coordinates + * @param {{x:number, y:number}} pos - Coordinates {x,y} + * @param {object} creatureData - Object containing info from the database (game.retrieveCreatureStats) + */ + previewCreature(pos, creatureData, player, secondary = false) { + const game = this.game; + const clearPreviewOverlay = (preview, isSecondary = false) => { + if (!preview) { + return; + } + + if (isSecondary) { + if (this._flickerTweenSecondary) { + this._flickerTweenSecondary.stop(true); + this._flickerTweenSecondary = undefined; + } + } else { + if (this._flickerTween) { + this._flickerTween.stop(true); + this._flickerTween = undefined; + } + } + + preview.alpha = 0; + + if (preview._previewPos === undefined) { + return; + } + + for (let i = 0, prevSize = preview._previewSize; i < prevSize; i++) { + const prevHex = this.hexes[preview._previewPos.y]?.[preview._previewPos.x - i]; + if (prevHex && prevHex.creature !== game.activeCreature) { + this.cleanHex(prevHex); + this.restoreReachableHexVisual(prevHex); + } + } + + preview._previewPos = undefined; + }; + + const shouldShowPlacementPreview = + !game.isReplayInProgress && + !game.botController?.isBotTurn() && + !!game.activeCreature && + game.activePlayer === game.activeCreature.player; + + if (!shouldShowPlacementPreview) { + clearPreviewOverlay(secondary ? this.secondary_overlay : this.materialize_overlay, secondary); + return; + } + + const targetHex = this.hexes[pos.y]?.[pos.x]; + const queryHexes = this.lastQueryOpt?.hexes; + if ( + !targetHex || + (Array.isArray(queryHexes) && + queryHexes.length > 0 && + (!targetHex.reachable || queryHexes.indexOf(targetHex) === -1)) + ) { + clearPreviewOverlay(secondary ? this.secondary_overlay : this.materialize_overlay, secondary); + return; + } + + const hex = this.hexes[pos.y][pos.x - (creatureData.size - 1)]; + const cardboard = + creatureData.type == '--' ? creatureData.name + ' ' + player.color : creatureData.name; + + if (!secondary) { + if (!this.materialize_overlay) { + // If sprite does not exist + // Adding sprite + this.materialize_overlay = this.creatureGroup.create(0, 0, cardboard); + this.materialize_overlay.anchor.setTo(0.5, 1); + this.materialize_overlay.posy = pos.y; + } else { + this.materialize_overlay.loadTexture(cardboard); + if (this.materialize_overlay.posy != pos.y) { + this.materialize_overlay.posy = pos.y; + this.orderCreatureZ(); + } + } + } else { + if (!this.secondary_overlay) { + // If sprite does not exists + // Adding sprite + this.secondary_overlay = this.creatureGroup.create(0, 0, cardboard); + this.secondary_overlay.anchor.setTo(0.5, 1); + this.secondary_overlay.posy = pos.y; + } else { + this.secondary_overlay.loadTexture(cardboard); + if (this.secondary_overlay.posy != pos.y) { + this.secondary_overlay.posy = pos.y; + this.orderCreatureZ(); + } + } + } + + const preview = secondary ? this.secondary_overlay : this.materialize_overlay; + + // Placing sprite + preview.x = + hex.displayPos.x + + (!player.flipped + ? creatureData.display['offset-x'] + : HEX_WIDTH_PX * creatureData.size - + preview.texture.width - + creatureData.display['offset-x']) + + preview.texture.width / 2; + preview.y = hex.displayPos.y + creatureData.display['offset-y'] + preview.texture.height; + preview.alpha = 0.5; + + if (player.flipped) { + preview.scale.setTo(-1, 1); + } else { + preview.scale.setTo(1, 1); + } + + const flickering = game.Phaser.add + .tween(preview) + .to( + { + alpha: 0.15, + }, + 777, + Phaser.Easing.Linear.None, + ) + .yoyo(true) + .repeat(-1) + .start(); + if (!secondary) { + if (this._flickerTween) { + // Stop animations that are about to be orphaned #2698 + this._flickerTween.stop(true); + } + this._flickerTween = flickering; + } else { + if (this._flickerTweenSecondary) { + this._flickerTweenSecondary.stop(true); + } + this._flickerTweenSecondary = flickering; + } + + // Clean overlay from the previous preview position before painting the new one. + // Without this, every hex the cursor passes over accumulates the creature-selected + // overlay, making it look like multiple creatures have been materialized at once. + // After cleaning, restore the reachable visual state so the hex stays filled like + // the rest of the spawn-range hexes (redoLastQuery may have already restored it). + if (preview._previewPos !== undefined) { + for (let i = 0, prevSize = preview._previewSize; i < prevSize; i++) { + const prevHex = this.hexes[preview._previewPos.y]?.[preview._previewPos.x - i]; + if (prevHex) { + if (prevHex.creature === game.activeCreature) { + continue; + } + this.cleanHex(prevHex); + this.restoreReachableHexVisual(prevHex); + } + } + } + + for (let i = 0, size = creatureData.size; i < size; i++) { + const hexInstance = this.hexes[pos.y][pos.x - i]; + this.cleanHex(hexInstance); + hexInstance.overlayVisualState('creature selected player' + game.activeCreature.team); + } + + preview._previewPos = { x: pos.x, y: pos.y }; + preview._previewSize = creatureData.size; + } + + /** + * Internal debugging method to log and visually highlight (in blue) an array + * of hexes. + * @param {Hex[]} hexes - Hexes to log and visually highlight. + */ + __debugHexes(hexes: Hex[]) { + if (DEBUG) { + console.debug({ hexes }, hexes.map((hex) => hex.coord).join(', ')); + hexes.forEach((hex) => hex.displayVisualState('creature selected player1')); + } + } + + fadeOutTempCreature(target = this.materialize_overlay, durationMs = 500) { + // TODO: factor out this function. Use either Creature.creatureSprite + // or the existing temp creature created by /src/abilities/Dark-Priest.js + if (target) { + target.alpha = 0.5; + this.game.Phaser.add + .tween(target) + .to( + { + alpha: 0, + }, + durationMs, + Phaser.Easing.Linear.None, + ) + .start(); + } + } +} diff --git a/src/utility/query_footprint.ts b/src/utility/query_footprint.ts new file mode 100644 index 000000000..58d51b4fd --- /dev/null +++ b/src/utility/query_footprint.ts @@ -0,0 +1,49 @@ +import type { Hex } from './hex'; + +type HexGridLike = { + hexes: Array>; +}; + +export function getQueryFootprintHexes( + grid: HexGridLike, + hex: Hex, + size = 1, + flipped = false, + id = 0, +): Hex[] { + if (!hex || size < 1) { + return []; + } + + const row = grid.hexes[hex.y]; + if (!row) { + return []; + } + + let x = hex.x; + const offset = flipped ? size - 1 : 0; + const mult = flipped ? 1 : -1; + + for (let i = 0; i < size; i++) { + const candidateX = x + offset - i * mult; + const candidate = row[candidateX]; + if (!candidate) { + continue; + } + + if (candidate.isWalkable(size, id)) { + x += offset - i * mult; + break; + } + } + + const footprint: Hex[] = []; + for (let i = 0; i < size; i++) { + const occupiedHex = row[x - i]; + if (occupiedHex) { + footprint.push(occupiedHex); + } + } + + return footprint; +}