diff --git a/client/apps/game/src/hooks/store/_three-store.ts b/client/apps/game/src/hooks/store/_three-store.ts index eecad2d02..7ec0178e3 100644 --- a/client/apps/game/src/hooks/store/_three-store.ts +++ b/client/apps/game/src/hooks/store/_three-store.ts @@ -1,5 +1,5 @@ import { StructureInfo } from "@/three/types"; -import { BuildingType, HexPosition, ID, Position } from "@bibliothecadao/eternum"; +import { BuildingType, HexPosition, HexTileInfo, ID, Position } from "@bibliothecadao/eternum"; export interface ThreeStore { navigationTarget: HexPosition | null; @@ -35,7 +35,7 @@ export interface ThreeStore { interface ArmyActions { hoveredHex: HexPosition | null; - travelPaths: Map; + travelPaths: Map; selectedEntityId: ID | null; } diff --git a/client/apps/game/src/three/helpers/pathfinding.ts b/client/apps/game/src/three/helpers/pathfinding.ts index 1bbf5f8ca..6de2afeb8 100644 --- a/client/apps/game/src/three/helpers/pathfinding.ts +++ b/client/apps/game/src/three/helpers/pathfinding.ts @@ -1,5 +1,5 @@ import { Position } from "@/types/position"; -import { getNeighborOffsets, HexPosition } from "@bibliothecadao/eternum"; +import { BiomeType, getNeighborOffsets, HexPosition } from "@bibliothecadao/eternum"; interface Node { col: number; @@ -10,17 +10,26 @@ interface Node { } export function findShortestPath( - startPosition: Position, - endPosition: Position, - exploredTiles: Map>, + oldPosition: Position, + newPosition: Position, + exploredTiles: Map>, + structureHexes: Map>, + armyHexes: Map>, + maxDistance: number, ): Position[] { + // Check if target is within maximum distance before starting pathfinding + const oldPos = oldPosition.getNormalized(); + const newPos = newPosition.getNormalized(); + const initialDistance = getHexDistance({ col: oldPos.x, row: oldPos.y }, { col: newPos.x, row: newPos.y }); + if (initialDistance > maxDistance) { + return []; // Return empty path if target is too far + } + const openSet: Node[] = []; const closedSet = new Set(); - const start = startPosition.getNormalized(); - const end = endPosition.getNormalized(); const startNode: Node = { - col: start.x, - row: start.y, + col: oldPos.x, + row: oldPos.y, f: 0, g: 0, }; @@ -32,7 +41,7 @@ export function findShortestPath( let current = openSet.reduce((min, node) => (node.f < min.f ? node : min), openSet[0]); // Reached end - if (current.col === end.x && current.row === end.y) { + if (current.col === newPos.x && current.row === newPos.y) { return reconstructPath(current); } @@ -46,13 +55,28 @@ export function findShortestPath( const neighborCol = current.col + i; const neighborRow = current.row + j; + // Skip if path is getting too long + if (current.g + 1 > maxDistance) { + continue; + } + // Skip if not explored or already in closed set if (!exploredTiles.get(neighborCol)?.has(neighborRow) || closedSet.has(`${neighborCol},${neighborRow}`)) { continue; } + // skip if the neighbor is a structure hex + if (structureHexes.get(neighborCol)?.has(neighborRow)) { + continue; + } + + // skip if the neighbor is an army hex and not the end hex (where the unit is going) + if (armyHexes.get(neighborCol)?.has(neighborRow) && !(neighborCol === newPos.x && neighborRow === newPos.y)) { + continue; + } + const g = current.g + 1; - const h = getHexDistance({ col: neighborCol, row: neighborRow }, { col: end.x, row: end.y }); + const h = getHexDistance({ col: neighborCol, row: neighborRow }, { col: newPos.x, row: newPos.y }); const f = g + h; const neighborNode: Node = { diff --git a/client/apps/game/src/three/managers/army-manager.ts b/client/apps/game/src/three/managers/army-manager.ts index 4531f4e47..3d5d3ad71 100644 --- a/client/apps/game/src/three/managers/army-manager.ts +++ b/client/apps/game/src/three/managers/army-manager.ts @@ -1,14 +1,22 @@ import { useAccountStore } from "@/hooks/store/use-account-store"; import { GUIManager } from "@/three/helpers/gui-manager"; -import { findShortestPath } from "@/three/helpers/pathfinding"; import { isAddressEqualToAccount } from "@/three/helpers/utils"; import { ArmyModel } from "@/three/managers/army-model"; -import { Biome } from "@/three/managers/biome"; import { LabelManager } from "@/three/managers/label-manager"; import { Position } from "@/types/position"; -import { BiomeType, ContractAddress, FELT_CENTER, ID, orders } from "@bibliothecadao/eternum"; +import { + Biome, + BiomeType, + configManager, + ContractAddress, + FELT_CENTER, + ID, + orders, + ResourcesIds, +} from "@bibliothecadao/eternum"; import * as THREE from "three"; import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer"; +import { findShortestPath } from "../helpers/pathfinding"; import { ArmyData, ArmySystemUpdate, MovingArmyData, MovingLabelData, RenderChunkSize } from "../types"; import { calculateOffset, getHexForWorldPosition, getWorldPositionForHex } from "../utils"; @@ -28,23 +36,15 @@ export class ArmyManager { private currentChunkKey: string | null = "190,170"; private renderChunkSize: RenderChunkSize; private visibleArmies: ArmyData[] = []; - private biome: Biome; private armyPaths: Map = new Map(); - private exploredTiles: Map>; private entityIdLabels: Map = new Map(); - constructor( - scene: THREE.Scene, - renderChunkSize: { width: number; height: number }, - exploredTiles: Map>, - ) { + constructor(scene: THREE.Scene, renderChunkSize: { width: number; height: number }) { this.scene = scene; this.armyModel = new ArmyModel(scene); this.scale = new THREE.Vector3(0.3, 0.3, 0.3); this.labelManager = new LabelManager("textures/army_label.png", 1.5); this.renderChunkSize = renderChunkSize; - this.biome = new Biome(); - this.exploredTiles = exploredTiles; this.onMouseMove = this.onMouseMove.bind(this); this.onRightClick = this.onRightClick.bind(this); @@ -124,7 +124,12 @@ export class ArmyManager { } } - async onUpdate(update: ArmySystemUpdate) { + async onUpdate( + update: ArmySystemUpdate, + armyHexes: Map>, + structureHexes: Map>, + exploredTiles: Map>, + ) { await this.armyModel.loadPromise; const { entityId, hexCoords, owner, battleId, currentHealth, order } = update; @@ -146,12 +151,12 @@ export class ArmyManager { } } - const position = new Position({ x: hexCoords.col, y: hexCoords.row }); + const newPosition = new Position({ x: hexCoords.col, y: hexCoords.row }); if (this.armies.has(entityId)) { - this.moveArmy(entityId, position); + this.moveArmy(entityId, newPosition, armyHexes, structureHexes, exploredTiles); } else { - this.addArmy(entityId, position, owner, order); + this.addArmy(entityId, newPosition, owner, order); } return false; } @@ -182,7 +187,7 @@ export class ArmyManager { // this.armyModel.dummyObject.updateMatrix(); // Determine model type based on order or other criteria const { x, y } = army.hexCoords.getContract(); - const biome = this.biome.getBiome(x, y); + const biome = Biome.getBiome(x, y); if (biome === BiomeType.Ocean || biome === BiomeType.DeepOcean) { this.armyModel.assignModelToEntity(army.entityId, "boat"); } else { @@ -283,7 +288,7 @@ export class ArmyManager { // Determine model type based on order or other criteria const { x, y } = hexCoords.getContract(); - const biome = this.biome.getBiome(x, y); + const biome = Biome.getBiome(x, y); if (biome === BiomeType.Ocean || biome === BiomeType.DeepOcean) { this.armyModel.assignModelToEntity(entityId, "boat"); } else { @@ -303,16 +308,26 @@ export class ArmyManager { this.renderVisibleArmies(this.currentChunkKey!); } - public moveArmy(entityId: ID, hexCoords: Position) { + public moveArmy( + entityId: ID, + hexCoords: Position, + armyHexes: Map>, + structureHexes: Map>, + exploredTiles: Map>, + ) { const armyData = this.armies.get(entityId); if (!armyData) return; - const { x: startX, y: startY } = armyData.hexCoords.getNormalized(); - const { x: targetX, y: targetY } = hexCoords.getNormalized(); + const startPos = armyData.hexCoords.getNormalized(); + const targetPos = hexCoords.getNormalized(); + + if (startPos.x === targetPos.x && startPos.y === targetPos.y) return; - if (startX === targetX && startY === targetY) return; + // todo: currently taking max stamina of paladin as max stamina but need to refactor + const maxTroopStamina = configManager.getTroopStaminaConfig(ResourcesIds.Paladin); + const maxHex = Math.floor(maxTroopStamina / configManager.getMinTravelStaminaCost()); - const path = findShortestPath(armyData.hexCoords, hexCoords, this.exploredTiles); + const path = findShortestPath(armyData.hexCoords, hexCoords, exploredTiles, structureHexes, armyHexes, maxHex); if (!path || path.length === 0) return; @@ -414,7 +429,7 @@ export class ArmyManager { } const { col, row } = getHexForWorldPosition({ x: position.x, y: position.y, z: position.z }); - const biome = this.biome.getBiome(col + FELT_CENTER, row + FELT_CENTER); + const biome = Biome.getBiome(col + FELT_CENTER, row + FELT_CENTER); if (biome === BiomeType.Ocean || biome === BiomeType.DeepOcean) { this.armyModel.assignModelToEntity(entityId, "boat"); } else { diff --git a/client/apps/game/src/three/managers/biome-colors.ts b/client/apps/game/src/three/managers/biome-colors.ts new file mode 100644 index 000000000..9bfb1f22e --- /dev/null +++ b/client/apps/game/src/three/managers/biome-colors.ts @@ -0,0 +1,21 @@ +import { BiomeType } from "@bibliothecadao/eternum"; +import * as THREE from "three"; + +export const BIOME_COLORS: Record = { + DeepOcean: new THREE.Color("#4a6b63"), + Ocean: new THREE.Color("#657d71"), + Beach: new THREE.Color("#d7b485"), + Scorched: new THREE.Color("#393131"), + Bare: new THREE.Color("#d1ae7f"), + Tundra: new THREE.Color("#cfd4d4"), + Snow: new THREE.Color("#cfd4d4"), + TemperateDesert: new THREE.Color("#ad6c44"), + Shrubland: new THREE.Color("#c1aa7f"), + Taiga: new THREE.Color("#292d23"), + Grassland: new THREE.Color("#6f7338"), + TemperateDeciduousForest: new THREE.Color("#6f7338"), + TemperateRainForest: new THREE.Color("#6f573e"), + SubtropicalDesert: new THREE.Color("#926338"), + TropicalSeasonalForest: new THREE.Color("#897049"), + TropicalRainForest: new THREE.Color("#8a714a"), +}; diff --git a/client/apps/game/src/three/managers/biome.ts b/client/apps/game/src/three/managers/biome.ts deleted file mode 100644 index ac488cf46..000000000 --- a/client/apps/game/src/three/managers/biome.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { Fixed, FixedTrait } from "@/utils/biome/fixed-point"; -import { noise as snoise } from "@/utils/biome/simplex-noise"; -import { Vec3 } from "@/utils/biome/vec3"; -import * as THREE from "three"; - -const MAP_AMPLITUDE = FixedTrait.fromInt(60n); -const MOISTURE_OCTAVE = FixedTrait.fromInt(2n); -const ELEVATION_OCTAVES = [ - FixedTrait.fromInt(1n), // 1 - FixedTrait.fromRatio(1n, 4n), // 0.25 - FixedTrait.fromRatio(1n, 10n), // 0.1 -]; -const ELEVATION_OCTAVES_SUM = ELEVATION_OCTAVES.reduce((a, b) => a.add(b), FixedTrait.ZERO); - -export enum BiomeType { - DeepOcean = "DeepOcean", - Ocean = "Ocean", - Beach = "Beach", - Scorched = "Scorched", - Bare = "Bare", - Tundra = "Tundra", - Snow = "Snow", - TemperateDesert = "TemperateDesert", - Shrubland = "Shrubland", - Taiga = "Taiga", - Grassland = "Grassland", - TemperateDeciduousForest = "TemperateDeciduousForest", - TemperateRainForest = "TemperateRainForest", - SubtropicalDesert = "SubtropicalDesert", - TropicalSeasonalForest = "TropicalSeasonalForest", - TropicalRainForest = "TropicalRainForest", -} - -export const BIOME_COLORS: Record = { - DeepOcean: new THREE.Color("#4a6b63"), - Ocean: new THREE.Color("#657d71"), - Beach: new THREE.Color("#d7b485"), - Scorched: new THREE.Color("#393131"), - Bare: new THREE.Color("#d1ae7f"), - Tundra: new THREE.Color("#cfd4d4"), - Snow: new THREE.Color("#cfd4d4"), - TemperateDesert: new THREE.Color("#ad6c44"), - Shrubland: new THREE.Color("#c1aa7f"), - Taiga: new THREE.Color("#292d23"), - Grassland: new THREE.Color("#6f7338"), - TemperateDeciduousForest: new THREE.Color("#6f7338"), - TemperateRainForest: new THREE.Color("#6f573e"), - SubtropicalDesert: new THREE.Color("#926338"), - TropicalSeasonalForest: new THREE.Color("#897049"), - TropicalRainForest: new THREE.Color("#8a714a"), -}; - -const LEVEL = { - DEEP_OCEAN: FixedTrait.fromRatio(25n, 100n), // 0.25 - OCEAN: FixedTrait.fromRatio(50n, 100n), // 0.5 - SAND: FixedTrait.fromRatio(53n, 100n), // 0.53 - FOREST: FixedTrait.fromRatio(60n, 100n), // 0.6 - DESERT: FixedTrait.fromRatio(72n, 100n), // 0.72 - MOUNTAIN: FixedTrait.fromRatio(80n, 100n), // 0.8 -}; - -export class Biome { - constructor() {} - - getBiome(col: number, row: number): BiomeType { - const elevation = this.calculateElevation(col, row, MAP_AMPLITUDE, ELEVATION_OCTAVES, ELEVATION_OCTAVES_SUM); - const moisture = this.calculateMoisture(col, row, MAP_AMPLITUDE, MOISTURE_OCTAVE); - return this.determineBiome(elevation, moisture, LEVEL); - } - - private calculateElevation( - col: number, - row: number, - mapAmplitude: Fixed, - octaves: Fixed[], - octavesSum: Fixed, - ): Fixed { - let elevation = FixedTrait.ZERO; - let _100 = FixedTrait.fromInt(100n); - let _2 = FixedTrait.fromInt(2n); - for (const octave of octaves) { - let x = FixedTrait.fromInt(BigInt(col)).div(octave).div(mapAmplitude); - let z = FixedTrait.fromInt(BigInt(row)).div(octave).div(mapAmplitude); - - let sn = snoise(Vec3.new(x, FixedTrait.ZERO, z)); - const noise = sn.add(FixedTrait.ONE).mul(_100).div(_2); - elevation = elevation.add(octave.mul(noise.floor())); - } - - return elevation.div(octavesSum).div(FixedTrait.fromInt(100n)); - } - - private calculateMoisture(col: number, row: number, mapAmplitude: Fixed, moistureOctave: Fixed): Fixed { - const moistureX = moistureOctave.mul(FixedTrait.fromInt(BigInt(col))).div(mapAmplitude); - const moistureZ = moistureOctave.mul(FixedTrait.fromInt(BigInt(row))).div(mapAmplitude); - const noise = snoise(Vec3.new(moistureX, FixedTrait.ZERO, moistureZ)) - .add(FixedTrait.ONE) - .mul(FixedTrait.fromInt(100n)) - .div(FixedTrait.fromInt(2n)); - return FixedTrait.floor(noise).div(FixedTrait.fromInt(100n)); - } - - private determineBiome(elevation: Fixed, moisture: Fixed, level: typeof LEVEL): BiomeType { - if (elevation.value < level.DEEP_OCEAN.value) return BiomeType.DeepOcean; - if (elevation.value < level.OCEAN.value) return BiomeType.Ocean; - if (elevation.value < level.SAND.value) return BiomeType.Beach; - - if (elevation.value > level.MOUNTAIN.value) { - if (moisture.value < FixedTrait.fromRatio(10n, 100n).value) return BiomeType.Scorched; - if (moisture.value < FixedTrait.fromRatio(40n, 100n).value) return BiomeType.Bare; - if (moisture.value < FixedTrait.fromRatio(50n, 100n).value) return BiomeType.Tundra; - return BiomeType.Snow; - } - if (elevation.value > level.DESERT.value) { - if (moisture.value < FixedTrait.fromRatio(33n, 100n).value) return BiomeType.TemperateDesert; - if (moisture.value < FixedTrait.fromRatio(66n, 100n).value) return BiomeType.Shrubland; - return BiomeType.Taiga; - } - if (elevation.value > level.FOREST.value) { - if (moisture.value < FixedTrait.fromRatio(16n, 100n).value) return BiomeType.TemperateDesert; - if (moisture.value < FixedTrait.fromRatio(50n, 100n).value) return BiomeType.Grassland; - if (moisture.value < FixedTrait.fromRatio(83n, 100n).value) return BiomeType.TemperateDeciduousForest; - return BiomeType.TemperateRainForest; - } - if (moisture.value < FixedTrait.fromRatio(16n, 100n).value) return BiomeType.SubtropicalDesert; - if (moisture.value < FixedTrait.fromRatio(33n, 100n).value) return BiomeType.Grassland; - if (moisture.value < FixedTrait.fromRatio(66n, 100n).value) return BiomeType.TropicalSeasonalForest; - return BiomeType.TropicalRainForest; - } -} - -function analyzeBiomeDistribution(centerX: number, centerY: number, radius: number) { - const biome = new Biome(); - const biomeCounts: Record = { - DeepOcean: 0, - Ocean: 0, - Beach: 0, - Scorched: 0, - Bare: 0, - Tundra: 0, - Snow: 0, - TemperateDesert: 0, - Shrubland: 0, - Taiga: 0, - Grassland: 0, - TemperateDeciduousForest: 0, - TemperateRainForest: 0, - SubtropicalDesert: 0, - TropicalSeasonalForest: 0, - TropicalRainForest: 0, - }; - - const startX = centerX - radius; - const endX = centerX + radius; - const startY = centerY - radius; - const endY = centerY + radius; - let totalTiles = 0; - - for (let x = startX; x <= endX; x++) { - for (let y = startY; y <= endY; y++) { - const biomeType = biome.getBiome(x, y); - biomeCounts[biomeType]++; - totalTiles++; - } - } - - console.log(`\nBiome Distribution Analysis`); - console.log(`Center: (${centerX}, ${centerY})`); - console.log(`Radius: ${radius} tiles`); - console.log(`Total area: ${totalTiles} tiles\n`); - console.log("Biome Counts:"); - - Object.entries(biomeCounts) - .sort(([, a], [, b]) => b - a) - .forEach(([biomeType, count]) => { - const percentage = ((count / totalTiles) * 100).toFixed(2); - console.log(`${biomeType}: ${count} tiles (${percentage}%)`); - }); -} - -// Example usage: -// analyzeBiomeDistribution(2147483646, 2147483646, 500); - -// function testBiomeGeneration() { -// const biome = new Biome(); -// const start = 5000871265127650; -// const end = 5000871265127678; -// for (let i = start; i <= end; i++) { -// const result = biome.getBiome(i - 5, i + 10); -// console.log(`biome for ${i - 5} ${i + 10} is ${result} \n`); -// } -// } - -// testBiomeGeneration(); diff --git a/client/apps/game/src/three/managers/minimap.ts b/client/apps/game/src/three/managers/minimap.ts index 6c1965f7c..97f959435 100644 --- a/client/apps/game/src/three/managers/minimap.ts +++ b/client/apps/game/src/three/managers/minimap.ts @@ -1,11 +1,10 @@ import { useUIStore } from "@/hooks/store/use-ui-store"; import { type ArmyManager } from "@/three/managers/army-manager"; import { type BattleManager } from "@/three/managers/battle-manager"; -import { type Biome, BIOME_COLORS } from "@/three/managers/biome"; +import { BIOME_COLORS } from "@/three/managers/biome-colors"; import { type StructureManager } from "@/three/managers/structure-manager"; import type WorldmapScene from "@/three/scenes/worldmap"; -import { FELT_CENTER } from "@/ui/config"; -import { StructureType } from "@bibliothecadao/eternum"; +import { BiomeType, StructureType } from "@bibliothecadao/eternum"; import throttle from "lodash/throttle"; import type * as THREE from "three"; import { getHexForWorldPosition } from "../utils"; @@ -62,11 +61,10 @@ class Minimap { private canvas!: HTMLCanvasElement; private context!: CanvasRenderingContext2D; private camera!: THREE.PerspectiveCamera; - private exploredTiles!: Map>; + private exploredTiles!: Map>; private structureManager!: StructureManager; private armyManager!: ArmyManager; private battleManager!: BattleManager; - private biome!: Biome; private mapCenter: { col: number; row: number } = { col: 250, row: 150 }; private mapSize: { width: number; height: number } = { width: MINIMAP_CONFIG.MAP_COLS_WIDTH, @@ -93,18 +91,17 @@ class Minimap { constructor( worldmapScene: WorldmapScene, - exploredTiles: Map>, + exploredTiles: Map>, camera: THREE.PerspectiveCamera, structureManager: StructureManager, armyManager: ArmyManager, battleManager: BattleManager, - biome: Biome, ) { this.worldmapScene = worldmapScene; this.waitForMinimapElement().then((canvas) => { this.canvas = canvas; this.loadLabelImages(); - this.initializeCanvas(structureManager, exploredTiles, armyManager, biome, camera, battleManager); + this.initializeCanvas(structureManager, exploredTiles, armyManager, camera, battleManager); this.canvas.addEventListener("canvasResized", this.handleResize); }); } @@ -125,9 +122,8 @@ class Minimap { private initializeCanvas( structureManager: StructureManager, - exploredTiles: Map>, + exploredTiles: Map>, armyManager: ArmyManager, - biome: Biome, camera: THREE.PerspectiveCamera, battleManager: BattleManager, ) { @@ -136,7 +132,6 @@ class Minimap { this.exploredTiles = exploredTiles; this.armyManager = armyManager; this.battleManager = battleManager; - this.biome = biome; this.camera = camera; this.scaleX = this.canvas.width / this.mapSize.width; this.scaleY = this.canvas.height / this.mapSize.height; @@ -218,14 +213,13 @@ class Minimap { private drawExploredTiles() { this.exploredTiles.forEach((rows, col) => { - rows.forEach((row) => { + rows.forEach((biome, row) => { const cacheKey = `${col},${row}`; let biomeColor; if (this.biomeCache.has(cacheKey)) { biomeColor = this.biomeCache.get(cacheKey)!; } else { - const biome = this.biome.getBiome(col + FELT_CENTER, row + FELT_CENTER); biomeColor = BIOME_COLORS[biome].getStyle(); this.biomeCache.set(cacheKey, biomeColor); } diff --git a/client/apps/game/src/three/scenes/constants.ts b/client/apps/game/src/three/scenes/constants.ts index 86d6fccad..567d9d471 100644 --- a/client/apps/game/src/three/scenes/constants.ts +++ b/client/apps/game/src/three/scenes/constants.ts @@ -1,6 +1,6 @@ -import { BiomeType } from "@/three/managers/biome"; import { IS_FLAT_MODE } from "@/ui/config"; import { + BiomeType, BuildingType, RealmLevelNames, RealmLevels, diff --git a/client/apps/game/src/three/scenes/hexagon-scene.ts b/client/apps/game/src/three/scenes/hexagon-scene.ts index 94dd40665..81d0d2489 100644 --- a/client/apps/game/src/three/scenes/hexagon-scene.ts +++ b/client/apps/game/src/three/scenes/hexagon-scene.ts @@ -2,7 +2,6 @@ import { useUIStore, type AppStore } from "@/hooks/store/use-ui-store"; import { GUIManager } from "@/three/helpers/gui-manager"; import { LocationManager } from "@/three/helpers/location-manager"; import { gltfLoader } from "@/three/helpers/utils"; -import { type BiomeType } from "@/three/managers/biome"; import { HighlightHexManager } from "@/three/managers/highlight-hex-manager"; import { InputManager } from "@/three/managers/input-manager"; import InstancedBiome from "@/three/managers/instanced-biome"; @@ -12,7 +11,7 @@ import { HEX_SIZE, biomeModelPaths } from "@/three/scenes/constants"; import { SystemManager } from "@/three/systems/system-manager"; import { LeftView, RightView } from "@/types"; import { GRAPHICS_SETTING, GraphicsSettings, IS_FLAT_MODE } from "@/ui/config"; -import { type HexPosition, type SetupResult } from "@bibliothecadao/eternum"; +import { BiomeType, type HexPosition, type SetupResult } from "@bibliothecadao/eternum"; import gsap from "gsap"; import throttle from "lodash/throttle"; import * as THREE from "three"; diff --git a/client/apps/game/src/three/scenes/hexception.tsx b/client/apps/game/src/three/scenes/hexception.tsx index 29e67b77f..ac7e41bcc 100644 --- a/client/apps/game/src/three/scenes/hexception.tsx +++ b/client/apps/game/src/three/scenes/hexception.tsx @@ -2,7 +2,7 @@ import { useAccountStore } from "@/hooks/store/use-account-store"; import { useUIStore } from "@/hooks/store/use-ui-store"; import { createHexagonShape } from "@/three/geometry/hexagon-geometry"; import { createPausedLabel, gltfLoader } from "@/three/helpers/utils"; -import { BIOME_COLORS, Biome, BiomeType } from "@/three/managers/biome"; +import { BIOME_COLORS } from "@/three/managers/biome-colors"; import { BuildingPreview } from "@/three/managers/building-preview"; import { SMALL_DETAILS_NAME } from "@/three/managers/instanced-model"; import { SceneManager } from "@/three/scene-manager"; @@ -14,6 +14,8 @@ import { IS_FLAT_MODE } from "@/ui/config"; import { ResourceIcon } from "@/ui/elements/resource-icon"; import { BUILDINGS_CENTER, + Biome, + BiomeType, BuildingType, HexPosition, RealmLevels, @@ -102,7 +104,6 @@ export default class HexceptionScene extends HexagonScene { private pillars: THREE.InstancedMesh | null = null; private buildings: any = []; centerColRow: number[] = [0, 0]; - private biome!: Biome; private highlights: { col: number; row: number }[] = []; private buildingPreview: BuildingPreview | null = null; private tileManager: TileManager; @@ -125,7 +126,6 @@ export default class HexceptionScene extends HexagonScene { ) { super(SceneName.Hexception, controls, dojo, mouse, raycaster, sceneManager); - this.biome = new Biome(); this.buildingPreview = new BuildingPreview(this.scene); const pillarGeometry = new THREE.ExtrudeGeometry(createHexagonShape(1), { depth: 2, bevelEnabled: false }); @@ -616,7 +616,7 @@ export default class HexceptionScene extends HexagonScene { existingBuildings: any[], biomeHexes: Record, ) => { - const biome = this.biome.getBiome(targetHex.col, targetHex.row); + const biome = Biome.getBiome(targetHex.col, targetHex.row); const buildableAreaBiome = "Grassland"; const isFlat = biome === "Ocean" || biome === "DeepOcean" || isMainHex; diff --git a/client/apps/game/src/three/scenes/worldmap.tsx b/client/apps/game/src/three/scenes/worldmap.tsx index 5ded856c1..ffd1ba771 100644 --- a/client/apps/game/src/three/scenes/worldmap.tsx +++ b/client/apps/game/src/three/scenes/worldmap.tsx @@ -4,7 +4,6 @@ import { useUIStore } from "@/hooks/store/use-ui-store"; import { LoadingStateKey } from "@/hooks/store/use-world-loading"; import { ArmyManager } from "@/three/managers/army-manager"; import { BattleManager } from "@/three/managers/battle-manager"; -import { Biome } from "@/three/managers/biome"; import Minimap from "@/three/managers/minimap"; import { SelectedHexManager } from "@/three/managers/selected-hex-manager"; import { StructureManager } from "@/three/managers/structure-manager"; @@ -20,6 +19,7 @@ import { UNDEFINED_STRUCTURE_ENTITY_ID } from "@/ui/constants"; import { getBlockTimestamp } from "@/utils/timestamp"; import { ArmyMovementManager, + Biome, BiomeType, DUMMY_HYPERSTRUCTURE_ENTITY_ID, HexPosition, @@ -34,7 +34,7 @@ import throttle from "lodash/throttle"; import * as THREE from "three"; import { Raycaster } from "three"; import { MapControls } from "three/examples/jsm/controls/MapControls"; -import { ArmySystemUpdate, SceneName, TileSystemUpdate } from "../types"; +import { ArmySystemUpdate, SceneName, StructureSystemUpdate, TileSystemUpdate } from "../types"; import { getWorldPositionForHex } from "../utils"; export default class WorldmapScene extends HexagonScene { @@ -53,7 +53,13 @@ export default class WorldmapScene extends HexagonScene { private armyManager: ArmyManager; private structureManager: StructureManager; private battleManager: BattleManager; - private exploredTiles: Map> = new Map(); + private exploredTiles: Map> = new Map(); + // normalized positions + private armyHexes: Map> = new Map(); + // normalized positions + private structureHexes: Map> = new Map(); + // store armies positions by ID, to remove previous positions when army moves + private armiesPositions: Map = new Map(); private battles: Map> = new Map(); private tileManager: TileManager; private structurePreview: StructurePreview | null = null; @@ -82,8 +88,6 @@ export default class WorldmapScene extends HexagonScene { this.GUIFolder.add(this, "moveCameraToURLLocation"); - this.biome = new Biome(); - this.structurePreview = new StructurePreview(this.scene); this.tileManager = new TileManager(this.dojo.components, this.dojo.systemCalls, { col: 0, row: 0 }); @@ -113,13 +117,13 @@ export default class WorldmapScene extends HexagonScene { }, ); - this.armyManager = new ArmyManager(this.scene, this.renderChunkSize, this.exploredTiles); + this.armyManager = new ArmyManager(this.scene, this.renderChunkSize); this.structureManager = new StructureManager(this.scene, this.renderChunkSize); this.battleManager = new BattleManager(this.scene); this.armySubscription?.unsubscribe(); this.armySubscription = this.systemManager.Army.onUpdate((update: ArmySystemUpdate) => { - this.armyManager.onUpdate(update).then((needsUpdate) => { + this.armyManager.onUpdate(update, this.armyHexes, this.structureHexes, this.exploredTiles).then((needsUpdate) => { if (needsUpdate) { this.updateVisibleChunks(); } @@ -128,7 +132,10 @@ export default class WorldmapScene extends HexagonScene { this.systemManager.Battle.onUpdate((value) => this.battleManager.onUpdate(value)); this.systemManager.Tile.onUpdate((value) => this.updateExploredHex(value)); + this.systemManager.Army.onUpdate((value) => this.updateArmyHexes(value)); this.systemManager.Structure.onUpdate((value) => { + this.updateStructureHexes(value); + const optimisticStructure = this.structureManager.structures.removeStructure( Number(DUMMY_HYPERSTRUCTURE_ENTITY_ID), ); @@ -180,7 +187,6 @@ export default class WorldmapScene extends HexagonScene { this.structureManager, this.armyManager, this.battleManager, - this.biome, ); } @@ -371,7 +377,14 @@ export default class WorldmapScene extends HexagonScene { ); const { currentDefaultTick, currentArmiesTick } = getBlockTimestamp(); - const travelPaths = armyMovementManager.findPaths(this.exploredTiles, currentDefaultTick, currentArmiesTick); + + const travelPaths = armyMovementManager.findPaths( + this.structureHexes, + this.armyHexes, + this.exploredTiles, + currentDefaultTick, + currentArmiesTick, + ); this.state.updateTravelPaths(travelPaths.getPaths()); this.highlightHexManager.highlightHexes(travelPaths.getHighlightedHexes()); } @@ -408,11 +421,61 @@ export default class WorldmapScene extends HexagonScene { this.armyManager.removeLabelsFromScene(); } + // used to track the position of the armies on the map + public updateArmyHexes(update: ArmySystemUpdate) { + const { + hexCoords: { col, row }, + } = update; + const normalized = new Position({ x: col, y: row }).getNormalized(); + const newCol = normalized.x; + const newRow = normalized.y; + const oldHexCoords = this.armiesPositions.get(update.entityId); + const oldCol = oldHexCoords?.col; + const oldRow = oldHexCoords?.row; + console.log({ oldCol, oldRow, newCol, newRow }); + + // update the position of the army + this.armiesPositions.set(update.entityId, { col: newCol, row: newRow }); + + if (oldCol !== undefined && oldRow !== undefined) { + if (oldCol !== newCol || oldRow !== newRow) { + this.armyHexes.get(oldCol)?.delete(oldRow); + if (!this.armyHexes.has(newCol)) { + this.armyHexes.set(newCol, new Set()); + } + this.armyHexes.get(newCol)?.add(newRow); + } + } else { + if (!this.armyHexes.has(newCol)) { + this.armyHexes.set(newCol, new Set()); + } + this.armyHexes.get(newCol)?.add(newRow); + } + } + + public updateStructureHexes(update: StructureSystemUpdate) { + const { + hexCoords: { col, row }, + } = update; + + const normalized = new Position({ x: col, y: row }).getNormalized(); + + const newCol = normalized.x; + const newRow = normalized.y; + + if (!this.structureHexes.has(newCol)) { + this.structureHexes.set(newCol, new Set()); + } + this.structureHexes.get(newCol)?.add(newRow); + } + public async updateExploredHex(update: TileSystemUpdate) { - const { hexCoords, removeExplored } = update; + const { hexCoords, removeExplored, biome } = update; - const col = hexCoords.col - FELT_CENTER; - const row = hexCoords.row - FELT_CENTER; + const normalized = new Position({ x: hexCoords.col, y: hexCoords.row }).getNormalized(); + + const col = normalized.x; + const row = normalized.y; if (removeExplored) { const chunkRow = parseInt(this.currentChunk.split(",")[0]); @@ -425,10 +488,10 @@ export default class WorldmapScene extends HexagonScene { } if (!this.exploredTiles.has(col)) { - this.exploredTiles.set(col, new Set()); + this.exploredTiles.set(col, new Map()); } if (!this.exploredTiles.get(col)!.has(row)) { - this.exploredTiles.get(col)!.add(row); + this.exploredTiles.get(col)!.set(row, biome); } const dummy = new THREE.Object3D(); @@ -454,9 +517,6 @@ export default class WorldmapScene extends HexagonScene { dummy.rotation.y = 0; } - const biomePosition = new Position({ x: col, y: row }).getContract(); - const biome = this.biome.getBiome(biomePosition.x, biomePosition.y); - dummy.updateMatrix(); const { chunkX, chunkZ } = this.worldToChunkCoordinates(pos.x, pos.z); @@ -672,7 +732,7 @@ export default class WorldmapScene extends HexagonScene { dummy.rotation.y = 0; } - const biome = this.biome.getBiome(startCol + col + FELT_CENTER, startRow + row + FELT_CENTER); + const biome = Biome.getBiome(startCol + col + FELT_CENTER, startRow + row + FELT_CENTER); dummy.updateMatrix(); diff --git a/client/apps/game/src/three/systems/system-manager.ts b/client/apps/game/src/three/systems/system-manager.ts index 9c5729e2f..bb9095f0c 100644 --- a/client/apps/game/src/three/systems/system-manager.ts +++ b/client/apps/game/src/three/systems/system-manager.ts @@ -1,5 +1,6 @@ import { Position } from "@/types/position"; import { + BiomeType, ClientComponents, configManager, divideByPrecision, @@ -284,6 +285,7 @@ export class SystemManager { return { hexCoords: { col, row }, removeExplored: !newState, + biome: newState?.biome === "None" ? BiomeType.Grassland : newState?.biome || BiomeType.Grassland, }; }); }, diff --git a/client/apps/game/src/three/types/systems.ts b/client/apps/game/src/three/types/systems.ts index 6891ee301..a84a52b7c 100644 --- a/client/apps/game/src/three/types/systems.ts +++ b/client/apps/game/src/three/types/systems.ts @@ -1,5 +1,5 @@ import { Position } from "@/types/position"; -import { HexPosition, ID, StructureType } from "@bibliothecadao/eternum"; +import { BiomeType, HexPosition, ID, StructureType } from "@bibliothecadao/eternum"; import { StructureProgress } from "./common"; export type ArmySystemUpdate = { @@ -25,6 +25,7 @@ export type StructureSystemUpdate = { export type TileSystemUpdate = { hexCoords: HexPosition; removeExplored: boolean; + biome: BiomeType; }; export type BattleSystemUpdate = { diff --git a/client/apps/game/src/ui/components/worldmap/armies/action-info.tsx b/client/apps/game/src/ui/components/worldmap/armies/action-info.tsx index 280e44c25..663e97491 100644 --- a/client/apps/game/src/ui/components/worldmap/armies/action-info.tsx +++ b/client/apps/game/src/ui/components/worldmap/armies/action-info.tsx @@ -10,6 +10,7 @@ import { computeTravelFoodCosts, configManager, getBalance, + HexTileInfo, ID, ResourcesIds, } from "@bibliothecadao/eternum"; @@ -28,7 +29,7 @@ const TooltipContent = memo( getBalance, }: { isExplored: boolean; - travelPath: any; + travelPath: { path: HexTileInfo[]; isExplored: boolean }; costs: { travelFoodCosts: any; exploreFoodCosts: any }; selectedEntityId: number; structureEntityId: number; @@ -66,7 +67,7 @@ const TooltipContent = memo( {!isExplored && (
@@ -131,7 +132,7 @@ export const ActionInfo = memo(() => { [selectedEntityTroops], ); - if (!showTooltip || !selectedEntityId) return null; + if (!showTooltip || !selectedEntityId || !travelPath) return null; return ( diff --git a/client/apps/game/src/ui/components/worldmap/battles/combat-simulation-panel.tsx b/client/apps/game/src/ui/components/worldmap/battles/combat-simulation-panel.tsx index e7c229587..8790efcf6 100644 --- a/client/apps/game/src/ui/components/worldmap/battles/combat-simulation-panel.tsx +++ b/client/apps/game/src/ui/components/worldmap/battles/combat-simulation-panel.tsx @@ -2,7 +2,14 @@ import { NumberInput } from "@/ui/elements/number-input"; import { SelectBiome } from "@/ui/elements/select-biome"; import { SelectTier } from "@/ui/elements/select-tier"; import { SelectTroop } from "@/ui/elements/select-troop"; -import { Biome, CombatParameters, CombatSimulator, ResourcesIds, TroopType, type Army } from "@bibliothecadao/eternum"; +import { + BiomeType, + CombatParameters, + CombatSimulator, + ResourcesIds, + TroopType, + type Army, +} from "@bibliothecadao/eternum"; import { useEffect, useState } from "react"; interface ArmyInputProps { @@ -14,9 +21,9 @@ interface ArmyInputProps { const MAX_TROOPS_PER_ARMY = 500_000; const TROOP_RESOURCES = [ - { type: TroopType.KNIGHT, resourceId: ResourcesIds.Knight }, - { type: TroopType.CROSSBOWMAN, resourceId: ResourcesIds.Crossbowman }, - { type: TroopType.PALADIN, resourceId: ResourcesIds.Paladin }, + { type: TroopType.Knight, resourceId: ResourcesIds.Knight }, + { type: TroopType.Crossbowman, resourceId: ResourcesIds.Crossbowman }, + { type: TroopType.Paladin, resourceId: ResourcesIds.Paladin }, ]; const getTroopResourceId = (troopType: TroopType): number => { @@ -123,17 +130,17 @@ const ParametersPanel = ({ parameters, onParametersChange, show }: ParametersPan }; export const CombatSimulationPanel = () => { - const [biome, setBiome] = useState(Biome.GRASSLAND); + const [biome, setBiome] = useState(BiomeType.Grassland); const [attacker, setAttacker] = useState({ stamina: 100, troopCount: 100, - troopType: TroopType.KNIGHT, + troopType: TroopType.Knight, tier: 1, }); const [defender, setDefender] = useState({ stamina: 100, troopCount: 100, - troopType: TroopType.CROSSBOWMAN, + troopType: TroopType.Crossbowman, tier: 1, }); const [showParameters, setShowParameters] = useState(false); @@ -180,7 +187,7 @@ export const CombatSimulationPanel = () => { { if (newBiome) { - setBiome(newBiome); + setBiome(newBiome as BiomeType); } }} defaultValue={biome} diff --git a/client/apps/game/src/ui/elements/select-biome.tsx b/client/apps/game/src/ui/elements/select-biome.tsx index 3368cb742..3e1532c87 100644 --- a/client/apps/game/src/ui/elements/select-biome.tsx +++ b/client/apps/game/src/ui/elements/select-biome.tsx @@ -1,6 +1,6 @@ import { ResourceIcon } from "@/ui/elements/resource-icon"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/ui/elements/select"; -import { Biome, CombatSimulator, resources, ResourcesIds, TroopType } from "@bibliothecadao/eternum"; +import { Biome, BiomeType, CombatSimulator, resources, ResourcesIds, TroopType } from "@bibliothecadao/eternum"; import React, { useState } from "react"; interface SelectBiomeProps { @@ -10,12 +10,16 @@ interface SelectBiomeProps { } const TROOP_RESOURCES = [ - { type: TroopType.KNIGHT, resourceId: ResourcesIds.Knight }, - { type: TroopType.CROSSBOWMAN, resourceId: ResourcesIds.Crossbowman }, - { type: TroopType.PALADIN, resourceId: ResourcesIds.Paladin }, + { type: TroopType.Knight, resourceId: ResourcesIds.Knight }, + { type: TroopType.Crossbowman, resourceId: ResourcesIds.Crossbowman }, + { type: TroopType.Paladin, resourceId: ResourcesIds.Paladin }, ]; -export const SelectBiome: React.FC = ({ onSelect, className, defaultValue = Biome.GRASSLAND }) => { +export const SelectBiome: React.FC = ({ + onSelect, + className, + defaultValue = BiomeType.Grassland, +}) => { const [selectedBiome, setSelectedBiome] = useState(defaultValue?.toString() || ""); const formatBiomeName = (biome: string) => { @@ -54,7 +58,7 @@ export const SelectBiome: React.FC = ({ onSelect, className, d {formatBiomeName(selectedBiome)}
{TROOP_RESOURCES.map(({ type, resourceId }) => { - const bonus = CombatSimulator.getBiomeBonus(type, selectedBiome as Biome); + const bonus = CombatSimulator.getBiomeBonus(type, selectedBiome as BiomeType); return (
r.id === resourceId)?.trait || ""} size="sm" /> diff --git a/client/apps/game/src/ui/elements/select-troop.tsx b/client/apps/game/src/ui/elements/select-troop.tsx index 17324b41e..bec760a3a 100644 --- a/client/apps/game/src/ui/elements/select-troop.tsx +++ b/client/apps/game/src/ui/elements/select-troop.tsx @@ -10,16 +10,16 @@ interface SelectTroopProps { } const TROOP_RESOURCES = [ - { type: TroopType.KNIGHT, resourceId: ResourcesIds.Knight }, - { type: TroopType.CROSSBOWMAN, resourceId: ResourcesIds.Crossbowman }, - { type: TroopType.PALADIN, resourceId: ResourcesIds.Paladin }, + { type: TroopType.Knight, resourceId: ResourcesIds.Knight }, + { type: TroopType.Crossbowman, resourceId: ResourcesIds.Crossbowman }, + { type: TroopType.Paladin, resourceId: ResourcesIds.Paladin }, ]; const formatTroopName = (type: string) => { return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase(); }; -export const SelectTroop: React.FC = ({ onSelect, className, defaultValue = TroopType.KNIGHT }) => { +export const SelectTroop: React.FC = ({ onSelect, className, defaultValue = TroopType.Knight }) => { const [selectedTroop, setSelectedTroop] = useState(defaultValue?.toString() || ""); // Call onSelect with default value on mount diff --git a/client/apps/game/src/ui/elements/stamina-resource-cost.tsx b/client/apps/game/src/ui/elements/stamina-resource-cost.tsx index 0bab65a1a..05319e4a0 100644 --- a/client/apps/game/src/ui/elements/stamina-resource-cost.tsx +++ b/client/apps/game/src/ui/elements/stamina-resource-cost.tsx @@ -1,41 +1,61 @@ import { useBlockTimestamp } from "@/hooks/helpers/use-block-timestamp"; -import { configManager, ID } from "@bibliothecadao/eternum"; +import { configManager, HexTileInfo, ID } from "@bibliothecadao/eternum"; import { useStaminaManager } from "@bibliothecadao/react"; import clsx from "clsx"; import { useMemo } from "react"; export const StaminaResourceCost = ({ travelingEntityId, - travelLength, isExplored, + path, }: { travelingEntityId: ID | undefined; - travelLength: number; isExplored: boolean; + path: HexTileInfo[]; }) => { const { currentArmiesTick } = useBlockTimestamp(); const staminaManager = useStaminaManager(travelingEntityId || 0); const stamina = useMemo(() => staminaManager.getStamina(currentArmiesTick), [currentArmiesTick, staminaManager]); - const destinationHex = useMemo(() => { + const pathInfo = useMemo(() => { if (!stamina) return; - const costs = - travelLength * (isExplored ? -configManager.getTravelStaminaCost() : -configManager.getExploreStaminaCost()); - const balanceColor = stamina !== undefined && stamina.amount < costs ? "text-red/90" : "text-green/90"; - return { isExplored, costs, balanceColor, balance: stamina.amount }; - }, [stamina, travelLength]); + + // Calculate total cost and collect biome info + const totalCost = path.reduce((acc, tile) => { + return acc + tile.staminaCost; + }, 0); + + const balanceColor = stamina.amount < totalCost ? "text-order-giants" : "text-order-brilliance"; + + return { + isExplored, + totalCost, + balanceColor, + balance: stamina.amount, + }; + }, [stamina, path, isExplored]); return ( - destinationHex && ( + pathInfo && (
⚡️
- {destinationHex?.costs}{" "} - ({destinationHex.balance}) + {pathInfo.isExplored ? pathInfo.totalCost : configManager.getExploreStaminaCost()}{" "} + ({pathInfo.balance}) +
+
+ {pathInfo.isExplored ? ( + path.map((tile) => ( +
+ {tile.biomeType}: {tile.staminaCost} +
+ )) + ) : ( +
Exploration
+ )}
-
Stamina
) diff --git a/packages/core/src/managers/army-movement-manager.ts b/packages/core/src/managers/army-movement-manager.ts index 36439b7c0..3e4e0547b 100644 --- a/packages/core/src/managers/army-movement-manager.ts +++ b/packages/core/src/managers/army-movement-manager.ts @@ -4,6 +4,7 @@ import { uuid } from "@latticexyz/utils"; import { Account, AccountInterface } from "starknet"; import { DojoAccount } from ".."; import { + BiomeType, CapacityConfigCategory, FELT_CENTER, getDirectionBetweenAdjacentHexes, @@ -12,62 +13,14 @@ import { } from "../constants"; import { ClientComponents } from "../dojo/create-client-components"; import { EternumProvider } from "../provider"; -import { ContractAddress, HexPosition, ID, TravelTypes } from "../types"; -import { multiplyByPrecision } from "../utils"; +import { ContractAddress, HexPosition, HexTileInfo, ID, TravelTypes, TroopType } from "../types"; +import { Biome, multiplyByPrecision } from "../utils"; +import { TravelPaths } from "../utils/travel-path"; import { configManager } from "./config-manager"; import { ResourceManager } from "./resource-manager"; import { StaminaManager } from "./stamina-manager"; import { computeExploreFoodCosts, computeTravelFoodCosts, getRemainingCapacityInKg } from "./utils"; -export class TravelPaths { - private readonly paths: Map; - - constructor() { - this.paths = new Map(); - } - - set(key: string, value: { path: HexPosition[]; isExplored: boolean }): void { - this.paths.set(key, value); - } - - deleteAll(): void { - this.paths.clear(); - } - - get(key: string): { path: HexPosition[]; isExplored: boolean } | undefined { - return this.paths.get(key); - } - - has(key: string): boolean { - return this.paths.has(key); - } - - values(): IterableIterator<{ path: HexPosition[]; isExplored: boolean }> { - return this.paths.values(); - } - - getHighlightedHexes(): Array<{ col: number; row: number }> { - return Array.from(this.paths.values()).map(({ path }) => ({ - col: path[path.length - 1].col - FELT_CENTER, - row: path[path.length - 1].row - FELT_CENTER, - })); - } - - isHighlighted(row: number, col: number): boolean { - return this.paths.has(TravelPaths.posKey({ col: col + FELT_CENTER, row: row + FELT_CENTER })); - } - - getPaths(): Map { - return this.paths; - } - - static posKey(pos: HexPosition, normalized = false): string { - const col = normalized ? pos.col + FELT_CENTER : pos.col; - const row = normalized ? pos.row + FELT_CENTER : pos.row; - return `${col},${row}`; - } -} - export class ArmyMovementManager { private readonly entity: Entity; private readonly entityId: ID; @@ -88,6 +41,21 @@ export class ArmyMovementManager { this.staminaManager = new StaminaManager(this.components, entityId); } + private _getTroopType(): TroopType { + const entityArmy = getComponentValue(this.components.Army, this.entity); + const knightCount = entityArmy?.troops?.knight_count ?? 0; + const crossbowmanCount = entityArmy?.troops?.crossbowman_count ?? 0; + const paladinCount = entityArmy?.troops?.paladin_count ?? 0; + + if (knightCount >= crossbowmanCount && knightCount >= paladinCount) { + return TroopType.Knight; + } + if (crossbowmanCount >= knightCount && crossbowmanCount >= paladinCount) { + return TroopType.Crossbowman; + } + return TroopType.Paladin; + } + private _canExplore(currentDefaultTick: number, currentArmiesTick: number): boolean { const stamina = this.staminaManager.getStamina(currentArmiesTick); @@ -115,11 +83,9 @@ export class ArmyMovementManager { private readonly _calculateMaxTravelPossible = (currentDefaultTick: number, currentArmiesTick: number) => { const stamina = this.staminaManager.getStamina(currentArmiesTick); - const travelStaminaCost = configManager.getTravelStaminaCost(); - - const maxStaminaSteps = travelStaminaCost - ? Math.floor((stamina.amount || 0) / configManager.getTravelStaminaCost()) - : 999; + // Calculate minimum stamina cost across all biomes for this troop type + const minTravelStaminaCost = configManager.getMinTravelStaminaCost(); + const maxStaminaSteps = Math.floor(stamina.amount / minTravelStaminaCost); const entityArmy = getComponentValue(this.components.Army, this.entity); const travelFoodCosts = computeTravelFoodCosts(entityArmy?.troops); @@ -146,45 +112,137 @@ export class ArmyMovementManager { }; } + // todo : refactor to use stamina config + public static staminaDrain(biome: BiomeType, troopType: TroopType) { + const baseStaminaCost = 20; // Base cost to move to adjacent hex + + // Biome-specific modifiers per troop type + switch (biome) { + case BiomeType.Ocean: + return baseStaminaCost - 10; // -10 for all troops + case BiomeType.DeepOcean: + return baseStaminaCost - 10; // -10 for all troops + case BiomeType.Beach: + return baseStaminaCost; // No modifier + case BiomeType.Grassland: + return baseStaminaCost + (troopType === TroopType.Paladin ? -10 : 0); + case BiomeType.Shrubland: + return baseStaminaCost + (troopType === TroopType.Paladin ? -10 : 0); + case BiomeType.SubtropicalDesert: + return baseStaminaCost + (troopType === TroopType.Paladin ? -10 : 0); + case BiomeType.TemperateDesert: + return baseStaminaCost + (troopType === TroopType.Paladin ? -10 : 0); + case BiomeType.TropicalRainForest: + return baseStaminaCost + (troopType === TroopType.Paladin ? 10 : 0); + case BiomeType.TropicalSeasonalForest: + return baseStaminaCost + (troopType === TroopType.Paladin ? 10 : 0); + case BiomeType.TemperateRainForest: + return baseStaminaCost + (troopType === TroopType.Paladin ? 10 : 0); + case BiomeType.TemperateDeciduousForest: + return baseStaminaCost + (troopType === TroopType.Paladin ? 10 : 0); + case BiomeType.Tundra: + return baseStaminaCost + (troopType === TroopType.Paladin ? -10 : 0); + case BiomeType.Taiga: + return baseStaminaCost + (troopType === TroopType.Paladin ? 10 : 0); + case BiomeType.Snow: + return baseStaminaCost + (troopType === TroopType.Paladin ? 0 : 10); + case BiomeType.Bare: + return baseStaminaCost + (troopType === TroopType.Paladin ? -10 : 0); + case BiomeType.Scorched: + return baseStaminaCost + 10; // +10 for all troops + default: + return baseStaminaCost; + } + } + public findPaths( - exploredHexes: Map>, + structureHexes: Map>, + armyHexes: Map>, + exploredHexes: Map>, currentDefaultTick: number, currentArmiesTick: number, ): TravelPaths { + const armyStamina = this.staminaManager.getStamina(currentArmiesTick).amount; + if (armyStamina === 0) return new TravelPaths(); + + const troopType = this._getTroopType(); const startPos = this._getCurrentPosition(); + // max hex based on food const maxHex = this._calculateMaxTravelPossible(currentDefaultTick, currentArmiesTick); const canExplore = this._canExplore(currentDefaultTick, currentArmiesTick); - const priorityQueue: Array<{ position: HexPosition; distance: number; path: HexPosition[] }> = [ - { position: startPos, distance: 0, path: [startPos] }, - ]; + const startBiome = Biome.getBiome(startPos.col, startPos.row); + const travelPaths = new TravelPaths(); - const shortestDistances = new Map(); + const lowestStaminaUse = new Map(); + const priorityQueue: Array<{ position: HexPosition; staminaUsed: number; distance: number; path: HexTileInfo[] }> = + []; + + // Process initial neighbors instead of start position + const neighbors = getNeighborHexes(startPos.col, startPos.row); + for (const { col, row } of neighbors) { + const isExplored = exploredHexes.get(col - FELT_CENTER)?.has(row - FELT_CENTER) || false; + const hasArmy = armyHexes.get(col - FELT_CENTER)?.has(row - FELT_CENTER) || false; + const hasStructure = structureHexes.get(col - FELT_CENTER)?.has(row - FELT_CENTER) || false; + const biome = exploredHexes.get(col - FELT_CENTER)?.get(row - FELT_CENTER); + + if (hasArmy || hasStructure) continue; + if (!isExplored && !canExplore) continue; + + const EXPLORATION_STAMINA_COST = configManager.getExploreStaminaCost(); + const staminaCost = biome ? ArmyMovementManager.staminaDrain(biome, troopType) : EXPLORATION_STAMINA_COST; + + if (staminaCost > armyStamina) continue; + + priorityQueue.push({ + position: { col, row }, + staminaUsed: staminaCost, + distance: 1, + path: [ + { col: startPos.col, row: startPos.row, biomeType: startBiome, staminaCost: 0 }, + { col, row, biomeType: biome, staminaCost }, + ], + }); + } while (priorityQueue.length > 0) { - priorityQueue.sort((a, b) => a.distance - b.distance); // This makes the queue work as a priority queue - const { position: current, distance, path } = priorityQueue.shift()!; + priorityQueue.sort((a, b) => a.staminaUsed - b.staminaUsed); + const { position: current, staminaUsed, distance, path } = priorityQueue.shift()!; const currentKey = TravelPaths.posKey(current); - if (!shortestDistances.has(currentKey) || distance < shortestDistances.get(currentKey)!) { - shortestDistances.set(currentKey, distance); + if (!lowestStaminaUse.has(currentKey) || staminaUsed < lowestStaminaUse.get(currentKey)!) { + lowestStaminaUse.set(currentKey, staminaUsed); const isExplored = exploredHexes.get(current.col - FELT_CENTER)?.has(current.row - FELT_CENTER) || false; - if (path.length >= 2) { - travelPaths.set(currentKey, { path, isExplored }); - } + travelPaths.set(currentKey, { path, isExplored }); + if (!isExplored) continue; - const neighbors = getNeighborHexes(current.col, current.row); // This function needs to be defined + const neighbors = getNeighborHexes(current.col, current.row); for (const { col, row } of neighbors) { const neighborKey = TravelPaths.posKey({ col, row }); const nextDistance = distance + 1; - const nextPath = [...path, { col, row }]; + + if (nextDistance > maxHex) continue; const isExplored = exploredHexes.get(col - FELT_CENTER)?.has(row - FELT_CENTER) || false; - if ((isExplored && nextDistance <= maxHex) || (!isExplored && canExplore && nextDistance === 1)) { - if (!shortestDistances.has(neighborKey) || nextDistance < shortestDistances.get(neighborKey)!) { - priorityQueue.push({ position: { col, row }, distance: nextDistance, path: nextPath }); - } + const hasArmy = armyHexes.get(col - FELT_CENTER)?.has(row - FELT_CENTER) || false; + const hasStructure = structureHexes.get(col - FELT_CENTER)?.has(row - FELT_CENTER) || false; + const biome = exploredHexes.get(col - FELT_CENTER)?.get(row - FELT_CENTER); + + if (!isExplored || hasArmy || hasStructure) continue; + + const staminaCost = ArmyMovementManager.staminaDrain(biome!, troopType); + const nextStaminaUsed = staminaUsed + staminaCost; + + if (nextStaminaUsed > armyStamina) continue; + + if (!lowestStaminaUse.has(neighborKey) || nextStaminaUsed < lowestStaminaUse.get(neighborKey)!) { + priorityQueue.push({ + position: { col, row }, + staminaUsed: nextStaminaUsed, + distance: nextDistance, + path: [...path, { col, row, biomeType: biome, staminaCost }], + }); } } } @@ -238,7 +296,7 @@ export class ArmyMovementManager { row, explored_by_id: this.entityId, explored_at: BigInt(Math.floor(Date.now() / 1000)), - biome: "None", + biome: Biome.getBiome(col, row), }, }); }; diff --git a/packages/core/src/managers/config-manager.ts b/packages/core/src/managers/config-manager.ts index 0408b591a..aee621f70 100644 --- a/packages/core/src/managers/config-manager.ts +++ b/packages/core/src/managers/config-manager.ts @@ -296,6 +296,11 @@ export class ClientConfigManager { }, 0); } + // todo: need to get this from config + getMinTravelStaminaCost() { + return 10; + } + getResourceBridgeFeeSplitConfig() { return this.getValueOrDefault( () => { diff --git a/packages/core/src/types/common.ts b/packages/core/src/types/common.ts index ce5f91357..8654bcce1 100644 --- a/packages/core/src/types/common.ts +++ b/packages/core/src/types/common.ts @@ -1,6 +1,7 @@ import { ComponentValue, Entity } from "@dojoengine/recs"; import { Account, AccountInterface } from "starknet"; import { + BiomeType, BuildingType, CapacityConfigCategory, QuestType, @@ -162,6 +163,13 @@ export enum ClaimStatus { export type HexPosition = { col: number; row: number }; +export type HexTileInfo = { + col: number; + row: number; + staminaCost: number; + biomeType: BiomeType | undefined; +}; + export enum Winner { Attacker = "Attacker", Target = "Target", @@ -207,6 +215,12 @@ export interface Health { lifetime: bigint; } +export enum TroopType { + Knight = "Knight", + Crossbowman = "Crossbowman", + Paladin = "Paladin", +} + export interface CombatResultInterface { attackerRealmEntityId: ID; targetRealmEntityId: ID; diff --git a/packages/core/src/utils/biome/biome.ts b/packages/core/src/utils/biome/biome.ts new file mode 100644 index 000000000..8b2f87a46 --- /dev/null +++ b/packages/core/src/utils/biome/biome.ts @@ -0,0 +1,90 @@ +import { BiomeType } from "../../constants/hex"; +import { Fixed, FixedTrait } from "./fixed-point"; +import { noise as snoise } from "./simplex-noise"; +import { Vec3 } from "./vec3"; + +const MAP_AMPLITUDE = FixedTrait.fromInt(60n); +const MOISTURE_OCTAVE = FixedTrait.fromInt(2n); +const ELEVATION_OCTAVES = [ + FixedTrait.fromInt(1n), // 1 + FixedTrait.fromRatio(1n, 4n), // 0.25 + FixedTrait.fromRatio(1n, 10n), // 0.1 +]; +const ELEVATION_OCTAVES_SUM = ELEVATION_OCTAVES.reduce((a, b) => a.add(b), FixedTrait.ZERO); + +const LEVEL = { + DEEP_OCEAN: FixedTrait.fromRatio(25n, 100n), // 0.25 + OCEAN: FixedTrait.fromRatio(50n, 100n), // 0.5 + SAND: FixedTrait.fromRatio(53n, 100n), // 0.53 + FOREST: FixedTrait.fromRatio(60n, 100n), // 0.6 + DESERT: FixedTrait.fromRatio(72n, 100n), // 0.72 + MOUNTAIN: FixedTrait.fromRatio(80n, 100n), // 0.8 +}; + +export class Biome { + static getBiome(col: number, row: number): BiomeType { + const elevation = Biome.calculateElevation(col, row, MAP_AMPLITUDE, ELEVATION_OCTAVES, ELEVATION_OCTAVES_SUM); + const moisture = Biome.calculateMoisture(col, row, MAP_AMPLITUDE, MOISTURE_OCTAVE); + return Biome.determineBiome(elevation, moisture, LEVEL); + } + + private static calculateElevation( + col: number, + row: number, + mapAmplitude: Fixed, + octaves: Fixed[], + octavesSum: Fixed, + ): Fixed { + let elevation = FixedTrait.ZERO; + let _100 = FixedTrait.fromInt(100n); + let _2 = FixedTrait.fromInt(2n); + for (const octave of octaves) { + let x = FixedTrait.fromInt(BigInt(col)).div(octave).div(mapAmplitude); + let z = FixedTrait.fromInt(BigInt(row)).div(octave).div(mapAmplitude); + + let sn = snoise(Vec3.new(x, FixedTrait.ZERO, z)); + const noise = sn.add(FixedTrait.ONE).mul(_100).div(_2); + elevation = elevation.add(octave.mul(noise.floor())); + } + + return elevation.div(octavesSum).div(FixedTrait.fromInt(100n)); + } + + private static calculateMoisture(col: number, row: number, mapAmplitude: Fixed, moistureOctave: Fixed): Fixed { + const moistureX = moistureOctave.mul(FixedTrait.fromInt(BigInt(col))).div(mapAmplitude); + const moistureZ = moistureOctave.mul(FixedTrait.fromInt(BigInt(row))).div(mapAmplitude); + const noise = snoise(Vec3.new(moistureX, FixedTrait.ZERO, moistureZ)) + .add(FixedTrait.ONE) + .mul(FixedTrait.fromInt(100n)) + .div(FixedTrait.fromInt(2n)); + return FixedTrait.floor(noise).div(FixedTrait.fromInt(100n)); + } + + private static determineBiome(elevation: Fixed, moisture: Fixed, level: typeof LEVEL): BiomeType { + if (elevation.value < level.DEEP_OCEAN.value) return BiomeType.DeepOcean; + if (elevation.value < level.OCEAN.value) return BiomeType.Ocean; + if (elevation.value < level.SAND.value) return BiomeType.Beach; + + if (elevation.value > level.MOUNTAIN.value) { + if (moisture.value < FixedTrait.fromRatio(10n, 100n).value) return BiomeType.Scorched; + if (moisture.value < FixedTrait.fromRatio(40n, 100n).value) return BiomeType.Bare; + if (moisture.value < FixedTrait.fromRatio(50n, 100n).value) return BiomeType.Tundra; + return BiomeType.Snow; + } + if (elevation.value > level.DESERT.value) { + if (moisture.value < FixedTrait.fromRatio(33n, 100n).value) return BiomeType.TemperateDesert; + if (moisture.value < FixedTrait.fromRatio(66n, 100n).value) return BiomeType.Shrubland; + return BiomeType.Taiga; + } + if (elevation.value > level.FOREST.value) { + if (moisture.value < FixedTrait.fromRatio(16n, 100n).value) return BiomeType.TemperateDesert; + if (moisture.value < FixedTrait.fromRatio(50n, 100n).value) return BiomeType.Grassland; + if (moisture.value < FixedTrait.fromRatio(83n, 100n).value) return BiomeType.TemperateDeciduousForest; + return BiomeType.TemperateRainForest; + } + if (moisture.value < FixedTrait.fromRatio(16n, 100n).value) return BiomeType.SubtropicalDesert; + if (moisture.value < FixedTrait.fromRatio(33n, 100n).value) return BiomeType.Grassland; + if (moisture.value < FixedTrait.fromRatio(66n, 100n).value) return BiomeType.TropicalSeasonalForest; + return BiomeType.TropicalRainForest; + } +} diff --git a/packages/core/src/utils/biome/fixed-point.ts b/packages/core/src/utils/biome/fixed-point.ts new file mode 100644 index 000000000..11c75fc96 --- /dev/null +++ b/packages/core/src/utils/biome/fixed-point.ts @@ -0,0 +1,382 @@ +/** + * TypeScript port of ABDK Math 64.64 Library plus modifications + * Original: https://github.com/abdk-consulting/abdk-libraries-solidity/blob/master/ABDKMath64x64.sol + * Original Copyright © 2019 by ABDK Consulting + * Original Author: Mikhail Vladimirov + * + * Modified by: Credence + * + * Modifications: + * - mul and div are handled differently from the original library, which may affect the precision. + */ + +// Constants +const MIN_64x64 = BigInt("0x80000000000000000000000000000000") * BigInt(-1); +const MAX_64x64 = BigInt("0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); +const ONE_64x64 = BigInt(1) << BigInt(64); + +export class Fixed { + public value: bigint; + + constructor(value: bigint) { + this.value = value; + } + + mul(other: Fixed): Fixed { + return FixedTrait.mul(this, other); + } + + div(other: Fixed): Fixed { + return FixedTrait.div(this, other); + } + + add(other: Fixed): Fixed { + return FixedTrait.add(this, other); + } + + sub(other: Fixed): Fixed { + return FixedTrait.sub(this, other); + } + + abs(): Fixed { + return FixedTrait.abs(this); + } + + sqrt(): Fixed { + return FixedTrait.sqrt(this); + } + + floor(): Fixed { + return FixedTrait.floor(this); + } + + mod(other: Fixed): Fixed { + return FixedTrait.mod(this, other); + } + + rem(other: Fixed): Fixed { + return FixedTrait.rem(this, other); + } + + neg(): Fixed { + return FixedTrait.neg(this); + } +} + +export class FixedTrait { + static ONE_64x64 = ONE_64x64; + + static ONE = new Fixed(ONE_64x64); + static ZERO = new Fixed(0n); + + /** + * Convert a ratio to a fixed point number + */ + static fromRatio(numerator: bigint, denominator: bigint): Fixed { + return FixedTrait.divi(FixedTrait.fromInt(numerator), FixedTrait.fromInt(denominator)); + } + + /** + * Convert signed integer to 64.64 fixed point + */ + static fromInt(x: bigint): Fixed { + if (x < -0x8000000000000000n || x > 0x7fffffffffffffffn) { + throw new Error("Input out of bounds"); + } + return new Fixed(x << 64n); + } + + /** + * Convert 64.64 fixed point to integer (rounding down) + */ + static toInt(x: Fixed): Fixed { + return new Fixed(x.value >> 64n); + } + + /** + * Convert unsigned integer to 64.64 fixed point + */ + static fromUInt(x: bigint): Fixed { + if (x > 0x7fffffffffffffffn) { + throw new Error("Input out of bounds"); + } + return new Fixed(x << 64n); + } + + /** + * Convert 64.64 fixed point to unsigned integer (rounding down) + */ + static toUInt(x: Fixed): bigint { + if (x.value < 0n) { + throw new Error("Input must be positive"); + } + return x.value >> 64n; + } + + /** + * Add two 64.64 fixed point numbers + */ + static add(x: Fixed, y: Fixed): Fixed { + const result = x.value + y.value; + if (result < MIN_64x64 || result > MAX_64x64) { + throw new Error("Overflow add"); + } + return new Fixed(result); + } + + /** + * Subtract two 64.64 fixed point numbers + */ + static sub(x: Fixed, y: Fixed): Fixed { + const result = x.value - y.value; + if (result < MIN_64x64 || result > MAX_64x64) { + throw new Error("Overflow sub"); + } + return new Fixed(result); + } + + /** + * Multiply two 64.64 fixed point numbers (rounding down) + * Handles sign separately from magnitude for better precision + * + * Note: This is a replica of the way it is handled in the cubit starknet library + * e.g of difference: + * + * ABDKMath64x64: -3952873730080618200n * 24233599860844537324 = 5192914255895257994 + * cubit: -3952873730080618200n * 24233599860844537324 = -5192914255895257993 + */ + static mul(x: Fixed, y: Fixed): Fixed { + // Extract signs + const xNegative = x.value < 0n; + const yNegative = y.value < 0n; + + // Work with absolute values + const xAbs = xNegative ? -x.value : x.value; + const yAbs = yNegative ? -y.value : y.value; + + // Perform multiplication and scaling + const result = (xAbs * yAbs) >> 64n; + + // Apply combined sign + const finalResult = xNegative !== yNegative ? -result : result; + + if (finalResult < MIN_64x64 || finalResult > MAX_64x64) { + throw new Error("Overflow mul"); + } + return new Fixed(finalResult); + } + + /** + * Divide two 64.64 fixed point numbers (rounding down) + * Handles sign separately from magnitude for better precision + * + * Note: This is a modified version of the original div function. + * It handles overflow differently, which may affect the precision. + * + * This is a replica of the way it is handled in the cubit starknet library + * e.g of difference: + */ + static div(x: Fixed, y: Fixed): Fixed { + if (y.value === 0n) { + throw new Error("Division by zero"); + } + + // Extract signs + const xNegative = x.value < 0n; + const yNegative = y.value < 0n; + + // Work with absolute values + const xAbs = xNegative ? -x.value : x.value; + const yAbs = yNegative ? -y.value : y.value; + + // Perform division with scaling + const result = (xAbs << 64n) / yAbs; + + // Apply combined sign + const finalResult = xNegative !== yNegative ? -result : result; + + if (finalResult < MIN_64x64 || finalResult > MAX_64x64) { + throw new Error("Overflow div"); + } + return new Fixed(finalResult); + } + + static divi(x: Fixed, y: Fixed): Fixed { + if (y.value === 0n) { + throw new Error("Division by zero"); + } + + let negativeResult = false; + if (x.value < 0n) { + x.value = -x.value; // We rely on overflow behavior here + negativeResult = true; + } + if (y.value < 0n) { + y.value = -y.value; // We rely on overflow behavior here + negativeResult = !negativeResult; + } + const absoluteResult = FixedTrait.divuu(x, y); + if (negativeResult) { + if (absoluteResult.value <= 0x80000000000000000000000000000000n) { + return new Fixed(-absoluteResult.value); // We rely on overflow behavior here + } else { + throw new Error("Overflow divi"); + } + } + return absoluteResult; + } + + static divuu(x: Fixed, y: Fixed): Fixed { + if (y.value === 0n) { + throw new Error("Division by zero"); + } + + let result; + + if (x.value <= 0xffffffffffffffffffffffffffffffffffffffffffffffffn) { + result = (x.value << 64n) / y.value; + } else { + let msb = 192n; + let xc = x.value >> 192n; + if (xc >= 0x100000000n) { + xc >>= 32n; + msb += 32n; + } + if (xc >= 0x10000n) { + xc >>= 16n; + msb += 16n; + } + if (xc >= 0x100n) { + xc >>= 8n; + msb += 8n; + } + if (xc >= 0x10n) { + xc >>= 4n; + msb += 4n; + } + if (xc >= 0x4n) { + xc >>= 2n; + msb += 2n; + } + if (xc >= 0x2n) msb += 1n; // No need to shift xc anymore + + result = (x.value << (255n - msb)) / (((y.value - 1n) >> (msb - 191n)) + 1n); + if (result > 0xffffffffffffffffffffffffffffffffn) { + throw new Error("Overflow divuu"); + } + + let hi = result * (y.value >> 128n); + let lo = result * (y.value & 0xffffffffffffffffffffffffffffffffn); + + let xh = x.value >> 192n; + let xl = x.value << 64n; + + if (xl < lo) xh -= 1n; + xl -= lo; // We rely on overflow behavior here + lo = hi << 128n; + if (xl < lo) xh -= 1n; + xl -= lo; // We rely on overflow behavior here + + result += xh == hi >> 128n ? xl / y.value : 1n; + } + + if (result > 0xffffffffffffffffffffffffffffffffn) { + throw new Error("Overflow divuu"); + } + return new Fixed(result); + } + + static divu(x: Fixed, y: Fixed): Fixed { + if (y.value === 0n) { + throw new Error("Division by zero"); + } + + const result = FixedTrait.divuu(x, y); + if (result.value > MAX_64x64) { + throw new Error("Overflow divu"); + } + return result; + } + + /** + * Calculate absolute value + */ + static abs(x: Fixed): Fixed { + if (x.value === MIN_64x64) { + throw new Error("Overflow abs"); + } + return x.value < 0n ? new Fixed(-x.value) : x; + } + + /** + * Calculate square root (rounding down) + */ + static sqrt(x: Fixed): Fixed { + if (x.value < 0n) { + throw new Error("Input must be positive"); + } + + if (x.value === 0n) return new Fixed(0n); + + // Initial estimate using integer square root + let r = 1n; + let xx = x.value; + + // Binary search for the square root + if (xx >= 1n << 128n) { + xx >>= 128n; + r <<= 64n; + } + if (xx >= 1n << 64n) { + xx >>= 64n; + r <<= 32n; + } + if (xx >= 1n << 32n) { + xx >>= 32n; + r <<= 16n; + } + if (xx >= 1n << 16n) { + xx >>= 16n; + r <<= 8n; + } + if (xx >= 1n << 8n) { + xx >>= 8n; + r <<= 4n; + } + if (xx >= 1n << 4n) { + xx >>= 4n; + r <<= 2n; + } + if (xx >= 1n << 2n) { + r <<= 1n; + } + + // Newton's method iterations + r = (r + x.value / r) >> 1n; + r = (r + x.value / r) >> 1n; + r = (r + x.value / r) >> 1n; + r = (r + x.value / r) >> 1n; + r = (r + x.value / r) >> 1n; + r = (r + x.value / r) >> 1n; + r = (r + x.value / r) >> 1n; + + const r1 = FixedTrait.div(x, new Fixed(r)); + return r < r1.value ? new Fixed(r) : r1; + } + + static floor(a: Fixed): Fixed { + return new Fixed((a.value >> 64n) * FixedTrait.ONE_64x64); + } + + static mod(a: Fixed, b: Fixed): Fixed { + return new Fixed(a.value % b.value); + } + + static rem(a: Fixed, b: Fixed): Fixed { + return new Fixed(a.value % b.value); + } + + static neg(a: Fixed): Fixed { + return new Fixed(-a.value); + } +} diff --git a/packages/core/src/utils/biome/index.ts b/packages/core/src/utils/biome/index.ts new file mode 100644 index 000000000..d2fd288bb --- /dev/null +++ b/packages/core/src/utils/biome/index.ts @@ -0,0 +1 @@ +export * from "./biome"; diff --git a/packages/core/src/utils/biome/simplex-noise.ts b/packages/core/src/utils/biome/simplex-noise.ts new file mode 100644 index 000000000..3500497ce --- /dev/null +++ b/packages/core/src/utils/biome/simplex-noise.ts @@ -0,0 +1,126 @@ +import { Fixed, FixedTrait } from "./fixed-point"; +import { Vec3 } from "./vec3"; +import { Vec4 } from "./vec4"; + +function permute(x: Vec4): Vec4 { + let v34 = Vec4.splat(new Fixed(627189298506124754944n)); + let v1 = Vec4.splat(FixedTrait.ONE); + let v289 = Vec4.splat(new Fixed(5331109037302060417024n)); + return x.mul(v34).add(v1).mul(x).mod(v289); +} + +function taylor_inv_sqrt(r: Vec4): Vec4 { + let v1: Vec4 = Vec4.splat(new Fixed(33072114398950993631n)); // 1.79284291400159 + let v2: Vec4 = Vec4.splat(new Fixed(15748625904262413056n)); // 0.85373472095314 + return v1.minus(v2.mul(r)); +} + +function step(edge: Fixed, x: Fixed): Fixed { + if (x.value < edge.value) { + return FixedTrait.ZERO; + } else { + return FixedTrait.ONE; + } +} + +function min(a: Fixed, b: Fixed): Fixed { + return a.value < b.value ? a : b; +} + +function max(a: Fixed, b: Fixed): Fixed { + return a.value > b.value ? a : b; +} + +export function noise(v: Vec3): Fixed { + let zero = new Fixed(0n); + let half = new Fixed(9223372036854775808n); // 0.5 + let one = FixedTrait.ONE; + + let Cx = new Fixed(3074457345618258602n); // 1 / 6 + let Cy = new Fixed(6148914691236517205n); // 1 / 3 + + // First corner + let i = v.add(Vec3.splat(v.dot(Vec3.splat(Cy)))).floor(); + let x0 = v.minus(i).add(Vec3.splat(i.dot(Vec3.splat(Cx)))); + + // Other corners + let g = Vec3.new(step(x0.y, x0.x), step(x0.z, x0.y), step(x0.x, x0.z)); + let l = Vec3.splat(one).minus(g); + let i1 = Vec3.new(min(g.x, l.z), min(g.y, l.x), min(g.z, l.y)); + let i2 = Vec3.new(max(g.x, l.z), max(g.y, l.x), max(g.z, l.y)); + + let x1 = Vec3.new(x0.x.sub(i1.x).add(Cx), x0.y.sub(i1.y).add(Cx), x0.z.sub(i1.z).add(Cx)); + let x2 = Vec3.new(x0.x.sub(i2.x).add(Cy), x0.y.sub(i2.y).add(Cy), x0.z.sub(i2.z).add(Cy)); + let x3 = Vec3.new(x0.x.sub(half), x0.y.sub(half), x0.z.sub(half)); + + // Permutations + i = i.remScaler(new Fixed(5331109037302060417024n)); // 289 + let _p1 = permute(Vec4.new(i.z.add(zero), i.z.add(i1.z), i.z.add(i2.z), i.z.add(one))); + let _p2 = permute( + Vec4.new(_p1.x.add(i.y).add(zero), _p1.y.add(i.y).add(i1.y), _p1.z.add(i.y).add(i2.y), _p1.w.add(i.y).add(one)), + ); + let p = permute( + Vec4.new(_p2.x.add(i.x).add(zero), _p2.y.add(i.x).add(i1.x), _p2.z.add(i.x).add(i2.x), _p2.w.add(i.x).add(one)), + ); + + // Gradients: 7x7 points over a square, mapped onto an octahedron. + // The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294) + let ns_x = new Fixed(5270498306774157605n); // 2 / 7 + let ns_y = new Fixed(-17129119497016012214n); // -13 / 14 + let ns_z = new Fixed(2635249153387078803n); // 1 / 7 + + let j = p.remScaler(new Fixed(903890459611768029184n)); // 49 + let x_ = j.mulScalar(ns_z).floor(); + let y_ = j.minus(x_.mulScalar(new Fixed(129127208515966861312n))).floor(); // 7 + + let x = x_.mulScalar(ns_x).addScalar(ns_y); + let y = y_.mulScalar(ns_x).addScalar(ns_y); + let h = Vec4.splat(one).minus(x.abs()).minus(y.abs()); + + let b0 = Vec4.new(x.x, x.y, y.x, y.y); + let b1 = Vec4.new(x.z, x.w, y.z, y.w); + + // vec4 s0 = vec4(lessThan(b0,0.0))*2.0 - 1.0; + // vec4 s1 = vec4(lessThan(b1,0.0))*2.0 - 1.0; + let s0 = b0.floor().mulScalar(new Fixed(36893488147419103232n)).addScalar(one); + let s1 = b1.floor().mulScalar(new Fixed(36893488147419103232n)).addScalar(one); + + let sh = Vec4.new(step(h.x, zero).neg(), step(h.y, zero).neg(), step(h.z, zero).neg(), step(h.w, zero).neg()); + + let a0 = Vec4.new( + b0.x.add(s0.x.mul(sh.x)), + b0.z.add(s0.z.mul(sh.x)), + b0.y.add(s0.y.mul(sh.y)), + b0.w.add(s0.w.mul(sh.y)), + ); + let a1 = Vec4.new( + b1.x.add(s1.x.mul(sh.z)), + b1.z.add(s1.z.mul(sh.z)), + b1.y.add(s1.y.mul(sh.w)), + b1.w.add(s1.w.mul(sh.w)), + ); + + let p0 = Vec3.new(a0.x, a0.y, h.x); + let p1 = Vec3.new(a0.z, a0.w, h.y); + let p2 = Vec3.new(a1.x, a1.y, h.z); + let p3 = Vec3.new(a1.z, a1.w, h.w); + + let norm = taylor_inv_sqrt(Vec4.new(p0.dot(p0), p1.dot(p1), p2.dot(p2), p3.dot(p3))); + p0 = Vec3.new(p0.x.mul(norm.x), p0.y.mul(norm.x), p0.z.mul(norm.x)); + p1 = Vec3.new(p1.x.mul(norm.y), p1.y.mul(norm.y), p1.z.mul(norm.y)); + p2 = Vec3.new(p2.x.mul(norm.z), p2.y.mul(norm.z), p2.z.mul(norm.z)); + p3 = Vec3.new(p3.x.mul(norm.w), p3.y.mul(norm.w), p3.z.mul(norm.w)); + + let m = Vec4.new( + max(half.sub(x0.dot(x0)), zero), + max(half.sub(x1.dot(x1)), zero), + max(half.sub(x2.dot(x2)), zero), + max(half.sub(x3.dot(x3)), zero), + ); + + // m = (m * m) * (m * m); + m = m.mul(m).mul(m.mul(m)); + + let _105 = FixedTrait.fromInt(105n); + return _105.mul(m.dot(Vec4.new(p0.dot(x0), p1.dot(x1), p2.dot(x2), p3.dot(x3)))); +} diff --git a/packages/core/src/utils/biome/vec3.ts b/packages/core/src/utils/biome/vec3.ts new file mode 100644 index 000000000..0e9a68ab4 --- /dev/null +++ b/packages/core/src/utils/biome/vec3.ts @@ -0,0 +1,67 @@ +import { Fixed } from "./fixed-point"; +export class Vec3 { + constructor( + public x: Fixed, + public y: Fixed, + public z: Fixed, + ) {} + + static new(x: Fixed, y: Fixed, z: Fixed): Vec3 { + return new Vec3(x, y, z); + } + + static splat(v: Fixed): Vec3 { + return new Vec3(v, v, v); + } + + dot(other: Vec3): Fixed { + let x = this.x.mul(other.x); + let y = this.y.mul(other.y); + let z = this.z.mul(other.z); + return x.add(y).add(z); + } + + minus(other: Vec3): Vec3 { + return new Vec3(this.x.sub(other.x), this.y.sub(other.y), this.z.sub(other.z)); + } + + add(other: Vec3): Vec3 { + return new Vec3(this.x.add(other.x), this.y.add(other.y), this.z.add(other.z)); + } + + mul(other: Vec3): Vec3 { + return new Vec3(this.x.mul(other.x), this.y.mul(other.y), this.z.mul(other.z)); + } + + mod(other: Vec3): Vec3 { + return new Vec3(this.x.mod(other.x), this.y.mod(other.y), this.z.mod(other.z)); + } + + div(other: Vec3): Vec3 { + return new Vec3(this.x.div(other.x), this.y.div(other.y), this.z.div(other.z)); + } + + floor(): Vec3 { + return new Vec3(this.x.floor(), this.y.floor(), this.z.floor()); + } + + remScaler(scalar: Fixed): Vec3 { + return new Vec3(this.x.mod(scalar), this.y.mod(scalar), this.z.mod(scalar)); + } + + divScalar(scalar: Fixed): Vec3 { + return new Vec3(this.x.div(scalar), this.y.div(scalar), this.z.div(scalar)); + } + + mulScalar(scalar: Fixed): Vec3 { + return new Vec3(this.x.mul(scalar), this.y.mul(scalar), this.z.mul(scalar)); + } + + addScalar(scalar: Fixed): Vec3 { + return new Vec3(this.x.add(scalar), this.y.add(scalar), this.z.add(scalar)); + } + + abs(): Vec3 { + return new Vec3(this.x.abs(), this.y.abs(), this.z.abs()); + } +} diff --git a/packages/core/src/utils/biome/vec4.ts b/packages/core/src/utils/biome/vec4.ts new file mode 100644 index 000000000..818991b98 --- /dev/null +++ b/packages/core/src/utils/biome/vec4.ts @@ -0,0 +1,70 @@ +import { Fixed } from "./fixed-point"; + +export class Vec4 { + constructor( + public x: Fixed, + public y: Fixed, + public z: Fixed, + public w: Fixed, + ) {} + + static new(x: Fixed, y: Fixed, z: Fixed, w: Fixed): Vec4 { + return new Vec4(x, y, z, w); + } + + static splat(v: Fixed): Vec4 { + return new Vec4(v, v, v, v); + } + + dot(other: Vec4): Fixed { + let x = this.x.mul(other.x); + let y = this.y.mul(other.y); + let z = this.z.mul(other.z); + let w = this.w.mul(other.w); + return x.add(y).add(z).add(w); + } + + minus(other: Vec4): Vec4 { + return new Vec4(this.x.sub(other.x), this.y.sub(other.y), this.z.sub(other.z), this.w.sub(other.w)); + } + + add(other: Vec4): Vec4 { + return new Vec4(this.x.add(other.x), this.y.add(other.y), this.z.add(other.z), this.w.add(other.w)); + } + + mul(other: Vec4): Vec4 { + return new Vec4(this.x.mul(other.x), this.y.mul(other.y), this.z.mul(other.z), this.w.mul(other.w)); + } + + mod(other: Vec4): Vec4 { + return new Vec4(this.x.mod(other.x), this.y.mod(other.y), this.z.mod(other.z), this.w.mod(other.w)); + } + + div(other: Vec4): Vec4 { + return new Vec4(this.x.div(other.x), this.y.div(other.y), this.z.div(other.z), this.w.div(other.w)); + } + + floor(): Vec4 { + return new Vec4(this.x.floor(), this.y.floor(), this.z.floor(), this.w.floor()); + } + + remScaler(scalar: Fixed): Vec4 { + return new Vec4(this.x.mod(scalar), this.y.mod(scalar), this.z.mod(scalar), this.w.mod(scalar)); + } + + divScalar(scalar: Fixed): Vec4 { + return new Vec4(this.x.div(scalar), this.y.div(scalar), this.z.div(scalar), this.w.div(scalar)); + } + + mulScalar(scalar: Fixed): Vec4 { + return new Vec4(this.x.mul(scalar), this.y.mul(scalar), this.z.mul(scalar), this.w.mul(scalar)); + } + + addScalar(scalar: Fixed): Vec4 { + return new Vec4(this.x.add(scalar), this.y.add(scalar), this.z.add(scalar), this.w.add(scalar)); + } + + abs(): Vec4 { + return new Vec4(this.x.abs(), this.y.abs(), this.z.abs(), this.w.abs()); + } +} diff --git a/packages/core/src/utils/combat-simulator.ts b/packages/core/src/utils/combat-simulator.ts index 2e485a16e..5e0bad81d 100644 --- a/packages/core/src/utils/combat-simulator.ts +++ b/packages/core/src/utils/combat-simulator.ts @@ -1,3 +1,6 @@ +import { BiomeType } from "../constants"; +import { TroopType } from "../types"; + export class Percentage { static _100() { return 10_000; @@ -7,32 +10,6 @@ export class Percentage { return (value * numerator) / Percentage._100(); } } - -export enum TroopType { - KNIGHT = "knight", - CROSSBOWMAN = "crossbowman", - PALADIN = "paladin", -} - -export enum Biome { - OCEAN = "ocean", - DEEP_OCEAN = "deep_ocean", - BEACH = "beach", - GRASSLAND = "grassland", - SHRUBLAND = "shrubland", - SUBTROPICAL_DESERT = "subtropical_desert", - TEMPERATE_DESERT = "temperate_desert", - TROPICAL_RAINFOREST = "tropical_rainforest", - TROPICAL_SEASONAL_FOREST = "tropical_seasonal_forest", - TEMPERATE_RAINFOREST = "temperate_rainforest", - TEMPERATE_DECIDUOUS_FOREST = "temperate_deciduous_forest", - TUNDRA = "tundra", - TAIGA = "taiga", - SNOW = "snow", - BARE = "bare", - SCORCHED = "scorched", -} - export interface Army { stamina: number; troopCount: number; @@ -62,32 +39,40 @@ export class CombatSimulator { private static readonly C0 = 100_000; // Transition point private static readonly DELTA = 50_000; // Transition width - public static getBiomeBonus(troopType: TroopType, biome: Biome): number { - const biomeModifiers: Record> = { - [Biome.OCEAN]: { [TroopType.KNIGHT]: 0, [TroopType.CROSSBOWMAN]: 0.3, [TroopType.PALADIN]: -0.3 }, - [Biome.DEEP_OCEAN]: { [TroopType.KNIGHT]: 0, [TroopType.CROSSBOWMAN]: 0.3, [TroopType.PALADIN]: -0.3 }, - [Biome.BEACH]: { [TroopType.KNIGHT]: -0.3, [TroopType.CROSSBOWMAN]: 0.3, [TroopType.PALADIN]: 0 }, - [Biome.GRASSLAND]: { [TroopType.KNIGHT]: 0, [TroopType.CROSSBOWMAN]: -0.3, [TroopType.PALADIN]: 0.3 }, - [Biome.SHRUBLAND]: { [TroopType.KNIGHT]: 0, [TroopType.CROSSBOWMAN]: -0.3, [TroopType.PALADIN]: 0.3 }, - [Biome.SUBTROPICAL_DESERT]: { [TroopType.KNIGHT]: -0.3, [TroopType.CROSSBOWMAN]: 0, [TroopType.PALADIN]: 0.3 }, - [Biome.TEMPERATE_DESERT]: { [TroopType.KNIGHT]: -0.3, [TroopType.CROSSBOWMAN]: 0, [TroopType.PALADIN]: 0.3 }, - [Biome.TROPICAL_RAINFOREST]: { [TroopType.KNIGHT]: 0.3, [TroopType.CROSSBOWMAN]: 0, [TroopType.PALADIN]: -0.3 }, - [Biome.TROPICAL_SEASONAL_FOREST]: { - [TroopType.KNIGHT]: 0.3, - [TroopType.CROSSBOWMAN]: 0, - [TroopType.PALADIN]: -0.3, + public static getBiomeBonus(troopType: TroopType, biome: BiomeType): number { + const biomeModifiers: Record> = { + [BiomeType.Ocean]: { [TroopType.Knight]: 0, [TroopType.Crossbowman]: 0.3, [TroopType.Paladin]: -0.3 }, + [BiomeType.DeepOcean]: { [TroopType.Knight]: 0, [TroopType.Crossbowman]: 0.3, [TroopType.Paladin]: -0.3 }, + [BiomeType.Beach]: { [TroopType.Knight]: -0.3, [TroopType.Crossbowman]: 0.3, [TroopType.Paladin]: 0 }, + [BiomeType.Grassland]: { [TroopType.Knight]: 0, [TroopType.Crossbowman]: -0.3, [TroopType.Paladin]: 0.3 }, + [BiomeType.Shrubland]: { [TroopType.Knight]: 0, [TroopType.Crossbowman]: -0.3, [TroopType.Paladin]: 0.3 }, + [BiomeType.SubtropicalDesert]: { [TroopType.Knight]: -0.3, [TroopType.Crossbowman]: 0, [TroopType.Paladin]: 0.3 }, + [BiomeType.TemperateDesert]: { [TroopType.Knight]: -0.3, [TroopType.Crossbowman]: 0, [TroopType.Paladin]: 0.3 }, + [BiomeType.TropicalRainForest]: { + [TroopType.Knight]: 0.3, + [TroopType.Crossbowman]: 0, + [TroopType.Paladin]: -0.3, + }, + [BiomeType.TropicalSeasonalForest]: { + [TroopType.Knight]: 0.3, + [TroopType.Crossbowman]: 0, + [TroopType.Paladin]: -0.3, + }, + [BiomeType.TemperateRainForest]: { + [TroopType.Knight]: 0.3, + [TroopType.Crossbowman]: 0, + [TroopType.Paladin]: -0.3, }, - [Biome.TEMPERATE_RAINFOREST]: { [TroopType.KNIGHT]: 0.3, [TroopType.CROSSBOWMAN]: 0, [TroopType.PALADIN]: -0.3 }, - [Biome.TEMPERATE_DECIDUOUS_FOREST]: { - [TroopType.KNIGHT]: 0.3, - [TroopType.CROSSBOWMAN]: 0, - [TroopType.PALADIN]: -0.3, + [BiomeType.TemperateDeciduousForest]: { + [TroopType.Knight]: 0.3, + [TroopType.Crossbowman]: 0, + [TroopType.Paladin]: -0.3, }, - [Biome.TUNDRA]: { [TroopType.KNIGHT]: -0.3, [TroopType.CROSSBOWMAN]: 0, [TroopType.PALADIN]: 0.3 }, - [Biome.TAIGA]: { [TroopType.KNIGHT]: 0.3, [TroopType.CROSSBOWMAN]: 0, [TroopType.PALADIN]: -0.3 }, - [Biome.SNOW]: { [TroopType.KNIGHT]: -0.3, [TroopType.CROSSBOWMAN]: 0.3, [TroopType.PALADIN]: 0 }, - [Biome.BARE]: { [TroopType.KNIGHT]: 0, [TroopType.CROSSBOWMAN]: -0.3, [TroopType.PALADIN]: 0.3 }, - [Biome.SCORCHED]: { [TroopType.KNIGHT]: 0.3, [TroopType.CROSSBOWMAN]: 0, [TroopType.PALADIN]: -0.3 }, + [BiomeType.Tundra]: { [TroopType.Knight]: -0.3, [TroopType.Crossbowman]: 0, [TroopType.Paladin]: 0.3 }, + [BiomeType.Taiga]: { [TroopType.Knight]: 0.3, [TroopType.Crossbowman]: 0, [TroopType.Paladin]: -0.3 }, + [BiomeType.Snow]: { [TroopType.Knight]: -0.3, [TroopType.Crossbowman]: 0.3, [TroopType.Paladin]: 0 }, + [BiomeType.Bare]: { [TroopType.Knight]: 0, [TroopType.Crossbowman]: -0.3, [TroopType.Paladin]: 0.3 }, + [BiomeType.Scorched]: { [TroopType.Knight]: 0.3, [TroopType.Crossbowman]: 0, [TroopType.Paladin]: -0.3 }, }; return 1 + (biomeModifiers[biome]?.[troopType] ?? 0); @@ -126,7 +111,7 @@ export class CombatSimulator { public static simulateBattle( attacker: Army, defender: Army, - biome: Biome, + biome: BiomeType, ): { attackerDamage: number; defenderDamage: number } { const totalTroops = attacker.troopCount + defender.troopCount; const betaEff = this.calculateEffectiveBeta(totalTroops); @@ -176,7 +161,7 @@ export class CombatSimulator { public static simulateBattleWithParams( attacker: Army, defender: Army, - biome: Biome, + biome: BiomeType, params: CombatParameters, ): { attackerDamage: number; defenderDamage: number } { const totalTroops = attacker.troopCount + defender.troopCount; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index eb62018cc..c1d2b4ab3 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./army"; +export * from "./biome"; export * from "./combat-simulator"; export * from "./entities"; export * from "./guild"; @@ -10,4 +11,5 @@ export * from "./resources"; export * from "./structure"; export * from "./trades"; export * from "./transport"; +export * from "./travel-path"; export * from "./utils"; diff --git a/packages/core/src/utils/travel-path.ts b/packages/core/src/utils/travel-path.ts new file mode 100644 index 000000000..6272b067d --- /dev/null +++ b/packages/core/src/utils/travel-path.ts @@ -0,0 +1,51 @@ +import { FELT_CENTER } from "../constants"; +import { HexPosition, HexTileInfo } from "../types"; + +export class TravelPaths { + private readonly paths: Map; + + constructor() { + this.paths = new Map(); + } + + set(key: string, value: { path: HexTileInfo[]; isExplored: boolean }): void { + this.paths.set(key, value); + } + + deleteAll(): void { + this.paths.clear(); + } + + get(key: string): { path: HexTileInfo[]; isExplored: boolean } | undefined { + return this.paths.get(key); + } + + has(key: string): boolean { + return this.paths.has(key); + } + + values(): IterableIterator<{ path: HexTileInfo[]; isExplored: boolean }> { + return this.paths.values(); + } + + getHighlightedHexes(): Array<{ col: number; row: number }> { + return Array.from(this.paths.values()).map(({ path }) => ({ + col: path[path.length - 1].col - FELT_CENTER, + row: path[path.length - 1].row - FELT_CENTER, + })); + } + + isHighlighted(row: number, col: number): boolean { + return this.paths.has(TravelPaths.posKey({ col: col + FELT_CENTER, row: row + FELT_CENTER })); + } + + getPaths(): Map { + return this.paths; + } + + static posKey(pos: HexPosition, normalized = false): string { + const col = normalized ? pos.col + FELT_CENTER : pos.col; + const row = normalized ? pos.row + FELT_CENTER : pos.row; + return `${col},${row}`; + } +}