├── public ├── assets │ ├── sprites │ │ ├── tree1.png │ │ ├── tree2.png │ │ ├── tree3.png │ │ ├── tree4.png │ │ ├── tree5.png │ │ ├── tree6.png │ │ ├── tree7.png │ │ ├── tree8.png │ │ ├── tree9.png │ │ ├── sawmill.png │ │ ├── tree10.png │ │ ├── warehouse.png │ │ ├── woodcutter.png │ │ ├── construction.png │ │ ├── plank_carried.png │ │ └── planks_on_ground.png │ └── textures │ │ ├── grass1.png │ │ ├── grass2.png │ │ ├── grass3.png │ │ ├── grass4.png │ │ ├── grass5.png │ │ ├── grass6.png │ │ ├── grass7.png │ │ ├── grass8.png │ │ ├── grass9.png │ │ └── grass10.png └── css │ └── style.css ├── vite.config.js ├── src └── js │ ├── main.js │ ├── entities │ ├── Entity.js │ ├── buildings │ │ ├── Woodcutter.js │ │ ├── Warehouse.js │ │ └── Construction.js │ ├── Building.js │ ├── settlers │ │ ├── Builder.js │ │ └── Porter.js │ └── Settler.js │ ├── utils │ ├── Enums.js │ ├── PerlinNoise.js │ ├── MapGen.js │ ├── Constants.js │ └── PathFinding.js │ ├── core │ ├── ResourceManager.js │ ├── Game.js │ └── World.js │ └── ui │ └── UIManager.js ├── package.json ├── index.html ├── prompts.md ├── LICENSE ├── README.md └── .gitignore /public/assets/sprites/tree1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree1.png -------------------------------------------------------------------------------- /public/assets/sprites/tree2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree2.png -------------------------------------------------------------------------------- /public/assets/sprites/tree3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree3.png -------------------------------------------------------------------------------- /public/assets/sprites/tree4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree4.png -------------------------------------------------------------------------------- /public/assets/sprites/tree5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree5.png -------------------------------------------------------------------------------- /public/assets/sprites/tree6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree6.png -------------------------------------------------------------------------------- /public/assets/sprites/tree7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree7.png -------------------------------------------------------------------------------- /public/assets/sprites/tree8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree8.png -------------------------------------------------------------------------------- /public/assets/sprites/tree9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree9.png -------------------------------------------------------------------------------- /public/assets/sprites/sawmill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/sawmill.png -------------------------------------------------------------------------------- /public/assets/sprites/tree10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/tree10.png -------------------------------------------------------------------------------- /public/assets/textures/grass1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass1.png -------------------------------------------------------------------------------- /public/assets/textures/grass2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass2.png -------------------------------------------------------------------------------- /public/assets/textures/grass3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass3.png -------------------------------------------------------------------------------- /public/assets/textures/grass4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass4.png -------------------------------------------------------------------------------- /public/assets/textures/grass5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass5.png -------------------------------------------------------------------------------- /public/assets/textures/grass6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass6.png -------------------------------------------------------------------------------- /public/assets/textures/grass7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass7.png -------------------------------------------------------------------------------- /public/assets/textures/grass8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass8.png -------------------------------------------------------------------------------- /public/assets/textures/grass9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass9.png -------------------------------------------------------------------------------- /public/assets/sprites/warehouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/warehouse.png -------------------------------------------------------------------------------- /public/assets/sprites/woodcutter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/woodcutter.png -------------------------------------------------------------------------------- /public/assets/textures/grass10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/textures/grass10.png -------------------------------------------------------------------------------- /public/assets/sprites/construction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/construction.png -------------------------------------------------------------------------------- /public/assets/sprites/plank_carried.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/plank_carried.png -------------------------------------------------------------------------------- /public/assets/sprites/planks_on_ground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/serfers/main/public/assets/sprites/planks_on_ground.png -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | export default { 3 | root: '.', 4 | build: { 5 | outDir: 'dist', 6 | }, 7 | server: { 8 | port: 3000, 9 | open: true 10 | } 11 | } -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | import { Game } from './core/Game.js'; 2 | 3 | // Initialize the game 4 | document.addEventListener('DOMContentLoaded', () => { 5 | const game = new Game(); 6 | game.init(); 7 | game.start(); 8 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isometric-settlers", 3 | "version": "0.1.0", 4 | "description": "An isometric strategy game inspired by The Settlers", 5 | "main": "index.html", 6 | "scripts": { 7 | "start": "npx vite" 8 | }, 9 | "dependencies": { 10 | "serve": "^14.2.0", 11 | "three": "^0.175.0" 12 | }, 13 | "devDependencies": { 14 | "vite": "^6.2.3" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/js/entities/Entity.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | export class Entity { 4 | constructor(world, x, y) { 5 | this.world = world; 6 | this.position = { x, y }; 7 | this.mesh = null; 8 | } 9 | 10 | init() { 11 | // Abstract method to be implemented by subclasses 12 | } 13 | 14 | update() { 15 | // Abstract method to be implemented by subclasses 16 | } 17 | 18 | createMesh() { 19 | // Abstract method to be implemented by subclasses 20 | } 21 | 22 | destroy() { 23 | if (this.mesh) { 24 | this.world.scene.remove(this.mesh); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Isometric Settlers 8 | 9 | 10 | 11 | 12 |
13 | 14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /prompts.md: -------------------------------------------------------------------------------- 1 | isometric 3d asset for a medival strategy game of the outside of a woodcutters hut on a grassy plot, solid white background, a 1x1 square, without ground underneath 2 | 3 | sprite animation sheet with 8 steps for a guy settler walking, he's not holding anything, solid white background, no shadows, the animation shows the separate steps of walking with arms and legs swinging 4 | 5 | isometric 45deg angle 3d asset for a medival strategy game of a very sparse patch of forest on a grassy plot, solid white background, a 1x1 square, without ground underneath the grass 6 | 7 | isometric 45deg angle 3d asset for a medival strategy game of a a couple small trees on a grassy plot, solid white background, a 1x1 square, without ground underneath the grass 8 | 9 | isometric 45deg angle 3d asset for a medival strategy game of a forest patch, solid white background, a 1x1 square, without ground underneath the grass, fully straight edges 10 | 11 | isometric 3d asset for a medival strategy game of the outside of a woodcutters hut on a grassy plot, solid white background, a 1x1 square, without ground underneath 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Roy Shilkrot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/js/utils/Enums.js: -------------------------------------------------------------------------------- 1 | // Terrain types 2 | export const TerrainType = { 3 | GRASS: 'grass', 4 | WATER: 'water', 5 | STONE: 'stone', 6 | MOUNTAIN: 'mountain', 7 | FOREST: 'forest', 8 | SAND: 'sand', 9 | SNOW: 'snow' 10 | }; 11 | 12 | // Resource types 13 | export const ResourceType = { 14 | WOOD: 'wood', 15 | STONE: 'stone', 16 | IRON_ORE: 'iron_ore', 17 | IRON: 'iron', 18 | PLANK: 'plank', 19 | WHEAT: 'wheat', 20 | FLOUR: 'flour', 21 | BREAD: 'bread', 22 | MEAT: 'meat' 23 | }; 24 | 25 | // Building types 26 | export const BuildingType = { 27 | WAREHOUSE: 'warehouse', 28 | WOODCUTTER: 'woodcutter', 29 | SAWMILL: 'sawmill', 30 | STONEMASON: 'stonemason', 31 | MINE: 'mine', 32 | IRONSMITH: 'ironsmith', 33 | FARM: 'farm', 34 | MILL: 'mill', 35 | BAKERY: 'bakery', 36 | HUNTER: 'hunter' 37 | }; 38 | 39 | // Settler types (jobs) 40 | export const SettlerType = { 41 | PORTER: 'porter', 42 | BUILDER: 'builder', 43 | WOODCUTTER: 'woodcutter', 44 | STONEMASON: 'stonemason', 45 | MINER: 'miner', 46 | BLACKSMITH: 'blacksmith', 47 | FARMER: 'farmer', 48 | MILLER: 'miller', 49 | BAKER: 'baker', 50 | HUNTER: 'hunter', 51 | DIGGER: 'digger' 52 | }; 53 | 54 | // Fog of War visibility states 55 | export const VisibilityState = { 56 | UNEXPLORED: 'unexplored', // Never seen, completely black 57 | EXPLORED: 'explored', // Previously seen but not in range of a building, darkened/grayed out 58 | VISIBLE: 'visible' // Currently visible, normal display 59 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Isometric Settlers 2 | 3 | A browser-based isometric strategy game inspired by "The Settlers", built with Three.js. 4 | 5 | ## Overview 6 | 7 | This game simulates an agrarian economy where you manage resources, build structures, and coordinate settlers with different jobs to grow your colony. 8 | 9 | Features: 10 | - Isometric 3D view with simple graphics 11 | - Resource gathering and production chains 12 | - Different types of buildings with unique functions 13 | - Settlers with various jobs (woodcutters, porters, builders, etc.) 14 | - Terrain that affects building placement 15 | 16 | ## How to Run 17 | 18 | 1. Install dependencies: 19 | ``` 20 | npm install 21 | ``` 22 | 23 | 2. Start the local server: 24 | ``` 25 | npm start 26 | ``` 27 | 28 | 3. The game should automatically open in your browser at http://localhost:3000 29 | 30 | ## Game Mechanics 31 | 32 | ### Resources 33 | - Wood, Stone, Iron Ore, Iron, Planks, Wheat, Flour, Bread, Meat 34 | 35 | ### Buildings 36 | - Warehouse: Central storage for all resources 37 | - Woodcutter's Hut: Produces wood 38 | - Sawmill: Converts wood into planks 39 | - Stonemason's Hut: Produces stone 40 | - Mine: Produces iron ore 41 | - Ironsmith's Forge: Converts iron ore into iron 42 | - Farm: Produces wheat 43 | - Mill: Converts wheat into flour 44 | - Bakery: Converts flour into bread 45 | - Hunter's Hut: Produces meat 46 | 47 | ### Settlers 48 | - Porters: Carry resources between buildings 49 | - Builders: Construct and upgrade buildings 50 | - Specialized workers: Operate specific buildings 51 | 52 | ## Development 53 | 54 | This project uses: 55 | - Three.js for 3D rendering 56 | - Pure JavaScript for game logic 57 | - HTML/CSS for UI 58 | 59 | ## Future Plans 60 | - Add more building types 61 | - Improve settler AI and pathfinding 62 | - Add combat mechanics 63 | - Implement multiplayer functionality 64 | 65 | ## License 66 | MIT -------------------------------------------------------------------------------- /src/js/core/ResourceManager.js: -------------------------------------------------------------------------------- 1 | import { ResourceType } from '../utils/Enums.js'; 2 | 3 | export class ResourceManager { 4 | constructor() { 5 | // Initialize resources with starting values 6 | this.resources = { 7 | [ResourceType.WOOD]: 20, 8 | [ResourceType.STONE]: 10, 9 | [ResourceType.IRON_ORE]: 0, 10 | [ResourceType.IRON]: 0, 11 | [ResourceType.PLANK]: 10, 12 | [ResourceType.WHEAT]: 0, 13 | [ResourceType.FLOUR]: 0, 14 | [ResourceType.BREAD]: 10, 15 | [ResourceType.MEAT]: 5 16 | }; 17 | 18 | this.listeners = []; 19 | } 20 | 21 | getResource(type) { 22 | return this.resources[type] || 0; 23 | } 24 | 25 | addResource(type, amount) { 26 | if (!this.resources[type]) { 27 | this.resources[type] = 0; 28 | } 29 | 30 | this.resources[type] += amount; 31 | this._notifyListeners(); 32 | return true; 33 | } 34 | 35 | removeResource(type, amount) { 36 | if (!this.resources[type] || this.resources[type] < amount) { 37 | return false; 38 | } 39 | 40 | this.resources[type] -= amount; 41 | this._notifyListeners(); 42 | return true; 43 | } 44 | 45 | // Consume a single resource of a specific type (used for construction) 46 | consumeResource(type, amount) { 47 | return this.removeResource(type, amount); 48 | } 49 | 50 | hasResources(requirements) { 51 | for (const [type, amount] of Object.entries(requirements)) { 52 | if (!this.resources[type] || this.resources[type] < amount) { 53 | return false; 54 | } 55 | } 56 | 57 | return true; 58 | } 59 | 60 | consumeResources(requirements) { 61 | if (!this.hasResources(requirements)) { 62 | return false; 63 | } 64 | 65 | for (const [type, amount] of Object.entries(requirements)) { 66 | this.resources[type] -= amount; 67 | } 68 | 69 | this._notifyListeners(); 70 | return true; 71 | } 72 | 73 | addListener(callback) { 74 | this.listeners.push(callback); 75 | } 76 | 77 | removeListener(callback) { 78 | this.listeners = this.listeners.filter(listener => listener !== callback); 79 | } 80 | 81 | _notifyListeners() { 82 | for (const listener of this.listeners) { 83 | listener(this.resources); 84 | } 85 | } 86 | 87 | getAllResources() { 88 | return { ...this.resources }; 89 | } 90 | } -------------------------------------------------------------------------------- /src/js/utils/PerlinNoise.js: -------------------------------------------------------------------------------- 1 | // A simple implementation of Perlin noise for JavaScript 2 | // Adapted from https://github.com/josephg/noisejs 3 | 4 | export class PerlinNoise { 5 | constructor(seed) { 6 | this.seed = seed || Math.random(); 7 | this.gradients = {}; 8 | this.memory = {}; 9 | } 10 | 11 | _random() { 12 | let s = this.seed + 1; 13 | s = Math.sin(s) * 10000; 14 | return s - Math.floor(s); 15 | } 16 | 17 | _dotProductI(ix, iy, x, y) { 18 | // Get cached gradient or compute it 19 | const key = ix + ',' + iy; 20 | let gradient; 21 | 22 | if (key in this.gradients) { 23 | gradient = this.gradients[key]; 24 | } else { 25 | // Random angle 26 | const theta = 2 * Math.PI * this._random(); 27 | gradient = { 28 | x: Math.cos(theta), 29 | y: Math.sin(theta) 30 | }; 31 | this.gradients[key] = gradient; 32 | } 33 | 34 | // Calculate dot product 35 | const dx = x - ix; 36 | const dy = y - iy; 37 | return dx * gradient.x + dy * gradient.y; 38 | } 39 | 40 | // Smoothing function 41 | _smootherstep(t) { 42 | return t * t * t * (t * (t * 6 - 15) + 10); 43 | } 44 | 45 | // Linear interpolation 46 | _lerp(a, b, t) { 47 | return a + t * (b - a); 48 | } 49 | 50 | noise(x, y) { 51 | // Unit square coordinates 52 | const x0 = Math.floor(x); 53 | const y0 = Math.floor(y); 54 | const x1 = x0 + 1; 55 | const y1 = y0 + 1; 56 | 57 | // Get dot products for each corner 58 | const n0 = this._dotProductI(x0, y0, x, y); 59 | const n1 = this._dotProductI(x1, y0, x, y); 60 | const n2 = this._dotProductI(x0, y1, x, y); 61 | const n3 = this._dotProductI(x1, y1, x, y); 62 | 63 | // Apply smoothing to coordinates 64 | const sx = this._smootherstep(x - x0); 65 | const sy = this._smootherstep(y - y0); 66 | 67 | // Interpolate along x 68 | const nx0 = this._lerp(n0, n1, sx); 69 | const nx1 = this._lerp(n2, n3, sx); 70 | 71 | // Interpolate along y 72 | const result = this._lerp(nx0, nx1, sy); 73 | 74 | // Return a value between -1 and 1 75 | return result; 76 | } 77 | 78 | // Generate noise at multiple frequencies 79 | fbm(x, y, octaves = 6, lacunarity = 2, persistence = 0.5) { 80 | let value = 0; 81 | let amplitude = 1; 82 | let frequency = 1; 83 | let max = 0; 84 | 85 | // Sum multiple noise values at different frequencies 86 | for (let i = 0; i < octaves; i++) { 87 | value += this.noise(x * frequency, y * frequency) * amplitude; 88 | max += amplitude; 89 | amplitude *= persistence; 90 | frequency *= lacunarity; 91 | } 92 | 93 | // Normalize to [0, 1] 94 | return (value / max + 1) / 2; 95 | } 96 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | # Dependencies 139 | node_modules/ 140 | package-lock.json 141 | 142 | # IDE files 143 | .vscode/ 144 | .idea/ 145 | *.sublime-* 146 | 147 | # OS files 148 | .DS_Store 149 | Thumbs.db 150 | 151 | # Build artifacts 152 | dist/ 153 | build/ 154 | 155 | # Logs 156 | *.log 157 | npm-debug.log* -------------------------------------------------------------------------------- /src/js/utils/MapGen.js: -------------------------------------------------------------------------------- 1 | import { TerrainType } from "./Enums"; 2 | 3 | export const generateTileMap = (width = 30, height = 30, options = {}) => { 4 | // Default terrain distribution 5 | const terrainOptions = { 6 | grassPercentage: options.grassPercentage || 55, 7 | forestPercentage: options.forestPercentage || 35, 8 | lakePercentage: options.lakePercentage || 5, 9 | mountainPercentage: options.mountainPercentage || 10, 10 | clusteringFactor: options.clusteringFactor || 0.95, 11 | smoothingPasses: options.smoothingPasses || 3 12 | }; 13 | 14 | // Initialize empty map 15 | const map = Array(height).fill().map(() => Array(width).fill(TerrainType.GRASS)); 16 | 17 | // Create initial random noise 18 | for (let y = 0; y < height; y++) { 19 | for (let x = 0; x < width; x++) { 20 | const random = Math.random() * 100; 21 | 22 | if (random < terrainOptions.grassPercentage) { 23 | map[y][x] = TerrainType.GRASS; 24 | } else if (random < terrainOptions.grassPercentage + terrainOptions.forestPercentage) { 25 | map[y][x] = TerrainType.FOREST; 26 | } else if (random < terrainOptions.grassPercentage + terrainOptions.forestPercentage + terrainOptions.lakePercentage) { 27 | map[y][x] = TerrainType.WATER; 28 | } else { 29 | map[y][x] = TerrainType.MOUNTAIN; 30 | } 31 | } 32 | } 33 | 34 | // Apply cellular automata for more natural clustering 35 | for (let i = 0; i < terrainOptions.smoothingPasses; i++) { 36 | applyCellularAutomata(map, terrainOptions.clusteringFactor); 37 | } 38 | 39 | return map.map(row => row.map(tile => ({ 40 | type: tile, 41 | height: 1.0, 42 | }))); 43 | }; 44 | 45 | const applyCellularAutomata = (map, clusteringFactor) => { 46 | const height = map.length; 47 | const width = map[0].length; 48 | const newMap = JSON.parse(JSON.stringify(map)); 49 | 50 | for (let y = 0; y < height; y++) { 51 | for (let x = 0; x < width; x++) { 52 | const neighbors = getNeighbors(map, x, y); 53 | const currentTile = map[y][x]; 54 | 55 | // Count occurrences of each terrain type in the neighbors 56 | const counts = neighbors.reduce((acc, terrain) => { 57 | acc[terrain] = (acc[terrain] || 0) + 1; 58 | return acc; 59 | }, {}); 60 | 61 | // Determine the most common terrain around this tile 62 | let mostCommonTerrain = currentTile; 63 | let maxCount = 0; 64 | 65 | for (const terrain in counts) { 66 | if (counts[terrain] > maxCount) { 67 | maxCount = counts[terrain]; 68 | mostCommonTerrain = terrain; 69 | } 70 | } 71 | 72 | // Apply clustering based on the factor 73 | if (Math.random() < clusteringFactor) { 74 | newMap[y][x] = mostCommonTerrain; 75 | } 76 | } 77 | } 78 | 79 | // Apply changes back to the original map 80 | for (let y = 0; y < height; y++) { 81 | for (let x = 0; x < width; x++) { 82 | map[y][x] = newMap[y][x]; 83 | } 84 | } 85 | }; 86 | 87 | const getNeighbors = (map, x, y) => { 88 | const height = map.length; 89 | const width = map[0].length; 90 | const neighbors = []; 91 | 92 | // Get the 8 surrounding tiles 93 | for (let ny = Math.max(0, y - 1); ny <= Math.min(height - 1, y + 1); ny++) { 94 | for (let nx = Math.max(0, x - 1); nx <= Math.min(width - 1, x + 1); nx++) { 95 | if (nx !== x || ny !== y) { // Skip the center tile 96 | neighbors.push(map[ny][nx]); 97 | } 98 | } 99 | } 100 | 101 | return neighbors; 102 | }; -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | overflow: hidden; 9 | font-family: Arial, sans-serif; 10 | } 11 | 12 | #game-container { 13 | position: relative; 14 | width: 100vw; 15 | height: 100vh; 16 | } 17 | 18 | #game-canvas { 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | width: 100%; 23 | height: 100%; 24 | } 25 | 26 | #hud { 27 | position: absolute; 28 | pointer-events: none; /* This allows clicks to pass through to the canvas */ 29 | width: 100%; 30 | height: 100%; 31 | z-index: 10; 32 | top: 0; 33 | left: 0; 34 | } 35 | 36 | .hud-panel { 37 | pointer-events: auto; 38 | background-color: rgba(0, 0, 0, 0.7); 39 | color: white; 40 | border-radius: 5px; 41 | padding: 10px; 42 | } 43 | 44 | #resources-panel { 45 | position: absolute; 46 | top: 10px; 47 | left: 10px; 48 | min-width: 200px; 49 | } 50 | 51 | #building-panel { 52 | position: absolute; 53 | bottom: 10px; 54 | right: 10px; 55 | min-width: 300px; 56 | min-height: 200px; 57 | } 58 | 59 | #warehouse-panel { 60 | position: absolute; 61 | top: 50%; 62 | left: 50%; 63 | transform: translate(-50%, -50%); 64 | min-width: 400px; 65 | min-height: 300px; 66 | } 67 | 68 | #construction-panel { 69 | position: absolute; 70 | bottom: 10px; 71 | right: 10px; 72 | min-width: 300px; 73 | min-height: 200px; 74 | } 75 | 76 | #build-menu-panel { 77 | position: absolute; 78 | top: 50%; 79 | left: 50%; 80 | transform: translate(-50%, -50%); 81 | min-width: 300px; 82 | max-height: 80vh; 83 | overflow-y: auto; 84 | } 85 | 86 | #build-button, .hud-button { 87 | position: absolute; 88 | bottom: 10px; 89 | left: 10px; 90 | padding: 10px 20px; 91 | background-color: rgba(0, 100, 0, 0.8); 92 | color: white; 93 | border: 2px solid #00bb00; 94 | border-radius: 5px; 95 | cursor: pointer; 96 | font-weight: bold; 97 | font-size: 16px; 98 | z-index: 100; 99 | pointer-events: auto; 100 | } 101 | 102 | #build-button:hover, .hud-button:hover { 103 | background-color: rgba(0, 120, 0, 0.9); 104 | transform: scale(1.05); 105 | } 106 | 107 | .buildings-list { 108 | margin: 10px 0; 109 | } 110 | 111 | .building-item { 112 | display: flex; 113 | flex-direction: column; 114 | margin: 10px 0; 115 | padding: 10px; 116 | background-color: rgba(50, 50, 50, 0.7); 117 | border-radius: 5px; 118 | cursor: pointer; 119 | transition: background-color 0.2s; 120 | } 121 | 122 | .building-item:hover { 123 | background-color: rgba(70, 70, 70, 0.9); 124 | } 125 | 126 | .building-name { 127 | font-weight: bold; 128 | margin-bottom: 5px; 129 | } 130 | 131 | .building-costs { 132 | font-size: 0.9em; 133 | color: #ccc; 134 | } 135 | 136 | .construction-progress-container { 137 | width: 100%; 138 | height: 20px; 139 | background-color: #444; 140 | border-radius: 10px; 141 | margin: 10px 0; 142 | position: relative; 143 | overflow: hidden; 144 | } 145 | 146 | .construction-progress-bar { 147 | height: 100%; 148 | background-color: #00aa00; 149 | border-radius: 10px; 150 | transition: width 0.3s; 151 | } 152 | 153 | .construction-progress-text { 154 | position: absolute; 155 | top: 0; 156 | left: 0; 157 | width: 100%; 158 | height: 100%; 159 | display: flex; 160 | align-items: center; 161 | justify-content: center; 162 | color: white; 163 | font-weight: bold; 164 | text-shadow: 1px 1px 2px #000; 165 | } 166 | 167 | .construction-resources { 168 | margin: 10px 0; 169 | } 170 | 171 | .resource-requirement { 172 | display: flex; 173 | justify-content: space-between; 174 | margin: 5px 0; 175 | } 176 | 177 | .placement-instructions { 178 | position: absolute; 179 | top: 50px; 180 | left: 50%; 181 | transform: translateX(-50%); 182 | background-color: rgba(0, 0, 0, 0.7); 183 | color: white; 184 | padding: 10px 20px; 185 | border-radius: 5px; 186 | text-align: center; 187 | pointer-events: none; 188 | animation: fadeIn 0.5s; 189 | } 190 | 191 | @keyframes fadeIn { 192 | from { opacity: 0; } 193 | to { opacity: 1; } 194 | } 195 | 196 | .hidden { 197 | display: none; 198 | } -------------------------------------------------------------------------------- /src/js/utils/Constants.js: -------------------------------------------------------------------------------- 1 | import { ResourceType, BuildingType, SettlerType } from './Enums.js'; 2 | 3 | // Resource names for display 4 | export const resourceNames = { 5 | [ResourceType.WOOD]: 'Wood', 6 | [ResourceType.STONE]: 'Stone', 7 | [ResourceType.IRON_ORE]: 'Iron Ore', 8 | [ResourceType.IRON]: 'Iron', 9 | [ResourceType.PLANK]: 'Planks', 10 | [ResourceType.WHEAT]: 'Wheat', 11 | [ResourceType.FLOUR]: 'Flour', 12 | [ResourceType.BREAD]: 'Bread', 13 | [ResourceType.MEAT]: 'Meat' 14 | }; 15 | 16 | // Building names for display 17 | export const buildingNames = { 18 | [BuildingType.WAREHOUSE]: 'Warehouse', 19 | [BuildingType.WOODCUTTER]: 'Woodcutter\'s Hut', 20 | [BuildingType.SAWMILL]: 'Sawmill', 21 | [BuildingType.STONEMASON]: 'Stonemason\'s Hut', 22 | [BuildingType.MINE]: 'Mine', 23 | [BuildingType.IRONSMITH]: 'Ironsmith\'s Forge', 24 | [BuildingType.FARM]: 'Farm', 25 | [BuildingType.MILL]: 'Mill', 26 | [BuildingType.BAKERY]: 'Bakery', 27 | [BuildingType.HUNTER]: 'Hunter\'s Hut' 28 | }; 29 | 30 | // Settler names for display 31 | export const settlerNames = { 32 | [SettlerType.PORTER]: 'Porter', 33 | [SettlerType.BUILDER]: 'Builder', 34 | [SettlerType.WOODCUTTER]: 'Woodcutter', 35 | [SettlerType.STONEMASON]: 'Stonemason', 36 | [SettlerType.MINER]: 'Miner', 37 | [SettlerType.BLACKSMITH]: 'Blacksmith', 38 | [SettlerType.FARMER]: 'Farmer', 39 | [SettlerType.MILLER]: 'Miller', 40 | [SettlerType.BAKER]: 'Baker', 41 | [SettlerType.HUNTER]: 'Hunter', 42 | [SettlerType.DIGGER]: 'Digger' 43 | }; 44 | 45 | // Fog of War settings 46 | export const FOG_OF_WAR = { 47 | ENABLED: true, 48 | INITIAL_VISIBILITY_RADIUS: 20, // Radius in grid units around warehouse 49 | BUILDING_VISIBILITY_RADIUS: { 50 | [BuildingType.WAREHOUSE]: 20, 51 | [BuildingType.WOODCUTTER]: 10, 52 | [BuildingType.SAWMILL]: 10, 53 | [BuildingType.STONEMASON]: 10, 54 | [BuildingType.MINE]: 12, 55 | [BuildingType.IRONSMITH]: 10, 56 | [BuildingType.FARM]: 15, 57 | [BuildingType.MILL]: 10, 58 | [BuildingType.BAKERY]: 10, 59 | [BuildingType.HUNTER]: 15 60 | } 61 | }; 62 | 63 | // Building costs (resources needed to build) 64 | export const buildingCosts = { 65 | [BuildingType.WAREHOUSE]: { 66 | [ResourceType.PLANK]: 20, 67 | [ResourceType.STONE]: 10 68 | }, 69 | [BuildingType.WOODCUTTER]: { 70 | [ResourceType.PLANK]: 5 71 | }, 72 | [BuildingType.SAWMILL]: { 73 | [ResourceType.PLANK]: 5, 74 | [ResourceType.STONE]: 5 75 | }, 76 | [BuildingType.STONEMASON]: { 77 | [ResourceType.PLANK]: 5 78 | }, 79 | [BuildingType.MINE]: { 80 | [ResourceType.PLANK]: 5, 81 | [ResourceType.STONE]: 10 82 | }, 83 | [BuildingType.IRONSMITH]: { 84 | [ResourceType.PLANK]: 5, 85 | [ResourceType.STONE]: 10 86 | }, 87 | [BuildingType.FARM]: { 88 | [ResourceType.PLANK]: 5 89 | }, 90 | [BuildingType.MILL]: { 91 | [ResourceType.PLANK]: 5, 92 | [ResourceType.STONE]: 5 93 | }, 94 | [BuildingType.BAKERY]: { 95 | [ResourceType.PLANK]: 5, 96 | [ResourceType.STONE]: 10 97 | }, 98 | [BuildingType.HUNTER]: { 99 | [ResourceType.PLANK]: 5 100 | } 101 | }; 102 | 103 | // Production chains - what resources each building uses and produces 104 | export const productionChains = { 105 | [BuildingType.WOODCUTTER]: { 106 | produces: { 107 | type: ResourceType.WOOD, 108 | rate: 1 109 | } 110 | }, 111 | [BuildingType.SAWMILL]: { 112 | consumes: { 113 | [ResourceType.WOOD]: 1 114 | }, 115 | produces: { 116 | type: ResourceType.PLANK, 117 | rate: 1 118 | } 119 | }, 120 | [BuildingType.STONEMASON]: { 121 | produces: { 122 | type: ResourceType.STONE, 123 | rate: 1 124 | } 125 | }, 126 | [BuildingType.MINE]: { 127 | produces: { 128 | type: ResourceType.IRON_ORE, 129 | rate: 1 130 | } 131 | }, 132 | [BuildingType.IRONSMITH]: { 133 | consumes: { 134 | [ResourceType.IRON_ORE]: 1 135 | }, 136 | produces: { 137 | type: ResourceType.IRON, 138 | rate: 1 139 | } 140 | }, 141 | [BuildingType.FARM]: { 142 | produces: { 143 | type: ResourceType.WHEAT, 144 | rate: 1 145 | } 146 | }, 147 | [BuildingType.MILL]: { 148 | consumes: { 149 | [ResourceType.WHEAT]: 1 150 | }, 151 | produces: { 152 | type: ResourceType.FLOUR, 153 | rate: 1 154 | } 155 | }, 156 | [BuildingType.BAKERY]: { 157 | consumes: { 158 | [ResourceType.FLOUR]: 1 159 | }, 160 | produces: { 161 | type: ResourceType.BREAD, 162 | rate: 1 163 | } 164 | }, 165 | [BuildingType.HUNTER]: { 166 | produces: { 167 | type: ResourceType.MEAT, 168 | rate: 1 169 | } 170 | } 171 | }; -------------------------------------------------------------------------------- /src/js/entities/buildings/Woodcutter.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Building } from '../Building.js'; 3 | import { BuildingType } from '../../utils/Enums.js'; 4 | 5 | export class Woodcutter extends Building { 6 | constructor(world, x, y) { 7 | super(world, x, y, BuildingType.WOODCUTTER); 8 | 9 | // Woodcutter is a 1x1 building 10 | this.size = { width: 1, height: 1 }; 11 | 12 | // Create a group for the mesh immediately 13 | this.mesh = new THREE.Group(); 14 | 15 | // Load the sprite texture 16 | this.textureLoaded = false; 17 | this.textureLoader = new THREE.TextureLoader(); 18 | 19 | console.log(`Woodcutter initialized at grid position: ${x}, ${y}`); 20 | 21 | // Create a simple fallback mesh to ensure visibility 22 | this.createMesh(); 23 | } 24 | 25 | createMesh() { 26 | const width = this.size.width * this.world.tileSize; 27 | const depth = this.size.height * this.world.tileSize; 28 | 29 | // Mesh is already created in the constructor 30 | // Just clear any existing children 31 | while (this.mesh.children.length > 0) { 32 | this.mesh.remove(this.mesh.children[0]); 33 | } 34 | 35 | // Path to the woodcutter sprite 36 | const spritePath = '/assets/sprites/woodcutter.png'; 37 | 38 | this.textureLoader.load(spritePath, 39 | // Success callback 40 | (texture) => { 41 | console.log("Successfully loaded woodcutter texture:", spritePath); 42 | // Use the helper method to create the sprite 43 | this._setupWoodcutterSprite(texture, width, depth); 44 | }, 45 | // Progress callback 46 | undefined, 47 | // Error callback 48 | (error) => { 49 | console.warn("Error loading woodcutter texture:", error); 50 | // Create a fallback mesh if texture loading fails 51 | this._createFallbackMesh(width, depth); 52 | }); 53 | 54 | // Position the woodcutter on the terrain 55 | const worldPos = this.world.getWorldPosition(this.position.x, this.position.y); 56 | const terrainHeight = this.world.terrain[this.position.y][this.position.x].height; 57 | 58 | // Place the woodcutter at the correct position 59 | this.mesh.position.set( 60 | worldPos.x + width / 2, 61 | terrainHeight, // On the terrain 62 | worldPos.z + depth / 2 63 | ); 64 | 65 | console.log(`Woodcutter placed at world position: (${this.mesh.position.x}, ${this.mesh.position.y}, ${this.mesh.position.z})`); 66 | 67 | // Name the mesh for raycasting 68 | this.mesh.name = `building_${this.type}_${this.position.x}_${this.position.y}`; 69 | 70 | // Mark this as a building for raycasting 71 | this.mesh.userData.isBuilding = true; 72 | 73 | return this.mesh; 74 | } 75 | 76 | // Helper method to create the sprite with a loaded texture 77 | _setupWoodcutterSprite(texture, width, depth) { 78 | this.textureLoaded = true; 79 | console.log("Setting up woodcutter sprite with texture:", texture); 80 | 81 | // Calculate sprite size based on image aspect ratio if available 82 | let imageAspect = 1; 83 | if (texture.image && texture.image.width && texture.image.height) { 84 | imageAspect = texture.image.width / texture.image.height; 85 | console.log(`Texture dimensions: ${texture.image.width}x${texture.image.height}, aspect ratio: ${imageAspect}`); 86 | } 87 | 88 | // Create billboard sprite material 89 | const spriteMaterial = new THREE.SpriteMaterial({ 90 | map: texture, 91 | transparent: true, 92 | alphaTest: 0.01, 93 | depthTest: true, 94 | depthWrite: true, 95 | sizeAttenuation: true, 96 | color: 0xffffff 97 | }); 98 | 99 | // Create the sprite 100 | const sprite = new THREE.Sprite(spriteMaterial); 101 | sprite.scale.set(width * 1.5, width * 1.5, 1); // Make sprite slightly larger for visibility 102 | 103 | // Position above the base 104 | sprite.position.set(0, width / 4, 0); 105 | this.mesh.add(sprite); 106 | 107 | // Add a shadow beneath the building 108 | const shadowGeometry = new THREE.PlaneGeometry(width, depth); 109 | const shadowMaterial = new THREE.MeshBasicMaterial({ 110 | color: 0x000000, 111 | transparent: true, 112 | opacity: 0.3, 113 | depthWrite: false 114 | }); 115 | 116 | const shadow = new THREE.Mesh(shadowGeometry, shadowMaterial); 117 | shadow.rotation.x = -Math.PI / 2; // Lay flat on the ground 118 | shadow.position.y = 0.01; // Slightly above the terrain to prevent z-fighting 119 | this.mesh.add(shadow); 120 | 121 | console.log("Woodcutter sprite created successfully"); 122 | } 123 | 124 | // Create a fallback mesh if the texture cannot be loaded 125 | _createFallbackMesh(width, depth) { 126 | const height = 1.5; 127 | const geometry = new THREE.BoxGeometry(width, height, depth); 128 | const material = new THREE.MeshStandardMaterial({ color: 0x8B4513 }); // Brown color for wood 129 | 130 | const box = new THREE.Mesh(geometry, material); 131 | box.position.y = height / 2; 132 | this.mesh.add(box); 133 | 134 | console.log("Created fallback mesh for woodcutter"); 135 | } 136 | } -------------------------------------------------------------------------------- /src/js/entities/Building.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Entity } from './Entity.js'; 3 | import { buildingNames, productionChains } from '../utils/Constants.js'; 4 | 5 | export class Building extends Entity { 6 | constructor(world, x, y, type) { 7 | super(world, x, y); 8 | 9 | this.type = type; 10 | this.name = buildingNames[type] || 'Building'; 11 | 12 | // Building properties 13 | this.size = { width: 1, height: 1 }; // Size in tiles 14 | this.level = 1; 15 | this.maxLevel = 3; 16 | this.isProducing = false; 17 | this.canUpgrade = true; 18 | 19 | // Define interaction point 20 | this._defineInteractionPoint(); 21 | 22 | // Production properties 23 | const production = productionChains[type] || {}; 24 | this.produces = production.produces || null; 25 | this.consumes = production.consumes || null; 26 | 27 | // Upgrade costs - increase with level 28 | this.upgradeCost = { 29 | plank: 10 * this.level, 30 | stone: 5 * this.level 31 | }; 32 | 33 | // Production timer 34 | this.productionTimer = 0; 35 | this.productionTime = 5000; // 5 seconds per unit 36 | 37 | // Workers assigned to this building 38 | this.workers = []; 39 | } 40 | 41 | init() { 42 | this.createMesh(); 43 | } 44 | 45 | createMesh() { 46 | // Create a simple box for the building 47 | const width = this.size.width * this.world.tileSize; 48 | const depth = this.size.height * this.world.tileSize; 49 | const height = 1.5; // Building height 50 | 51 | const geometry = new THREE.BoxGeometry(width, height, depth); 52 | const material = new THREE.MeshStandardMaterial({ color: 0xDDDDDD }); 53 | 54 | this.mesh = new THREE.Mesh(geometry, material); 55 | 56 | // Position the building 57 | const worldPos = this.world.getWorldPosition(this.position.x, this.position.y); 58 | this.mesh.position.set(worldPos.x, height / 2, worldPos.z); 59 | 60 | // Name the mesh for raycasting 61 | this.mesh.name = `building_${this.type}_${this.position.x}_${this.position.y}`; 62 | 63 | // Mark this as a building for raycasting 64 | this.mesh.userData.isBuilding = true; 65 | 66 | return this.mesh; 67 | } 68 | 69 | update() { 70 | if (!this.isProducing || !this.produces) return; 71 | 72 | // Update production timer 73 | this.productionTimer += 16.67; // Approx 1 frame at 60fps 74 | 75 | if (this.productionTimer >= this.productionTime) { 76 | this.productionTimer = 0; 77 | this._produce(); 78 | } 79 | } 80 | 81 | startProduction() { 82 | // Check if we have resources to consume 83 | if (this.consumes) { 84 | const resourceManager = this.world.game.resourceManager; 85 | if (!resourceManager.hasResources(this.consumes)) { 86 | console.log(`${this.name} cannot produce - missing resources`); 87 | return false; 88 | } 89 | 90 | // Consume resources 91 | resourceManager.consumeResources(this.consumes); 92 | } 93 | 94 | this.isProducing = true; 95 | return true; 96 | } 97 | 98 | stopProduction() { 99 | this.isProducing = false; 100 | } 101 | 102 | _produce() { 103 | if (!this.produces) return; 104 | 105 | // Add produced resource to storage 106 | const resourceManager = this.world.game.resourceManager; 107 | resourceManager.addResource(this.produces.type, this.produces.rate * this.level); 108 | 109 | console.log(`${this.name} produced ${this.produces.rate * this.level} ${this.produces.type}`); 110 | 111 | // If we need resources to continue, check and consume them 112 | if (this.consumes) { 113 | if (resourceManager.hasResources(this.consumes)) { 114 | resourceManager.consumeResources(this.consumes); 115 | } else { 116 | // Stop production if we don't have resources 117 | this.stopProduction(); 118 | } 119 | } 120 | } 121 | 122 | upgrade() { 123 | if (this.level >= this.maxLevel) { 124 | console.log(`${this.name} is already at maximum level`); 125 | return false; 126 | } 127 | 128 | this.level++; 129 | 130 | // Update upgrade cost for next level 131 | this.upgradeCost = { 132 | plank: 10 * this.level, 133 | stone: 5 * this.level 134 | }; 135 | 136 | // Update mesh to reflect upgrade 137 | this.mesh.scale.y = 1 + (this.level - 1) * 0.3; 138 | 139 | console.log(`${this.name} upgraded to level ${this.level}`); 140 | return true; 141 | } 142 | 143 | assignWorker(worker) { 144 | this.workers.push(worker); 145 | 146 | // If we have workers and resources, start production 147 | if (this.workers.length > 0 && this.produces) { 148 | this.startProduction(); 149 | } 150 | } 151 | 152 | removeWorker(worker) { 153 | this.workers = this.workers.filter(w => w !== worker); 154 | 155 | // If no workers left, stop production 156 | if (this.workers.length === 0) { 157 | this.stopProduction(); 158 | } 159 | } 160 | 161 | // Define the interaction point for settlers to approach the building 162 | _defineInteractionPoint() { 163 | // By default, the interaction point is at the bottom edge, middle 164 | this.interactionPoint = { 165 | x: this.position.x + Math.floor(this.size.width / 2), 166 | y: this.position.y + this.size.height - 1 167 | }; 168 | 169 | // For warehouse, use the bottom right corner 170 | if (this.type === 'warehouse') { 171 | this.interactionPoint = { 172 | x: this.position.x + this.size.width - 1, 173 | y: this.position.y + this.size.height - 1 174 | }; 175 | } 176 | } 177 | 178 | // Get the interaction position in world coordinates 179 | getInteractionPosition() { 180 | return this.world.getWorldPosition( 181 | this.interactionPoint.x, 182 | this.interactionPoint.y 183 | ); 184 | } 185 | } -------------------------------------------------------------------------------- /src/js/entities/buildings/Warehouse.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Building } from '../Building.js'; 3 | import { BuildingType } from '../../utils/Enums.js'; 4 | 5 | export class Warehouse extends Building { 6 | constructor(world, x, y) { 7 | super(world, x, y, BuildingType.WAREHOUSE); 8 | 9 | // Warehouse is larger than normal buildings 10 | this.size = { width: 2, height: 2 }; 11 | 12 | // Warehouse has higher upgrade costs 13 | this.upgradeCost = { 14 | plank: 20 * this.level, 15 | stone: 10 * this.level 16 | }; 17 | 18 | // Create a group for the mesh immediately 19 | this.mesh = new THREE.Group(); 20 | 21 | // Load the sprite texture 22 | this.textureLoaded = false; 23 | this.textureLoader = new THREE.TextureLoader(); 24 | 25 | console.log(`Warehouse initialized at grid position: ${x}, ${y}`); 26 | 27 | // Create a simple fallback mesh to ensure visibility 28 | this.createMesh(); 29 | } 30 | 31 | createMesh() { 32 | const width = this.size.width * this.world.tileSize; 33 | const depth = this.size.height * this.world.tileSize; 34 | 35 | // Mesh is already created in the constructor 36 | // Just clear any existing children 37 | while (this.mesh.children.length > 0) { 38 | this.mesh.remove(this.mesh.children[0]); 39 | } 40 | 41 | // Fallback to loading it ourselves - try direct paths 42 | const spritePath = '/assets/sprites/warehouse.png'; // Correct path in public directory 43 | 44 | this.textureLoader.load(spritePath, 45 | // Success callback 46 | (texture) => { 47 | console.log("Successfully loaded warehouse texture:", spritePath); 48 | // Use the helper method to create the sprite 49 | this._setupWarehouseSprite(texture, width, depth); 50 | }, 51 | // Progress callback 52 | undefined, 53 | // Error callback - try alternative paths 54 | (error) => { 55 | console.warn("Error loading warehouse texture from primary path:", error); 56 | }); 57 | 58 | // Position the warehouse on the terrain 59 | const worldPos = this.world.getWorldPosition(this.position.x, this.position.y); 60 | const terrainHeight = this.world.terrain[this.position.y][this.position.x].height; 61 | 62 | // Place the warehouse at the correct position - elevated higher than the terrain 63 | this.mesh.position.set( 64 | worldPos.x + width / 2, 65 | 0, // Slightly elevated for better visibility 66 | worldPos.z + depth / 2 67 | ); 68 | 69 | console.log(`Warehouse placed at world position: (${this.mesh.position.x}, ${this.mesh.position.y}, ${this.mesh.position.z})`); 70 | console.log(`Terrain height at position: ${terrainHeight}`); 71 | 72 | // Name the mesh for raycasting 73 | this.mesh.name = `building_${this.type}_${this.position.x}_${this.position.y}`; 74 | 75 | // Mark this as a building for raycasting 76 | this.mesh.userData.isBuilding = true; 77 | 78 | return this.mesh; 79 | } 80 | 81 | // Helper method to create the sprite with a loaded texture 82 | _setupWarehouseSprite(texture, width, depth) { 83 | this.textureLoaded = true; 84 | console.log("Setting up warehouse sprite with texture:", texture); 85 | 86 | // Calculate sprite size based on image aspect ratio if available 87 | let imageAspect = 1; 88 | if (texture.image && texture.image.width && texture.image.height) { 89 | imageAspect = texture.image.width / texture.image.height; 90 | console.log(`Texture dimensions: ${texture.image.width}x${texture.image.height}, aspect ratio: ${imageAspect}`); 91 | } else { 92 | console.warn("Texture loaded but image dimensions not available"); 93 | } 94 | 95 | // Make sprite exactly match the 2x2 grid size 96 | const spriteHeight = depth; // Exactly 2 grid cells tall 97 | const spriteWidth = width; // Exactly 2 grid cells wide 98 | 99 | // Create billboard sprite material with explicit settings for visibility 100 | const spriteMaterial = new THREE.SpriteMaterial({ 101 | map: texture, 102 | transparent: true, 103 | alphaTest: 0.01, // Lower value to ensure the sprite is visible 104 | depthTest: true, // Enable depth testing for proper z-ordering 105 | depthWrite: true, // Enable depth writing 106 | sizeAttenuation: true, // Enable size attenuation for proper scaling 107 | color: 0xffffff // Ensure full brightness 108 | }); 109 | 110 | // Create the sprite 111 | const sprite = new THREE.Sprite(spriteMaterial); 112 | sprite.scale.set(spriteWidth, spriteHeight, 1); 113 | 114 | // Position exactly at the center and above the base 115 | sprite.position.set(0, spriteHeight / 4, 0); 116 | this.mesh.add(sprite); 117 | 118 | // Remove the fallback mesh - we'll use the sprite only 119 | // Just add a small base for collision detection 120 | // const baseGeometry = new THREE.BoxGeometry(width, 0.5, depth); 121 | // const baseMaterial = new THREE.MeshBasicMaterial({ 122 | // color: 0x333333, 123 | // transparent: true, 124 | // opacity: 0.1 125 | // }); 126 | // const base = new THREE.Mesh(baseGeometry, baseMaterial); 127 | // base.position.y = 0.25; // Half its height 128 | // this.mesh.add(base); 129 | 130 | // // Add a simple shadow plane beneath the building 131 | const shadowGeometry = new THREE.PlaneGeometry(width, depth); 132 | const shadowMaterial = new THREE.MeshBasicMaterial({ 133 | color: 0x000000, 134 | transparent: true, 135 | opacity: 0.3, 136 | depthWrite: false 137 | }); 138 | 139 | const shadow = new THREE.Mesh(shadowGeometry, shadowMaterial); 140 | shadow.rotation.x = -Math.PI / 2; // Lay flat on the ground 141 | shadow.position.y = -0.01; // Slightly above the terrain to prevent z-fighting 142 | this.mesh.add(shadow); 143 | 144 | console.log("Warehouse sprite created successfully"); 145 | } 146 | 147 | // Override methods specific to warehouse 148 | update() { 149 | // Warehouse doesn't produce anything, so override default behavior 150 | 151 | // If we want the sprite to always face the camera (billboarding) 152 | // we could add that logic here, but THREE.Sprite already does this automatically 153 | } 154 | } -------------------------------------------------------------------------------- /src/js/utils/PathFinding.js: -------------------------------------------------------------------------------- 1 | import { TerrainType } from './Enums.js'; 2 | 3 | // A* pathfinding algorithm for settlers to find paths through the terrain 4 | export class PathFinder { 5 | constructor(world) { 6 | this.world = world; 7 | } 8 | 9 | // Find the shortest path between two points 10 | findPath(startX, startY, endX, endY) { 11 | // Create a grid representation of the terrain 12 | const grid = this._createGrid(); 13 | 14 | // A* algorithm implementation 15 | const openSet = []; 16 | const closedSet = new Set(); 17 | const cameFrom = new Map(); 18 | 19 | // Cost from start to current node 20 | const gScore = new Map(); 21 | // Estimated total cost from start to goal through current node 22 | const fScore = new Map(); 23 | 24 | // Start node 25 | const startNode = this._nodeKey(startX, startY); 26 | gScore.set(startNode, 0); 27 | fScore.set(startNode, this._heuristic(startX, startY, endX, endY)); 28 | openSet.push({ key: startNode, x: startX, y: startY, f: fScore.get(startNode) }); 29 | 30 | while (openSet.length > 0) { 31 | // Sort by fScore and take the lowest 32 | openSet.sort((a, b) => a.f - b.f); 33 | const current = openSet.shift(); 34 | 35 | // If we reached the goal 36 | if (current.x === endX && current.y === endY) { 37 | return this._reconstructPath(cameFrom, current.key); 38 | } 39 | 40 | closedSet.add(current.key); 41 | 42 | // Get neighbors 43 | const neighbors = this._getNeighbors(current.x, current.y, grid); 44 | 45 | for (const neighbor of neighbors) { 46 | const neighborKey = this._nodeKey(neighbor.x, neighbor.y); 47 | 48 | // Skip if already evaluated 49 | if (closedSet.has(neighborKey)) continue; 50 | 51 | // Calculate tentative gScore 52 | const tentativeGScore = (gScore.get(current.key) || Infinity) + 1; 53 | 54 | // Add to open set if not already there 55 | const existingIndex = openSet.findIndex(n => n.key === neighborKey); 56 | if (existingIndex === -1) { 57 | openSet.push({ 58 | key: neighborKey, 59 | x: neighbor.x, 60 | y: neighbor.y, 61 | f: tentativeGScore + this._heuristic(neighbor.x, neighbor.y, endX, endY) 62 | }); 63 | } else if (tentativeGScore >= (gScore.get(neighborKey) || Infinity)) { 64 | // This is not a better path 65 | continue; 66 | } 67 | 68 | // This path is the best until now 69 | cameFrom.set(neighborKey, current.key); 70 | gScore.set(neighborKey, tentativeGScore); 71 | fScore.set(neighborKey, tentativeGScore + this._heuristic(neighbor.x, neighbor.y, endX, endY)); 72 | 73 | // Update existing node in openSet if needed 74 | if (existingIndex !== -1) { 75 | openSet[existingIndex].f = fScore.get(neighborKey); 76 | } 77 | } 78 | } 79 | 80 | // No path found 81 | return []; 82 | } 83 | 84 | // Create a grid representation of the terrain 85 | _createGrid() { 86 | const grid = []; 87 | for (let y = 0; y < this.world.size.height; y++) { 88 | grid[y] = []; 89 | for (let x = 0; x < this.world.size.width; x++) { 90 | // Check if tile is passable 91 | grid[y][x] = this._isPassable(x, y); 92 | } 93 | } 94 | return grid; 95 | } 96 | 97 | // Check if a tile is passable 98 | _isPassable(x, y) { 99 | // Check bounds 100 | if (x < 0 || y < 0 || x >= this.world.size.width || y >= this.world.size.height) { 101 | return false; 102 | } 103 | 104 | const terrain = this.world.terrain[y][x]; 105 | 106 | // Water and mountains are impassable 107 | if (terrain.type === TerrainType.WATER || terrain.type === TerrainType.MOUNTAIN) { 108 | return false; 109 | } 110 | 111 | // Check if tile is occupied by a building 112 | if (terrain.building) { 113 | // The only exception is the entry point of a building 114 | const building = terrain.building; 115 | 116 | // For warehouse, the entry point is the bottom right corner 117 | if (building.type === 'warehouse') { 118 | const entryX = building.position.x + building.size.width - 1; 119 | const entryY = building.position.y + building.size.height - 1; 120 | return x === entryX && y === entryY; 121 | } 122 | 123 | // For other buildings, only the bottom edge is accessible 124 | const isBottomEdge = y === building.position.y + building.size.height - 1; 125 | const isWithinBuilding = 126 | x >= building.position.x && 127 | x < building.position.x + building.size.width; 128 | 129 | return isBottomEdge && isWithinBuilding; 130 | } 131 | 132 | // Forest is passable but with higher cost (handled in getNeighbors) 133 | 134 | return true; 135 | } 136 | 137 | // Get neighbors of a node 138 | _getNeighbors(x, y, grid) { 139 | const neighbors = []; 140 | const directions = [ 141 | { dx: 0, dy: -1 }, // North 142 | { dx: 1, dy: 0 }, // East 143 | { dx: 0, dy: 1 }, // South 144 | { dx: -1, dy: 0 } // West 145 | // Can add diagonals if needed 146 | ]; 147 | 148 | for (const dir of directions) { 149 | const newX = x + dir.dx; 150 | const newY = y + dir.dy; 151 | 152 | // Check bounds 153 | if (newX < 0 || newY < 0 || newX >= this.world.size.width || newY >= this.world.size.height) { 154 | continue; 155 | } 156 | 157 | // Check if passable 158 | if (grid[newY][newX]) { 159 | neighbors.push({ x: newX, y: newY }); 160 | } 161 | } 162 | 163 | return neighbors; 164 | } 165 | 166 | // Heuristic function (Manhattan distance) 167 | _heuristic(x1, y1, x2, y2) { 168 | return Math.abs(x1 - x2) + Math.abs(y1 - y2); 169 | } 170 | 171 | // Create a key for a node 172 | _nodeKey(x, y) { 173 | return `${x},${y}`; 174 | } 175 | 176 | // Reconstruct path from cameFrom map 177 | _reconstructPath(cameFrom, currentKey) { 178 | const path = []; 179 | let current = currentKey; 180 | 181 | while (cameFrom.has(current)) { 182 | const [x, y] = current.split(',').map(Number); 183 | path.unshift({ x, y }); 184 | current = cameFrom.get(current); 185 | } 186 | 187 | return path; 188 | } 189 | } -------------------------------------------------------------------------------- /src/js/core/Game.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 3 | import { World } from './World.js'; 4 | import { UIManager } from '../ui/UIManager.js'; 5 | import { ResourceManager } from './ResourceManager.js'; 6 | 7 | export class Game { 8 | constructor() { 9 | this.canvas = document.getElementById('game-canvas'); 10 | this.width = window.innerWidth; 11 | this.height = window.innerHeight; 12 | 13 | // Three.js components 14 | this.scene = null; 15 | this.camera = null; 16 | this.renderer = null; 17 | this.controls = null; 18 | 19 | // Game components 20 | this.world = null; 21 | this.resourceManager = null; 22 | this.uiManager = null; 23 | 24 | // Game state 25 | this.isRunning = false; 26 | 27 | // Bind methods 28 | this._onWindowResize = this._onWindowResize.bind(this); 29 | this._update = this._update.bind(this); 30 | } 31 | 32 | init() { 33 | // Initialize Three.js 34 | this.scene = new THREE.Scene(); 35 | this.scene.background = new THREE.Color(0x87CEEB); // Sky blue 36 | 37 | // Create texture loader 38 | this.textureLoader = new THREE.TextureLoader(); 39 | 40 | // Preload commonly used assets 41 | this.preloadAssets(); 42 | 43 | // Setup camera (isometric view) with increased frustum size 44 | const frustumSize = Math.max(this.width, this.height); 45 | const aspect = this.width / this.height; 46 | 47 | this.camera = new THREE.OrthographicCamera( 48 | -frustumSize * aspect / 2, frustumSize * aspect / 2, 49 | frustumSize / 2, -frustumSize / 2, 50 | 0.1, 5000 // Further increased far plane to prevent clipping 51 | ); 52 | 53 | // Set a more traditional isometric view for better visibility 54 | this.camera.position.set(50, 70, 50); 55 | this.camera.lookAt(0, 0, 0); 56 | 57 | console.log("Camera positioned directly above the center point"); 58 | 59 | // Start with a significantly zoomed-in view on the settlement area 60 | this.camera.zoom = 70; // Very zoomed in to clearly see the starting point 61 | this.camera.updateProjectionMatrix(); 62 | 63 | // Setup renderer 64 | this.renderer = new THREE.WebGLRenderer({ 65 | canvas: this.canvas, 66 | antialias: true 67 | }); 68 | this.renderer.setSize(this.width, this.height); 69 | this.renderer.setPixelRatio(window.devicePixelRatio); 70 | 71 | // Enable shadows 72 | this.renderer.shadowMap.enabled = true; 73 | this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; 74 | 75 | // Setup controls - restricted to only panning and zooming 76 | this.controls = new OrbitControls(this.camera, this.renderer.domElement); 77 | this.controls.enableDamping = true; 78 | this.controls.dampingFactor = 0.1; 79 | this.controls.screenSpacePanning = true; 80 | 81 | // Lock rotation to maintain isometric view 82 | this.controls.enableRotate = false; 83 | 84 | // Allow panning and zooming 85 | this.controls.enablePan = true; 86 | this.controls.enableZoom = true; 87 | 88 | // Zoom limits - restrict zoom out but allow good zoom in 89 | this.controls.minZoom = 1.0; // Restrict zooming out so buildings stay visible 90 | this.controls.maxZoom = 100; // Allow extreme zoom in 91 | 92 | // Speed settings 93 | this.controls.panSpeed = 1.5; // Faster panning for large map 94 | this.controls.zoomSpeed = 1.2; // Faster zooming 95 | 96 | // Basic ambient light only - directional lights disabled to prevent color issues with sprites 97 | const ambientLight = new THREE.AmbientLight(0xffffff, 1.0); // Full brightness ambient 98 | this.scene.add(ambientLight); 99 | 100 | console.log("Disabled directional lighting to preserve sprite colors"); 101 | 102 | // Initialize game components 103 | this.resourceManager = new ResourceManager(); 104 | 105 | // Create world after controls are initialized so it can access them 106 | this.world = new World(this); 107 | this.uiManager = new UIManager(this); 108 | 109 | // Initialize world 110 | this.world.init(); 111 | 112 | // Initialize UI 113 | this.uiManager.init(); 114 | 115 | // Add event listeners 116 | window.addEventListener('resize', this._onWindowResize); 117 | 118 | console.log('Game initialized'); 119 | } 120 | 121 | start() { 122 | this.isRunning = true; 123 | this._update(); 124 | 125 | // Set initial camera position to view the center of the map clearly 126 | this._setInitialCameraView(); 127 | 128 | console.log('Game started'); 129 | } 130 | 131 | // Set the camera to a good initial view of the warehouse 132 | _setInitialCameraView() { 133 | // Get the center of the map 134 | const centerX = Math.floor(this.world.size.width / 2); 135 | const centerY = Math.floor(this.world.size.height / 2); 136 | 137 | // Get world position adjusted for warehouse (slightly offset from center) 138 | const worldPos = this.world.getWorldPosition(centerX - 1, centerY - 1); 139 | 140 | // Position camera for clear view of the center area 141 | const distance = 40; // Further out for better perspective 142 | this.camera.position.set( 143 | worldPos.x + distance, 144 | distance * 0.8, // Lower height 145 | worldPos.z + distance 146 | ); 147 | 148 | console.log(`Camera positioned at: (${this.camera.position.x}, ${this.camera.position.y}, ${this.camera.position.z})`); 149 | 150 | // Look at the warehouse position 151 | this.controls.target.set(worldPos.x, 0, worldPos.z); 152 | 153 | // Set a higher zoom level for a closer view 154 | this.camera.zoom = 25; 155 | this.camera.updateProjectionMatrix(); 156 | 157 | // Update the controls 158 | this.controls.update(); 159 | } 160 | 161 | pause() { 162 | this.isRunning = false; 163 | console.log('Game paused'); 164 | } 165 | 166 | _update() { 167 | if (!this.isRunning) return; 168 | 169 | // Update controls 170 | this.controls.update(); 171 | 172 | // Update world 173 | this.world.update(); 174 | 175 | // Render scene 176 | this.renderer.render(this.scene, this.camera); 177 | 178 | // Request next frame 179 | requestAnimationFrame(this._update); 180 | } 181 | 182 | // Preload commonly used textures and assets 183 | preloadAssets() { 184 | console.log("Preloading assets..."); 185 | 186 | // Texture cache to store loaded textures 187 | this.textures = {}; 188 | 189 | // Preload the warehouse texture - try multiple paths 190 | const warehousePaths = [ 191 | '/assets/sprites/warehouse.png', // Correct path based on public directory 192 | ]; 193 | 194 | // Try to load from the first path, fall back to the second if needed 195 | this.textureLoader.load(warehousePaths[0], 196 | // Success callback 197 | (texture) => { 198 | console.log("Preloaded warehouse texture from primary path"); 199 | this.textures.warehouse = texture; 200 | }, 201 | // Progress callback 202 | undefined, 203 | // Error callback - try the fallback path 204 | (error) => { 205 | console.warn("Failed to preload from primary path", error); 206 | } 207 | ); 208 | } 209 | 210 | 211 | _onWindowResize() { 212 | this.width = window.innerWidth; 213 | this.height = window.innerHeight; 214 | 215 | // Update camera with proper aspect ratio 216 | const frustumSize = Math.max(this.width, this.height); 217 | const aspect = this.width / this.height; 218 | 219 | this.camera.left = -frustumSize * aspect / 2; 220 | this.camera.right = frustumSize * aspect / 2; 221 | this.camera.top = frustumSize / 2; 222 | this.camera.bottom = -frustumSize / 2; 223 | 224 | // Make sure far plane is properly set to prevent clipping 225 | this.camera.far = 5000; 226 | this.camera.near = 0.01; 227 | 228 | // Make sure we keep the same zoom level 229 | const currentZoom = this.camera.zoom; 230 | this.camera.updateProjectionMatrix(); 231 | this.camera.zoom = currentZoom; 232 | this.camera.updateProjectionMatrix(); 233 | 234 | // Update renderer 235 | this.renderer.setSize(this.width, this.height); 236 | this.renderer.setPixelRatio(window.devicePixelRatio); 237 | } 238 | } -------------------------------------------------------------------------------- /src/js/entities/settlers/Builder.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Settler } from '../Settler.js'; 3 | import { SettlerType } from '../../utils/Enums.js'; 4 | import { PathFinder } from '../../utils/PathFinding.js'; 5 | 6 | export class Builder extends Settler { 7 | constructor(world, x, y) { 8 | super(world, x, y, SettlerType.BUILDER); 9 | 10 | // Builder-specific properties 11 | this.targetConstruction = null; 12 | this.buildingTime = 5000; // 5 seconds of building time 13 | this.currentBuildingTime = 0; 14 | this.isBuilding = false; 15 | this.pathFinder = new PathFinder(world); 16 | 17 | // States 18 | this.state = 'IDLE'; // IDLE, MOVING_TO_CONSTRUCTION, BUILDING 19 | } 20 | 21 | createMesh() { 22 | // Create a group for the builder 23 | this.mesh = new THREE.Group(); 24 | 25 | // Create the builder figure (orange cylinder) - larger size for visibility 26 | const geometry = new THREE.CylinderGeometry(0.3, 0.3, 1.2, 8); 27 | const material = new THREE.MeshStandardMaterial({ color: 0xFF6600 }); // Bright orange for builders 28 | 29 | const figure = new THREE.Mesh(geometry, material); 30 | figure.position.y = 0.6; // Half the height of the taller cylinder 31 | this.mesh.add(figure); 32 | 33 | // Position the builder 34 | const worldPos = this.world.getWorldPosition(this.position.x, this.position.y); 35 | const terrainHeight = this.world.terrain[this.position.y][this.position.x].height; 36 | this.mesh.position.set(worldPos.x, terrainHeight + 0.1, worldPos.z); // Slightly above terrain 37 | 38 | // Name the mesh for raycasting 39 | this.mesh.name = `settler_${this.type}_${this.position.x}_${this.position.y}`; 40 | 41 | return this.mesh; 42 | } 43 | 44 | update() { 45 | // First handle movement if we're moving 46 | if (this.isMoving && this.targetPosition) { 47 | this._moveToTarget(); 48 | return; 49 | } 50 | 51 | // Handle states 52 | switch (this.state) { 53 | case 'IDLE': 54 | this._findConstructionToBuild(); 55 | break; 56 | case 'MOVING_TO_CONSTRUCTION': 57 | this._moveToConstruction(); 58 | break; 59 | case 'BUILDING': 60 | this._performBuilding(); 61 | break; 62 | } 63 | } 64 | 65 | _findConstructionToBuild() { 66 | // Look for construction sites that have all resources and need building 67 | const readyConstructions = this.world.constructions.filter(construction => { 68 | return construction._hasAllResources() && construction.progress < 100; 69 | }); 70 | 71 | if (readyConstructions.length > 0) { 72 | // Find the closest construction site 73 | let closestConstruction = null; 74 | let closestDistance = Infinity; 75 | 76 | for (const construction of readyConstructions) { 77 | const distance = Math.abs(this.position.x - construction.position.x) + 78 | Math.abs(this.position.y - construction.position.y); 79 | 80 | if (distance < closestDistance) { 81 | closestDistance = distance; 82 | closestConstruction = construction; 83 | } 84 | } 85 | 86 | // Target this construction 87 | this.targetConstruction = closestConstruction; 88 | console.log(`Builder found construction to build at ${this.targetConstruction.position.x}, ${this.targetConstruction.position.y}`); 89 | 90 | // Change state 91 | this.state = 'MOVING_TO_CONSTRUCTION'; 92 | } else { 93 | // No construction needs building, wait a bit and check again 94 | setTimeout(() => { 95 | if (this.state === 'IDLE') { 96 | this._findConstructionToBuild(); 97 | } 98 | }, 2000); 99 | } 100 | } 101 | 102 | _moveToConstruction() { 103 | // Ensure the construction site still exists 104 | if (!this.targetConstruction || !this.world.constructions.includes(this.targetConstruction)) { 105 | console.log('Target construction no longer exists'); 106 | this.targetConstruction = null; 107 | this.state = 'IDLE'; 108 | return; 109 | } 110 | 111 | // Check if we're close to the construction site interaction point - using world coordinates and proximity detection 112 | const builderWorldPos = this.world.getWorldPosition(this.position.x, this.position.y); 113 | const constructionWorldPos = this.world.getWorldPosition( 114 | this.targetConstruction.interactionPoint.x, 115 | this.targetConstruction.interactionPoint.y 116 | ); 117 | 118 | const distance = Math.sqrt( 119 | Math.pow(builderWorldPos.x - constructionWorldPos.x, 2) + 120 | Math.pow(builderWorldPos.z - constructionWorldPos.z, 2) 121 | ); 122 | 123 | // Consider close enough if within 1 tile distance 124 | if (distance < this.world.tileSize * 2) { 125 | // Start building 126 | this.state = 'BUILDING'; 127 | this.isBuilding = true; 128 | this.currentBuildingTime = 0; 129 | console.log(`Builder starts building at ${this.position.x}, ${this.position.y}`); 130 | return; 131 | } 132 | 133 | // Find path to construction interaction point 134 | const path = this.pathFinder.findPath( 135 | this.position.x, this.position.y, 136 | this.targetConstruction.interactionPoint.x, this.targetConstruction.interactionPoint.y 137 | ); 138 | 139 | if (path.length > 0) { 140 | console.log(`Builder moving to construction site along path of ${path.length} steps`); 141 | // Directly move to construction if path is too complex or nonexistent 142 | if (path.length > 10) { 143 | console.log("Path too complex, taking direct route"); 144 | this.moveTo({ x: this.targetConstruction.interactionPoint.x, y: this.targetConstruction.interactionPoint.y }); 145 | } else { 146 | this.followPath(path); 147 | } 148 | } else { 149 | console.log('No path to construction site found, taking direct route'); 150 | // Direct route as fallback 151 | this.moveTo({ x: this.targetConstruction.interactionPoint.x, y: this.targetConstruction.interactionPoint.y }); 152 | } 153 | } 154 | 155 | _performBuilding() { 156 | // Check if construction still exists 157 | if (!this.targetConstruction || !this.world.constructions.includes(this.targetConstruction)) { 158 | console.log('Target construction no longer exists'); 159 | this.isBuilding = false; 160 | this.targetConstruction = null; 161 | this.state = 'IDLE'; 162 | return; 163 | } 164 | 165 | // Update building time 166 | this.currentBuildingTime += 16.67; // Approx 1 frame at 60fps 167 | 168 | // Create building effect (particle animation) 169 | if (Math.random() < 0.1) { // Occasionally spawn particles 170 | this._createBuildingEffect(); 171 | } 172 | 173 | // Check if building is complete 174 | if (this.currentBuildingTime >= this.buildingTime) { 175 | console.log('Builder finished construction'); 176 | this.isBuilding = false; 177 | this.targetConstruction.progress = 100; // Mark as complete 178 | this.targetConstruction = null; 179 | this.state = 'IDLE'; 180 | } 181 | } 182 | 183 | _createBuildingEffect() { 184 | // Create a simple particle effect to show building activity 185 | const geometry = new THREE.SphereGeometry(0.05, 8, 8); 186 | const material = new THREE.MeshBasicMaterial({ 187 | color: Math.random() < 0.5 ? 0xFFFF00 : 0xFF0000, // Yellow or red sparks 188 | transparent: true, 189 | opacity: 0.8 190 | }); 191 | 192 | const particle = new THREE.Mesh(geometry, material); 193 | 194 | // Position at the construction site with random offset 195 | const worldPos = this.world.getWorldPosition( 196 | this.targetConstruction.position.x, 197 | this.targetConstruction.position.y 198 | ); 199 | 200 | const offsetX = (Math.random() - 0.5) * 0.8; 201 | const offsetY = Math.random() * 0.8 + 0.5; // Above ground 202 | const offsetZ = (Math.random() - 0.5) * 0.8; 203 | 204 | particle.position.set( 205 | worldPos.x + offsetX, 206 | offsetY, 207 | worldPos.z + offsetZ 208 | ); 209 | 210 | // Add to scene 211 | this.world.scene.add(particle); 212 | 213 | // Animate and remove 214 | let life = 1.0; 215 | const animateInterval = setInterval(() => { 216 | life -= 0.05; 217 | 218 | // Move upward 219 | particle.position.y += 0.02; 220 | 221 | // Fade out 222 | material.opacity = life; 223 | 224 | if (life <= 0) { 225 | clearInterval(animateInterval); 226 | this.world.scene.remove(particle); 227 | } 228 | }, 50); 229 | } 230 | } -------------------------------------------------------------------------------- /src/js/entities/Settler.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Entity } from './Entity.js'; 3 | import { settlerNames } from '../utils/Constants.js'; 4 | 5 | export class Settler extends Entity { 6 | constructor(world, x, y, type) { 7 | super(world, x, y); 8 | 9 | this.type = type; 10 | this.name = settlerNames[type] || 'Settler'; 11 | 12 | // Settler properties 13 | this.speed = 0.05; // Increased speed for better visibility 14 | this.path = []; 15 | this.targetPosition = null; 16 | this.isMoving = false; 17 | this.assignedBuilding = null; 18 | 19 | // For carriers 20 | this.carriedResource = null; 21 | this.carriedAmount = 0; 22 | 23 | // To detect stuck movement 24 | this.lastPosition = { x: 0, z: 0 }; 25 | this.stuckCounter = 0; 26 | this.stuckThreshold = 100; // Frames without significant movement 27 | } 28 | 29 | init() { 30 | this.createMesh(); 31 | } 32 | 33 | createMesh() { 34 | // Create a simple cylinder for the settler 35 | const geometry = new THREE.CylinderGeometry(0.2, 0.2, 0.8, 8); 36 | const material = new THREE.MeshStandardMaterial({ color: 0x0000FF }); 37 | 38 | this.mesh = new THREE.Mesh(geometry, material); 39 | 40 | // Position the settler 41 | const worldPos = this.world.getWorldPosition(this.position.x, this.position.y); 42 | this.mesh.position.set(worldPos.x, 0.4, worldPos.z); 43 | 44 | // Name the mesh for raycasting 45 | this.mesh.name = `settler_${this.type}_${this.position.x}_${this.position.y}`; 46 | 47 | return this.mesh; 48 | } 49 | 50 | update() { 51 | if (this.isMoving && this.targetPosition) { 52 | this._moveToTarget(); 53 | } else if (this.assignedBuilding) { 54 | this._performJob(); 55 | } else { 56 | // Idle, look for work 57 | this._findWork(); 58 | } 59 | } 60 | 61 | _moveToTarget() { 62 | // Simple movement towards target 63 | const target = new THREE.Vector3( 64 | this.targetPosition.x, 65 | this.mesh.position.y, 66 | this.targetPosition.z 67 | ); 68 | 69 | const distance = this.mesh.position.distanceTo(target); 70 | 71 | // Check for reaching target 72 | if (distance < 0.25) { // Increased threshold for more reliable proximity detection 73 | // Reached target 74 | this.isMoving = false; 75 | this.targetPosition = null; 76 | this.stuckCounter = 0; // Reset stuck counter 77 | 78 | // Get grid position 79 | const gridPos = this.world.getGridPosition( 80 | this.mesh.position.x, 81 | this.mesh.position.z 82 | ); 83 | 84 | this.position.x = gridPos.x; 85 | this.position.y = gridPos.z; 86 | 87 | console.log(`${this.type} reached target at grid (${this.position.x}, ${this.position.y})`); 88 | 89 | // If we have a path, get next point 90 | if (this.path && this.path.length > 0) { 91 | // Short delay before moving to next point to avoid rapid state changes 92 | setTimeout(() => { 93 | if (!this.isMoving && this.path && this.path.length > 0) { 94 | this.moveTo(this.path.shift()); 95 | } 96 | }, 50); 97 | } 98 | } else { 99 | // Check if we're stuck 100 | const movementThreshold = 0.001; 101 | const isStuck = 102 | Math.abs(this.mesh.position.x - this.lastPosition.x) < movementThreshold && 103 | Math.abs(this.mesh.position.z - this.lastPosition.z) < movementThreshold; 104 | 105 | if (isStuck) { 106 | this.stuckCounter++; 107 | 108 | // If stuck for too long, try to recover 109 | if (this.stuckCounter > this.stuckThreshold) { 110 | console.log(`${this.type} is stuck! Attempting recovery...`); 111 | 112 | // Try a small random displacement 113 | const randomOffsetX = (Math.random() - 0.5) * 0.5; 114 | const randomOffsetZ = (Math.random() - 0.5) * 0.5; 115 | 116 | this.mesh.position.x += randomOffsetX; 117 | this.mesh.position.z += randomOffsetZ; 118 | 119 | // Reset counter 120 | this.stuckCounter = 0; 121 | 122 | // If still stuck after several recoveries, just teleport to target 123 | if (Math.random() < 0.3) { // Increased probability 124 | console.log(`${this.type} emergency teleport to target`); 125 | this.mesh.position.x = this.targetPosition.x; 126 | this.mesh.position.z = this.targetPosition.z; 127 | 128 | // Force update grid position 129 | const gridPos = this.world.getGridPosition( 130 | this.mesh.position.x, 131 | this.mesh.position.z 132 | ); 133 | 134 | this.position.x = gridPos.x; 135 | this.position.y = gridPos.z; 136 | 137 | // Mark as reached target 138 | this.isMoving = false; 139 | this.targetPosition = null; 140 | 141 | // If we're following a path, continue 142 | if (this.path && this.path.length > 0) { 143 | setTimeout(() => this.moveTo(this.path.shift()), 50); 144 | } 145 | } 146 | } 147 | } else { 148 | // Not stuck, reset counter 149 | this.stuckCounter = 0; 150 | } 151 | 152 | // Save current position for next stuck check 153 | this.lastPosition.x = this.mesh.position.x; 154 | this.lastPosition.z = this.mesh.position.z; 155 | 156 | // Move towards target 157 | const direction = new THREE.Vector3() 158 | .subVectors(target, this.mesh.position) 159 | .normalize(); 160 | 161 | // Update position 162 | const newX = this.mesh.position.x + direction.x * this.speed; 163 | const newZ = this.mesh.position.z + direction.z * this.speed; 164 | 165 | // Get the grid coordinates to find terrain height 166 | const gridPos = this.world.getGridPosition(newX, newZ); 167 | 168 | // Update all components of position 169 | this.mesh.position.x = newX; 170 | this.mesh.position.z = newZ; 171 | 172 | // Debug logging for porter movement 173 | if (this.type === 'porter' && Math.random() < 0.01) { 174 | console.log(`Porter moving to ${this.targetPosition.x.toFixed(2)}, ${this.targetPosition.z.toFixed(2)}, distance: ${distance.toFixed(2)}, path remaining: ${this.path ? this.path.length : 0}`); 175 | } 176 | 177 | // Update height based on terrain if grid position is valid 178 | if (gridPos.x >= 0 && gridPos.x < this.world.size.width && 179 | gridPos.z >= 0 && gridPos.z < this.world.size.height) { 180 | const terrainHeight = this.world.terrain[gridPos.z][gridPos.x].height; 181 | this.mesh.position.y = terrainHeight + 0.1; // Slightly above terrain 182 | } 183 | } 184 | } 185 | 186 | _performJob() { 187 | // Different behavior based on settler type 188 | switch (this.type) { 189 | case 'porter': 190 | this._performPorterJob(); 191 | break; 192 | case 'builder': 193 | this._performBuilderJob(); 194 | break; 195 | default: 196 | // Most workers just stay at their building 197 | break; 198 | } 199 | } 200 | 201 | _performPorterJob() { 202 | // Porter logic: Find resources to carry or buildings that need resources 203 | if (this.carriedResource) { 204 | // Find where to deliver 205 | // For now, just go to warehouse 206 | const warehouse = this.world.buildings.find(b => b.type === 'warehouse'); 207 | 208 | if (warehouse) { 209 | this.moveTo({ x: warehouse.position.x, y: warehouse.position.y }); 210 | 211 | // Once we reach warehouse, deposit resources 212 | if (!this.isMoving) { 213 | this.world.game.resourceManager.addResource( 214 | this.carriedResource, 215 | this.carriedAmount 216 | ); 217 | 218 | this.carriedResource = null; 219 | this.carriedAmount = 0; 220 | } 221 | } 222 | } else { 223 | // Find building with output to collect 224 | // For simplicity, just go to a random production building 225 | const productionBuildings = this.world.buildings.filter( 226 | b => b.isProducing && b !== this.assignedBuilding 227 | ); 228 | 229 | if (productionBuildings.length > 0) { 230 | const randomBuilding = productionBuildings[ 231 | Math.floor(Math.random() * productionBuildings.length) 232 | ]; 233 | 234 | this.moveTo({ x: randomBuilding.position.x, y: randomBuilding.position.y }); 235 | 236 | // Once we reach building, collect resources 237 | if (!this.isMoving && randomBuilding.produces) { 238 | this.carriedResource = randomBuilding.produces.type; 239 | this.carriedAmount = 1; // Simplified: just carry 1 unit 240 | } 241 | } 242 | } 243 | } 244 | 245 | _performBuilderJob() { 246 | // Builder logic: Find buildings to build or upgrade 247 | // For now, just move around randomly 248 | if (!this.isMoving) { 249 | const randomX = Math.floor(Math.random() * this.world.size.width); 250 | const randomY = Math.floor(Math.random() * this.world.size.height); 251 | 252 | this.moveTo({ x: randomX, y: randomY }); 253 | } 254 | } 255 | 256 | _findWork() { 257 | // Find a building that needs workers 258 | // For simplicity, just find any building 259 | const availableBuildings = this.world.buildings.filter(b => b.workers.length < 2); 260 | 261 | if (availableBuildings.length > 0) { 262 | const randomBuilding = availableBuildings[ 263 | Math.floor(Math.random() * availableBuildings.length) 264 | ]; 265 | 266 | this.assignToBuilding(randomBuilding); 267 | } 268 | } 269 | 270 | moveTo(position) { 271 | // Set target position in world coordinates 272 | const worldPos = this.world.getWorldPosition(position.x, position.y); 273 | 274 | this.targetPosition = { 275 | x: worldPos.x, 276 | z: worldPos.z 277 | }; 278 | 279 | this.isMoving = true; 280 | } 281 | 282 | followPath(path) { 283 | // Make a defensive copy of the path 284 | this.path = path ? [...path] : []; 285 | 286 | // Log the path for debugging 287 | if (this.type === 'porter') { 288 | console.log(`Porter following path of ${this.path.length} steps from (${this.position.x}, ${this.position.y})`); 289 | 290 | // Show the first few steps 291 | if (this.path.length > 0) { 292 | console.log(`Path starts: ${JSON.stringify(this.path.slice(0, Math.min(3, this.path.length)))}`); 293 | } 294 | } 295 | 296 | // Clear any existing movement 297 | this.isMoving = false; 298 | this.targetPosition = null; 299 | 300 | // Start following the path if it's not empty 301 | if (this.path.length > 0) { 302 | // Small delay before starting to move 303 | setTimeout(() => { 304 | if (this.path && this.path.length > 0) { 305 | this.moveTo(this.path.shift()); 306 | } 307 | }, 50); 308 | } 309 | } 310 | 311 | assignToBuilding(building) { 312 | // Unassign from previous building 313 | if (this.assignedBuilding) { 314 | this.assignedBuilding.removeWorker(this); 315 | } 316 | 317 | // Assign to new building 318 | this.assignedBuilding = building; 319 | building.assignWorker(this); 320 | 321 | // Move to building 322 | this.moveTo({ x: building.position.x, y: building.position.y }); 323 | } 324 | } -------------------------------------------------------------------------------- /src/js/entities/buildings/Construction.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Building } from '../Building.js'; 3 | import { BuildingType, ResourceType } from '../../utils/Enums.js'; 4 | import { buildingCosts } from '../../utils/Constants.js'; 5 | 6 | // Construction is a special building class that represents a building under construction 7 | export class Construction extends Building { 8 | constructor(world, x, y, targetBuildingType) { 9 | super(world, x, y, BuildingType.WAREHOUSE); // Temporarily use warehouse type 10 | 11 | // Store the target building type 12 | this.targetBuildingType = targetBuildingType; 13 | 14 | // Get the target building size from the constants or default to 1x1 15 | this.size = { 16 | width: targetBuildingType === BuildingType.WAREHOUSE ? 2 : 1, 17 | height: targetBuildingType === BuildingType.WAREHOUSE ? 2 : 1 18 | }; 19 | 20 | // Define interaction point 21 | this._defineInteractionPoint(); 22 | 23 | // Construction progress (0-100%) 24 | this.progress = 0; 25 | this.constructionSpeed = 0.5; // Progress increment per frame (increased for faster testing) 26 | 27 | // Resources needed for construction 28 | this.requiredResources = {...buildingCosts[targetBuildingType]}; 29 | this.allocatedResources = {}; 30 | Object.keys(this.requiredResources).forEach(resource => { 31 | this.allocatedResources[resource] = 0; 32 | }); 33 | 34 | // Debug: Log resources required for construction 35 | console.log(`Construction requires resources:`, this.requiredResources); 36 | 37 | // Create a group for the mesh immediately 38 | this.mesh = new THREE.Group(); 39 | 40 | // Load the construction sprite texture 41 | this.textureLoaded = false; 42 | this.textureLoader = new THREE.TextureLoader(); 43 | 44 | console.log(`Construction started at grid position: ${x}, ${y} for ${targetBuildingType}`); 45 | 46 | // Create the construction mesh 47 | this.createMesh(); 48 | } 49 | 50 | createMesh() { 51 | const width = this.size.width * this.world.tileSize; 52 | const depth = this.size.height * this.world.tileSize; 53 | 54 | // Clear any existing children 55 | while (this.mesh.children.length > 0) { 56 | this.mesh.remove(this.mesh.children[0]); 57 | } 58 | 59 | // Path to the construction sprite 60 | const spritePath = '/assets/sprites/construction.png'; 61 | 62 | this.textureLoader.load(spritePath, 63 | // Success callback 64 | (texture) => { 65 | console.log("Successfully loaded construction texture:", spritePath); 66 | this._setupConstructionSprite(texture, width, depth); 67 | }, 68 | // Progress callback 69 | undefined, 70 | // Error callback 71 | (error) => { 72 | console.warn("Error loading construction texture:", error); 73 | this._createFallbackMesh(width, depth); 74 | }); 75 | 76 | // Position the construction on the terrain 77 | const worldPos = this.world.getWorldPosition(this.position.x, this.position.y); 78 | const terrainHeight = this.world.terrain[this.position.y][this.position.x].height; 79 | 80 | // Place at the correct position 81 | this.mesh.position.set( 82 | worldPos.x + width / 2, 83 | terrainHeight, 84 | worldPos.z + depth / 2 85 | ); 86 | 87 | // Create progress bar 88 | this._createProgressBar(width); 89 | 90 | // Name the mesh for raycasting 91 | this.mesh.name = `construction_${this.targetBuildingType}_${this.position.x}_${this.position.y}`; 92 | 93 | // Mark as a building for raycasting 94 | this.mesh.userData.isBuilding = true; 95 | this.mesh.userData.isConstruction = true; 96 | 97 | return this.mesh; 98 | } 99 | 100 | // Helper method to create the construction sprite 101 | _setupConstructionSprite(texture, width, depth) { 102 | this.textureLoaded = true; 103 | 104 | // Create billboard sprite material 105 | const spriteMaterial = new THREE.SpriteMaterial({ 106 | map: texture, 107 | transparent: true, 108 | alphaTest: 0.01, 109 | depthTest: true, 110 | sizeAttenuation: true 111 | }); 112 | 113 | // Create the sprite 114 | const sprite = new THREE.Sprite(spriteMaterial); 115 | sprite.scale.set(width * 1.2, width * 1.2, 1); 116 | 117 | // Position above the base 118 | sprite.position.set(0, width/4, 0); 119 | this.mesh.add(sprite); 120 | 121 | // Add a shadow beneath 122 | const shadowGeometry = new THREE.PlaneGeometry(width, depth); 123 | const shadowMaterial = new THREE.MeshBasicMaterial({ 124 | color: 0x000000, 125 | transparent: true, 126 | opacity: 0.3, 127 | depthWrite: false 128 | }); 129 | 130 | const shadow = new THREE.Mesh(shadowGeometry, shadowMaterial); 131 | shadow.rotation.x = -Math.PI / 2; 132 | shadow.position.y = 0.01; 133 | this.mesh.add(shadow); 134 | } 135 | 136 | // Create a fallback mesh if texture loading fails 137 | _createFallbackMesh(width, depth) { 138 | const height = 0.5; 139 | const geometry = new THREE.BoxGeometry(width, height, depth); 140 | const material = new THREE.MeshStandardMaterial({ color: 0xA0522D }); 141 | 142 | const base = new THREE.Mesh(geometry, material); 143 | base.position.y = height / 2; 144 | this.mesh.add(base); 145 | 146 | // Add vertical beams at corners 147 | const beamHeight = 1.5; 148 | const beamSize = 0.2; 149 | const beamGeometry = new THREE.BoxGeometry(beamSize, beamHeight, beamSize); 150 | const beamMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513 }); 151 | 152 | // Calculate positions for the corners 153 | const halfWidth = width / 2 - beamSize / 2; 154 | const halfDepth = depth / 2 - beamSize / 2; 155 | 156 | // Create beams at each corner 157 | const corners = [ 158 | { x: -halfWidth, z: -halfDepth }, 159 | { x: halfWidth, z: -halfDepth }, 160 | { x: -halfWidth, z: halfDepth }, 161 | { x: halfWidth, z: halfDepth } 162 | ]; 163 | 164 | corners.forEach(corner => { 165 | const beam = new THREE.Mesh(beamGeometry, beamMaterial); 166 | beam.position.set(corner.x, height + beamHeight / 2, corner.z); 167 | this.mesh.add(beam); 168 | }); 169 | } 170 | 171 | // Create a progress bar above the construction 172 | _createProgressBar(width) { 173 | const progressBarWidth = width; 174 | const progressBarHeight = 0.2; 175 | const progressBarElevation = width / 2 + 0.5; 176 | 177 | // Background bar 178 | const backgroundGeometry = new THREE.BoxGeometry(progressBarWidth, progressBarHeight, progressBarHeight); 179 | const backgroundMaterial = new THREE.MeshBasicMaterial({ color: 0x333333 }); 180 | this.progressBarBackground = new THREE.Mesh(backgroundGeometry, backgroundMaterial); 181 | this.progressBarBackground.position.set(0, progressBarElevation, 0); 182 | this.mesh.add(this.progressBarBackground); 183 | 184 | // Progress fill 185 | const fillGeometry = new THREE.BoxGeometry(0.01, progressBarHeight * 0.8, progressBarHeight * 0.8); 186 | const fillMaterial = new THREE.MeshBasicMaterial({ color: 0x00FF00 }); 187 | this.progressBarFill = new THREE.Mesh(fillGeometry, fillMaterial); 188 | 189 | // Position at the left edge of the background 190 | this.progressBarFill.position.set(-progressBarWidth / 2, progressBarElevation, 0); 191 | this.mesh.add(this.progressBarFill); 192 | 193 | // Store info for updating the progress bar 194 | this.progressBarInfo = { 195 | width: progressBarWidth, 196 | startX: -progressBarWidth / 2, 197 | endX: progressBarWidth / 2 198 | }; 199 | 200 | // Create resource indicators 201 | this._createResourceIndicators(width); 202 | } 203 | 204 | // Create indicators showing required resources 205 | _createResourceIndicators(width) { 206 | if (Object.keys(this.requiredResources).length === 0) return; 207 | 208 | const textureLoader = new THREE.TextureLoader(); 209 | const resourceSprites = {}; 210 | 211 | // Planks are currently the main resource 212 | if (this.requiredResources[ResourceType.PLANK]) { 213 | textureLoader.load('/assets/sprites/plank_carried.png', texture => { 214 | const material = new THREE.SpriteMaterial({ 215 | map: texture, 216 | transparent: true, 217 | depthTest: true 218 | }); 219 | 220 | const sprite = new THREE.Sprite(material); 221 | sprite.scale.set(0.4, 0.2, 1); 222 | 223 | // Position to the right of the construction site 224 | sprite.position.set(width / 2 + 0.3, 0.5, 0); 225 | 226 | // Add a text indicator showing required amount 227 | const amount = this.requiredResources[ResourceType.PLANK]; 228 | this._createTextIndicator(`0/${amount}`, width / 2 + 0.3, 0.8, sprite); 229 | 230 | resourceSprites[ResourceType.PLANK] = { 231 | sprite, 232 | textSprite: this.resourceTextSprites[ResourceType.PLANK] 233 | }; 234 | 235 | this.mesh.add(sprite); 236 | }); 237 | } 238 | 239 | // Store for later updates 240 | this.resourceSprites = resourceSprites; 241 | } 242 | 243 | // Create a text indicator for resources 244 | _createTextIndicator(text, x, y, parent) { 245 | // Create canvas for text 246 | const canvas = document.createElement('canvas'); 247 | const context = canvas.getContext('2d'); 248 | canvas.width = 64; 249 | canvas.height = 32; 250 | 251 | // Draw text 252 | context.fillStyle = 'white'; 253 | context.font = '20px Arial'; 254 | context.textAlign = 'center'; 255 | context.fillText(text, canvas.width / 2, canvas.height / 2); 256 | 257 | // Create texture and sprite 258 | const texture = new THREE.CanvasTexture(canvas); 259 | const material = new THREE.SpriteMaterial({ 260 | map: texture, 261 | transparent: true 262 | }); 263 | 264 | const sprite = new THREE.Sprite(material); 265 | sprite.scale.set(0.5, 0.25, 1); 266 | sprite.position.set(0, y, 0); 267 | 268 | // Add to parent or mesh 269 | if (parent) { 270 | parent.add(sprite); 271 | } else { 272 | this.mesh.add(sprite); 273 | } 274 | 275 | // Store for updating 276 | if (!this.resourceTextSprites) { 277 | this.resourceTextSprites = {}; 278 | } 279 | 280 | const resourceType = Object.keys(this.requiredResources).find( 281 | type => this.requiredResources[type] === parseInt(text.split('/')[1]) 282 | ); 283 | 284 | if (resourceType) { 285 | this.resourceTextSprites[resourceType] = { 286 | sprite, 287 | canvas, 288 | context 289 | }; 290 | } 291 | 292 | return sprite; 293 | } 294 | 295 | // Update resource indicators 296 | _updateResourceIndicators() { 297 | if (!this.resourceTextSprites) return; 298 | 299 | // Update each resource indicator 300 | Object.entries(this.requiredResources).forEach(([resourceType, amount]) => { 301 | const allocated = this.allocatedResources[resourceType] || 0; 302 | 303 | if (this.resourceTextSprites[resourceType]) { 304 | const { context, canvas, sprite } = this.resourceTextSprites[resourceType]; 305 | 306 | // Clear canvas 307 | context.clearRect(0, 0, canvas.width, canvas.height); 308 | 309 | // Draw updated text 310 | context.fillStyle = 'white'; 311 | context.font = '20px Arial'; 312 | context.textAlign = 'center'; 313 | context.fillText(`${allocated}/${amount}`, canvas.width / 2, canvas.height / 2); 314 | 315 | // Update texture 316 | sprite.material.map.needsUpdate = true; 317 | } 318 | }); 319 | } 320 | 321 | // Update the progress bar based on construction progress 322 | _updateProgressBar() { 323 | if (!this.progressBarFill) return; 324 | 325 | // Calculate the width of the fill bar based on progress 326 | const fillWidth = (this.progress / 100) * this.progressBarInfo.width; 327 | this.progressBarFill.scale.x = fillWidth; 328 | 329 | // Position the fill bar so its left edge stays at the start 330 | const newX = this.progressBarInfo.startX + fillWidth / 2; 331 | this.progressBarFill.position.x = newX; 332 | 333 | // Update resource indicators 334 | this._updateResourceIndicators(); 335 | } 336 | 337 | // Attempt to allocate resources from the warehouse 338 | _allocateResources() { 339 | const resourceManager = this.world.game.resourceManager; 340 | let resourcesAllocated = false; 341 | 342 | // Try to allocate resources for each required resource type 343 | Object.entries(this.requiredResources).forEach(([resource, amount]) => { 344 | // How much is still needed 345 | const neededAmount = amount - this.allocatedResources[resource]; 346 | 347 | if (neededAmount > 0) { 348 | // Check if resource is available 349 | const available = resourceManager.getResource(resource); 350 | 351 | if (available > 0) { 352 | // Allocate 1 unit at a time 353 | resourceManager.consumeResource(resource, 1); 354 | this.allocatedResources[resource]++; 355 | resourcesAllocated = true; 356 | } 357 | } 358 | }); 359 | 360 | return resourcesAllocated; 361 | } 362 | 363 | // Check if all required resources have been allocated 364 | _hasAllResources() { 365 | return Object.entries(this.requiredResources).every( 366 | ([resource, amount]) => this.allocatedResources[resource] >= amount 367 | ); 368 | } 369 | 370 | // Update method called every frame 371 | update() { 372 | // Don't auto-allocate resources anymore - porters will do this 373 | 374 | // Progress is controlled by builders now 375 | // We just need to check if progress is complete 376 | if (this.progress >= 100) { 377 | this._completeConstruction(); 378 | } 379 | 380 | // Update the progress bar 381 | this._updateProgressBar(); 382 | } 383 | 384 | // Complete construction and replace with actual building 385 | _completeConstruction() { 386 | console.log(`Construction completed for ${this.targetBuildingType} at ${this.position.x}, ${this.position.y}`); 387 | 388 | // Tell the world to replace this construction with the actual building 389 | this.world.completeConstruction(this); 390 | } 391 | } -------------------------------------------------------------------------------- /src/js/entities/settlers/Porter.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Settler } from '../Settler.js'; 3 | import { SettlerType, ResourceType } from '../../utils/Enums.js'; 4 | import { PathFinder } from '../../utils/PathFinding.js'; 5 | 6 | export class Porter extends Settler { 7 | constructor(world, x, y) { 8 | super(world, x, y, SettlerType.PORTER); 9 | 10 | // Porter-specific properties 11 | this.carriedResource = null; 12 | this.carriedAmount = 0; 13 | this.targetConstruction = null; 14 | this.resourceSprite = null; 15 | this.pathFinder = new PathFinder(world); 16 | 17 | // States for the porter's workflow 18 | this.state = 'IDLE'; // IDLE, FETCHING_RESOURCE, DELIVERING_RESOURCE 19 | } 20 | 21 | createMesh() { 22 | // Create a group for the porter and carried resources 23 | this.mesh = new THREE.Group(); 24 | 25 | // Create the porter figure (blue cylinder) - larger size for visibility 26 | const geometry = new THREE.CylinderGeometry(0.3, 0.3, 1.2, 8); 27 | const material = new THREE.MeshStandardMaterial({ color: 0x0088FF }); // Bright blue for porters 28 | 29 | const figure = new THREE.Mesh(geometry, material); 30 | figure.position.y = 0.6; // Half the height of the taller cylinder 31 | this.mesh.add(figure); 32 | 33 | // Position the porter 34 | const worldPos = this.world.getWorldPosition(this.position.x, this.position.y); 35 | const terrainHeight = this.world.terrain[this.position.y][this.position.x].height; 36 | this.mesh.position.set(worldPos.x, terrainHeight + 0.1, worldPos.z); // Slightly above terrain 37 | 38 | // Name the mesh for raycasting 39 | this.mesh.name = `settler_${this.type}_${this.position.x}_${this.position.y}`; 40 | 41 | return this.mesh; 42 | } 43 | 44 | update() { 45 | // First handle movement if we're moving 46 | if (this.isMoving && this.targetPosition) { 47 | this._moveToTarget(); 48 | return; 49 | } 50 | 51 | // Occasionally log the current state (for debugging) 52 | if (Math.random() < 0.005) { 53 | console.log(`Porter state: ${this.state}, carrying: ${this.carriedResource || 'nothing'}, target construction: ${this.targetConstruction ? 'yes' : 'no'}`); 54 | } 55 | 56 | // Handle states 57 | switch (this.state) { 58 | case 'IDLE': 59 | this._findConstructionInNeedOfResources(); 60 | break; 61 | case 'FETCHING_RESOURCE': 62 | this._fetchResourceFromWarehouse(); 63 | break; 64 | case 'DELIVERING_RESOURCE': 65 | this._deliverResourceToConstruction(); 66 | break; 67 | default: 68 | // If we somehow get into an invalid state, reset to IDLE 69 | console.error(`Porter in invalid state: ${this.state}, resetting to IDLE`); 70 | this.state = 'IDLE'; 71 | this.targetPosition = null; 72 | this.isMoving = false; 73 | this.path = []; 74 | } 75 | } 76 | 77 | _findConstructionInNeedOfResources() { 78 | // Find a construction site that needs resources 79 | const constructions = this.world.constructions.filter(construction => { 80 | return !construction._hasAllResources(); 81 | }); 82 | 83 | if (constructions.length > 0) { 84 | // Find the closest construction site 85 | let closestConstruction = null; 86 | let closestDistance = Infinity; 87 | 88 | // Prioritize the construction we were already working on (if it still exists) 89 | const existingConstruction = constructions.find(c => c === this.targetConstruction); 90 | if (existingConstruction) { 91 | closestConstruction = existingConstruction; 92 | console.log(`Porter continuing to work on existing construction`); 93 | } else { 94 | // Otherwise find the closest one 95 | for (const construction of constructions) { 96 | const distance = Math.abs(this.position.x - construction.position.x) + 97 | Math.abs(this.position.y - construction.position.y); 98 | 99 | if (distance < closestDistance) { 100 | closestDistance = distance; 101 | closestConstruction = construction; 102 | } 103 | } 104 | } 105 | 106 | // Target this construction 107 | this.targetConstruction = closestConstruction; 108 | 109 | // Determine what resource to fetch 110 | for (const [resourceType, required] of Object.entries(closestConstruction.requiredResources)) { 111 | const allocated = closestConstruction.allocatedResources[resourceType] || 0; 112 | 113 | if (allocated < required) { 114 | // We need to fetch this resource 115 | this.resourceToFetch = resourceType; 116 | break; 117 | } 118 | } 119 | 120 | console.log(`Porter assigned to fetch ${this.resourceToFetch} for construction at ${this.targetConstruction.position.x}, ${this.targetConstruction.position.y}`); 121 | 122 | // Change state 123 | this.state = 'FETCHING_RESOURCE'; 124 | } else { 125 | // No construction needs resources, wait a bit and check again 126 | setTimeout(() => { 127 | if (this.state === 'IDLE') { 128 | this._findConstructionInNeedOfResources(); 129 | } 130 | }, 2000); 131 | } 132 | } 133 | 134 | _fetchResourceFromWarehouse() { 135 | // Find the warehouse 136 | const warehouse = this.world.buildings.find(b => b.type === 'warehouse'); 137 | 138 | if (!warehouse) { 139 | console.error('No warehouse found'); 140 | this.state = 'IDLE'; 141 | return; 142 | } 143 | 144 | // check if resource is still needed 145 | if (this.targetConstruction && this.targetConstruction._hasAllResources()) { 146 | console.log('Target construction has all resources, going back to idle'); 147 | this.carriedResource = null; 148 | this.carriedAmount = 0; 149 | this._removeResourceSprite(); 150 | this.state = 'IDLE'; 151 | return; 152 | } 153 | 154 | // Check if we're close to the warehouse interaction point - using world coordinates and proximity detection 155 | const porterWorldPos = this.world.getWorldPosition(this.position.x, this.position.y); 156 | const warehouseWorldPos = this.world.getWorldPosition( 157 | warehouse.interactionPoint.x, 158 | warehouse.interactionPoint.y 159 | ); 160 | 161 | const distance = Math.sqrt( 162 | Math.pow(porterWorldPos.x - warehouseWorldPos.x, 2) + 163 | Math.pow(porterWorldPos.z - warehouseWorldPos.z, 2) 164 | ); 165 | 166 | // Debug output to help understand proximity calculation 167 | if (Math.random() < 0.01) { // Only log occasionally 168 | console.log(`Porter distance to warehouse: ${distance.toFixed(2)} vs threshold ${this.world.tileSize}`); 169 | } 170 | 171 | // Consider close enough if within 1 tile distance 172 | if (distance < this.world.tileSize * 2.1) { // Slightly increased detection range 173 | // Get resource from warehouse 174 | const resourceManager = this.world.game.resourceManager; 175 | 176 | if (resourceManager.getResource(this.resourceToFetch) > 0) { 177 | // Take 1 resource 178 | resourceManager.removeResource(this.resourceToFetch, 1); 179 | this.carriedResource = this.resourceToFetch; 180 | this.carriedAmount = 1; 181 | 182 | // Add resource sprite to porter 183 | this._addResourceSprite(); 184 | 185 | // Change state 186 | this.state = 'DELIVERING_RESOURCE'; 187 | 188 | console.log(`Porter picked up ${this.carriedResource}`); 189 | 190 | // Set target position to null to ensure proper movement 191 | this.targetPosition = null; 192 | this.isMoving = false; 193 | this.path = []; 194 | } else { 195 | // No resources available, wait for resources 196 | console.log(`Warehouse has no ${this.resourceToFetch} available`); 197 | 198 | // Check again after a delay 199 | setTimeout(() => { 200 | if (this.state === 'FETCHING_RESOURCE') { 201 | this._fetchResourceFromWarehouse(); 202 | } 203 | }, 2000); 204 | } 205 | } else { 206 | // Move to warehouse interaction point 207 | // const path = this.pathFinder.findPath( 208 | // this.position.x, this.position.y, 209 | // warehouse.interactionPoint.x, warehouse.interactionPoint.y 210 | // ); 211 | const path = []; 212 | 213 | if (path.length > 0) { 214 | console.log(`Porter moving to warehouse along path of ${path.length} steps`); 215 | // Directly move to warehouse if path is too complex 216 | if (path.length > 10) { 217 | console.log("Path too complex, taking direct route"); 218 | this.moveTo({ x: warehouse.interactionPoint.x, y: warehouse.interactionPoint.y }); 219 | } else { 220 | // Clear the current path before starting a new one 221 | this.path = []; 222 | this.followPath(path); 223 | } 224 | } else { 225 | console.log('No path to warehouse found, taking direct route'); 226 | // Direct route as fallback 227 | this.targetPosition = null; // Clear any existing target 228 | this.moveTo({ x: warehouse.interactionPoint.x, y: warehouse.interactionPoint.y }); 229 | } 230 | } 231 | } 232 | 233 | _deliverResourceToConstruction() { 234 | // Ensure the construction site still exists 235 | if (!this.targetConstruction || !this.world.constructions.includes(this.targetConstruction)) { 236 | console.log('Target construction no longer exists'); 237 | this.carriedResource = null; 238 | this.carriedAmount = 0; 239 | this._removeResourceSprite(); 240 | this.state = 'IDLE'; 241 | return; 242 | } 243 | 244 | // Check if we're close to the construction site interaction point - using world coordinates and proximity detection 245 | const porterWorldPos = this.world.getWorldPosition(this.position.x, this.position.y); 246 | const constructionWorldPos = this.world.getWorldPosition( 247 | this.targetConstruction.interactionPoint.x, 248 | this.targetConstruction.interactionPoint.y 249 | ); 250 | 251 | const distance = Math.sqrt( 252 | Math.pow(porterWorldPos.x - constructionWorldPos.x, 2) + 253 | Math.pow(porterWorldPos.z - constructionWorldPos.z, 2) 254 | ); 255 | 256 | // Debug output to help understand proximity calculation 257 | if (Math.random() < 0.01) { // Only log occasionally 258 | console.log(`Porter distance to construction: ${distance.toFixed(2)} vs threshold ${this.world.tileSize}`); 259 | } 260 | 261 | // Consider close enough if within 1 tile distance (with a bit of buffer) 262 | if (distance < this.world.tileSize * 1.2) { 263 | // Deliver the resource 264 | this.targetConstruction.allocatedResources[this.carriedResource] = 265 | (this.targetConstruction.allocatedResources[this.carriedResource] || 0) + this.carriedAmount; 266 | 267 | console.log(`Porter delivered ${this.carriedAmount} ${this.carriedResource} to construction`); 268 | 269 | // Create resource sprite at construction site 270 | this._createResourceAtConstruction(); 271 | 272 | // Reset carried resource 273 | this.carriedResource = null; 274 | this.carriedAmount = 0; 275 | this._removeResourceSprite(); 276 | 277 | // Clear movement state to prevent getting stuck 278 | this.targetPosition = null; 279 | this.isMoving = false; 280 | this.path = []; 281 | 282 | // Check if construction still needs resources - if so, go directly to fetch more 283 | if (!this.targetConstruction._hasAllResources()) { 284 | console.log(`Construction still needs resources, going to fetch more`); 285 | 286 | // Store current construction to continue working on it 287 | const currentConstruction = this.targetConstruction; 288 | 289 | // Reset state to fetch resources from warehouse 290 | this.state = 'FETCHING_RESOURCE'; 291 | 292 | // Give a small delay to ensure states don't change too quickly 293 | setTimeout(() => { 294 | if (this.state === 'FETCHING_RESOURCE') { 295 | this._fetchResourceFromWarehouse(); 296 | } 297 | }, 100); 298 | } else { 299 | console.log(`Construction has all needed resources, looking for other work`); 300 | // Reset target construction 301 | this.targetConstruction = null; 302 | // Back to idle state to find new work 303 | this.state = 'IDLE'; 304 | } 305 | } else { 306 | // Move to construction site interaction point 307 | const path = this.pathFinder.findPath( 308 | this.position.x, this.position.y, 309 | this.targetConstruction.interactionPoint.x, this.targetConstruction.interactionPoint.y 310 | ); 311 | 312 | if (path && path.length > 0) { 313 | console.log(`Porter moving to construction site along path of ${path.length} steps`); 314 | // Directly move to construction if path is too complex 315 | if (path.length > 10) { 316 | console.log("Path too complex, taking direct route"); 317 | this.targetPosition = null; // Clear any existing target 318 | this.moveTo({ x: this.targetConstruction.interactionPoint.x, y: this.targetConstruction.interactionPoint.y }); 319 | } else { 320 | // Clear the current path before starting a new one 321 | this.path = []; 322 | this.followPath(path); 323 | } 324 | } else { 325 | console.log('No path to construction site found, taking direct route'); 326 | // Direct route as fallback 327 | this.targetPosition = null; // Clear any existing target 328 | this.moveTo({ x: this.targetConstruction.interactionPoint.x, y: this.targetConstruction.interactionPoint.y }); 329 | } 330 | } 331 | } 332 | 333 | _addResourceSprite() { 334 | // Remove any existing resource sprite 335 | this._removeResourceSprite(); 336 | 337 | // Load the appropriate resource sprite based on resource type 338 | const textureLoader = new THREE.TextureLoader(); 339 | let spritePath; 340 | 341 | if (this.carriedResource === ResourceType.PLANK) { 342 | spritePath = '/assets/sprites/plank_carried.png'; 343 | } else { 344 | // Default for other resources 345 | spritePath = '/assets/sprites/plank_carried.png'; 346 | } 347 | 348 | textureLoader.load(spritePath, texture => { 349 | const spriteMaterial = new THREE.SpriteMaterial({ 350 | map: texture, 351 | transparent: true, 352 | depthTest: true 353 | }); 354 | 355 | const sprite = new THREE.Sprite(spriteMaterial); 356 | sprite.scale.set(0.5, 0.25, 1); 357 | sprite.position.set(0, 0.8, 0); // Position above the porter 358 | 359 | this.resourceSprite = sprite; 360 | this.mesh.add(sprite); 361 | }); 362 | } 363 | 364 | _removeResourceSprite() { 365 | if (this.resourceSprite) { 366 | this.mesh.remove(this.resourceSprite); 367 | this.resourceSprite = null; 368 | } 369 | } 370 | 371 | _createResourceAtConstruction() { 372 | // Only handle planks for now 373 | if (this.carriedResource !== ResourceType.PLANK) return; 374 | 375 | const textureLoader = new THREE.TextureLoader(); 376 | 377 | textureLoader.load('/assets/sprites/planks_on_ground.png', texture => { 378 | const spriteMaterial = new THREE.SpriteMaterial({ 379 | map: texture, 380 | transparent: true, 381 | depthTest: true 382 | }); 383 | 384 | const sprite = new THREE.Sprite(spriteMaterial); 385 | sprite.scale.set(0.8, 0.4, 1); 386 | 387 | // Create a random position near the construction site 388 | const offsetX = (Math.random() - 0.5) * 0.8; 389 | const offsetZ = (Math.random() - 0.5) * 0.8; 390 | 391 | const worldPos = this.world.getWorldPosition( 392 | this.targetConstruction.position.x, 393 | this.targetConstruction.position.y 394 | ); 395 | 396 | // Position slightly above ground to avoid z-fighting 397 | sprite.position.set(worldPos.x + offsetX, 0.05, worldPos.z + offsetZ); 398 | 399 | // Add to scene for a while, then fade out 400 | this.world.scene.add(sprite); 401 | 402 | // Fade out after the construction is complete 403 | const checkInterval = setInterval(() => { 404 | // Check if construction is complete or no longer exists 405 | if (!this.world.constructions.includes(this.targetConstruction)) { 406 | clearInterval(checkInterval); 407 | 408 | // Fade out 409 | let opacity = 1.0; 410 | const fadeInterval = setInterval(() => { 411 | opacity -= 0.05; 412 | spriteMaterial.opacity = opacity; 413 | 414 | if (opacity <= 0) { 415 | clearInterval(fadeInterval); 416 | this.world.scene.remove(sprite); 417 | } 418 | }, 100); 419 | } 420 | }, 1000); 421 | }); 422 | } 423 | } -------------------------------------------------------------------------------- /src/js/ui/UIManager.js: -------------------------------------------------------------------------------- 1 | import { ResourceType, BuildingType } from '../utils/Enums.js'; 2 | import { resourceNames, buildingNames, buildingCosts } from '../utils/Constants.js'; 3 | 4 | export class UIManager { 5 | constructor(game) { 6 | this.game = game; 7 | 8 | // UI elements 9 | this.resourcesPanel = document.getElementById('resources-panel'); 10 | this.buildingPanel = document.getElementById('building-panel'); 11 | this.warehousePanel = document.getElementById('warehouse-panel'); 12 | this.constructionPanel = document.getElementById('construction-panel'); 13 | this.buildMenuPanel = document.getElementById('build-menu-panel'); 14 | 15 | // Currently selected building 16 | this.selectedBuilding = null; 17 | this.selectedConstruction = null; 18 | 19 | // Track if build menu is open 20 | this.buildMenuOpen = false; 21 | 22 | // Available buildings to build 23 | this.availableBuildings = [ 24 | BuildingType.WOODCUTTER, 25 | // Add more building types as they become implemented 26 | ]; 27 | } 28 | 29 | init() { 30 | // Initialize UI 31 | this._initResourcesPanel(); 32 | this._initBuildMenu(); 33 | 34 | // Add resource update listener 35 | this.game.resourceManager.addListener(this._updateResourcesPanel.bind(this)); 36 | 37 | // Create construction panel if it doesn't exist 38 | if (!this.constructionPanel) { 39 | this.constructionPanel = document.createElement('div'); 40 | this.constructionPanel.id = 'construction-panel'; 41 | this.constructionPanel.className = 'hud-panel hidden'; 42 | document.getElementById('hud').appendChild(this.constructionPanel); 43 | } 44 | 45 | // Create build menu panel if it doesn't exist 46 | if (!this.buildMenuPanel) { 47 | this.buildMenuPanel = document.createElement('div'); 48 | this.buildMenuPanel.id = 'build-menu-panel'; 49 | this.buildMenuPanel.className = 'hud-panel hidden'; 50 | document.getElementById('hud').appendChild(this.buildMenuPanel); 51 | } 52 | 53 | // Remove any existing build button to avoid duplicates 54 | const existingButton = document.getElementById('build-button'); 55 | if (existingButton) { 56 | existingButton.remove(); 57 | } 58 | 59 | // Add a build button to the HUD 60 | const buildButton = document.createElement('button'); 61 | buildButton.id = 'build-button'; 62 | buildButton.className = 'hud-button'; 63 | buildButton.textContent = 'Build (B)'; 64 | buildButton.onclick = () => this.toggleBuildMenu(); 65 | document.getElementById('hud').appendChild(buildButton); 66 | 67 | // Add keyboard shortcut for build menu 68 | document.addEventListener('keydown', (event) => { 69 | if (event.key === 'b' || event.key === 'B') { 70 | this.toggleBuildMenu(); 71 | } 72 | 73 | // Add escape key to cancel building placement 74 | if (event.key === 'Escape' && this.game.world.buildingPlacementMode) { 75 | this.game.world.cancelBuildingPlacement(); 76 | } 77 | }); 78 | 79 | console.log('UI initialized with keyboard shortcuts'); 80 | } 81 | 82 | // Initialize the build menu with available buildings 83 | _initBuildMenu() { 84 | if (!this.buildMenuPanel) { 85 | console.error('Build menu panel not found'); 86 | return; 87 | } 88 | 89 | // Clear previous content 90 | this.buildMenuPanel.innerHTML = '

Build

'; 91 | 92 | // Create a list of available buildings 93 | const buildingsList = document.createElement('div'); 94 | buildingsList.className = 'buildings-list'; 95 | 96 | this.availableBuildings.forEach(buildingType => { 97 | const buildingName = buildingNames[buildingType] || buildingType; 98 | 99 | // Create building item 100 | const buildingItem = document.createElement('div'); 101 | buildingItem.className = 'building-item'; 102 | buildingItem.setAttribute('data-type', buildingType); 103 | 104 | // Add building name 105 | const nameElement = document.createElement('div'); 106 | nameElement.className = 'building-name'; 107 | nameElement.textContent = buildingName; 108 | buildingItem.appendChild(nameElement); 109 | 110 | // Add building costs 111 | const costsElement = document.createElement('div'); 112 | costsElement.className = 'building-costs'; 113 | 114 | const costs = buildingCosts[buildingType]; 115 | if (costs) { 116 | Object.entries(costs).forEach(([resource, amount]) => { 117 | const resourceName = resourceNames[resource] || resource; 118 | costsElement.innerHTML += `
${resourceName}: ${amount}
`; 119 | }); 120 | } 121 | 122 | buildingItem.appendChild(costsElement); 123 | 124 | // Add click handler 125 | buildingItem.addEventListener('click', () => { 126 | this.selectBuildingToBuild(buildingType); 127 | }); 128 | 129 | buildingsList.appendChild(buildingItem); 130 | }); 131 | 132 | this.buildMenuPanel.appendChild(buildingsList); 133 | 134 | // Add close button 135 | const closeButton = document.createElement('button'); 136 | closeButton.textContent = 'Close'; 137 | closeButton.addEventListener('click', () => this.hideBuildMenu()); 138 | this.buildMenuPanel.appendChild(closeButton); 139 | } 140 | 141 | _initResourcesPanel() { 142 | this.resourcesPanel.innerHTML = '

Resources

'; 143 | const resources = this.game.resourceManager.getAllResources(); 144 | 145 | for (const [type, amount] of Object.entries(resources)) { 146 | const resourceName = resourceNames[type] || type; 147 | this.resourcesPanel.innerHTML += ` 148 |
149 | ${resourceName}: 150 | ${amount} 151 |
152 | `; 153 | } 154 | } 155 | 156 | _updateResourcesPanel(resources) { 157 | for (const [type, amount] of Object.entries(resources)) { 158 | const resourceItem = this.resourcesPanel.querySelector(`.resource-item[data-type="${type}"]`); 159 | if (resourceItem) { 160 | const amountEl = resourceItem.querySelector('.resource-amount'); 161 | amountEl.textContent = amount; 162 | } 163 | } 164 | } 165 | 166 | showBuildingPanel(building) { 167 | this.selectedBuilding = building; 168 | this.buildingPanel.classList.remove('hidden'); 169 | 170 | // Clear previous content 171 | this.buildingPanel.innerHTML = ''; 172 | 173 | // Add building info 174 | this.buildingPanel.innerHTML += `

${building.name}

`; 175 | 176 | // Add production info if applicable 177 | if (building.produces) { 178 | const resourceName = resourceNames[building.produces.type] || building.produces.type; 179 | this.buildingPanel.innerHTML += ` 180 |
181 |
Produces: ${resourceName}
182 |
Rate: ${building.produces.rate} per min
183 |
Status: ${building.isProducing ? 'Working' : 'Idle'}
184 |
185 | `; 186 | } 187 | 188 | // Add storage info if applicable 189 | if (building.type === 'warehouse') { 190 | const warehouseButton = document.createElement('button'); 191 | warehouseButton.textContent = 'Show Storage'; 192 | warehouseButton.addEventListener('click', () => this.showWarehousePanel()); 193 | this.buildingPanel.appendChild(warehouseButton); 194 | } 195 | 196 | // Add upgrade button if applicable 197 | if (building.canUpgrade) { 198 | const upgradeButton = document.createElement('button'); 199 | upgradeButton.textContent = 'Upgrade'; 200 | upgradeButton.addEventListener('click', () => this._upgradeBuilding(building)); 201 | this.buildingPanel.appendChild(upgradeButton); 202 | } 203 | 204 | // Add close button 205 | const closeButton = document.createElement('button'); 206 | closeButton.textContent = 'Close'; 207 | closeButton.addEventListener('click', () => this.hideBuildingPanel()); 208 | this.buildingPanel.appendChild(closeButton); 209 | } 210 | 211 | hideBuildingPanel() { 212 | this.selectedBuilding = null; 213 | this.buildingPanel.classList.add('hidden'); 214 | } 215 | 216 | showWarehousePanel() { 217 | this.warehousePanel.classList.remove('hidden'); 218 | 219 | // Clear previous content 220 | this.warehousePanel.innerHTML = ''; 221 | 222 | // Add warehouse info 223 | this.warehousePanel.innerHTML += '

Warehouse Storage

'; 224 | 225 | // Add resources list 226 | const resources = this.game.resourceManager.getAllResources(); 227 | 228 | const resourcesList = document.createElement('div'); 229 | resourcesList.className = 'warehouse-resources'; 230 | 231 | for (const [type, amount] of Object.entries(resources)) { 232 | const resourceName = resourceNames[type] || type; 233 | resourcesList.innerHTML += ` 234 |
235 | ${resourceName}: 236 | ${amount} 237 |
238 | `; 239 | } 240 | 241 | this.warehousePanel.appendChild(resourcesList); 242 | 243 | // Add close button 244 | const closeButton = document.createElement('button'); 245 | closeButton.textContent = 'Close'; 246 | closeButton.addEventListener('click', () => this.hideWarehousePanel()); 247 | this.warehousePanel.appendChild(closeButton); 248 | } 249 | 250 | hideWarehousePanel() { 251 | this.warehousePanel.classList.add('hidden'); 252 | } 253 | 254 | // Show the construction panel for a selected construction site 255 | showConstructionPanel(construction) { 256 | this.selectedConstruction = construction; 257 | 258 | // Hide any other panels 259 | this.hideBuildingPanel(); 260 | this.hideWarehousePanel(); 261 | this.hideBuildMenu(); 262 | 263 | if (!this.constructionPanel) { 264 | console.error('Construction panel not found'); 265 | return; 266 | } 267 | 268 | this.constructionPanel.classList.remove('hidden'); 269 | 270 | // Clear previous content 271 | this.constructionPanel.innerHTML = ''; 272 | 273 | // Add construction info 274 | const buildingName = buildingNames[construction.targetBuildingType] || 'Building'; 275 | this.constructionPanel.innerHTML += `

${buildingName} Construction

`; 276 | 277 | // Add progress bar 278 | const progressContainer = document.createElement('div'); 279 | progressContainer.className = 'construction-progress-container'; 280 | 281 | const progressBar = document.createElement('div'); 282 | progressBar.className = 'construction-progress-bar'; 283 | progressBar.style.width = `${construction.progress}%`; 284 | 285 | const progressText = document.createElement('div'); 286 | progressText.className = 'construction-progress-text'; 287 | progressText.textContent = `${Math.floor(construction.progress)}%`; 288 | 289 | progressContainer.appendChild(progressBar); 290 | progressContainer.appendChild(progressText); 291 | this.constructionPanel.appendChild(progressContainer); 292 | 293 | // Add resource requirements 294 | const resourcesElement = document.createElement('div'); 295 | resourcesElement.className = 'construction-resources'; 296 | resourcesElement.innerHTML = '

Required Resources:

'; 297 | 298 | Object.entries(construction.requiredResources).forEach(([resource, amount]) => { 299 | const resourceName = resourceNames[resource] || resource; 300 | const allocated = construction.allocatedResources[resource] || 0; 301 | 302 | resourcesElement.innerHTML += ` 303 |
304 | ${resourceName}: 305 | ${allocated}/${amount} 306 |
307 | `; 308 | }); 309 | 310 | this.constructionPanel.appendChild(resourcesElement); 311 | 312 | // Add close button 313 | const closeButton = document.createElement('button'); 314 | closeButton.textContent = 'Close'; 315 | closeButton.addEventListener('click', () => this.hideConstructionPanel()); 316 | this.constructionPanel.appendChild(closeButton); 317 | 318 | // Update the panel every 10 frames to show progress 319 | this.constructionUpdateInterval = setInterval(() => { 320 | if (this.selectedConstruction) { 321 | const progressBar = this.constructionPanel.querySelector('.construction-progress-bar'); 322 | const progressText = this.constructionPanel.querySelector('.construction-progress-text'); 323 | 324 | if (progressBar && progressText) { 325 | progressBar.style.width = `${this.selectedConstruction.progress}%`; 326 | progressText.textContent = `${Math.floor(this.selectedConstruction.progress)}%`; 327 | } 328 | 329 | // Update resource allocation display 330 | const resourceElements = this.constructionPanel.querySelectorAll('.resource-requirement'); 331 | 332 | Object.entries(this.selectedConstruction.requiredResources).forEach(([resource, amount], index) => { 333 | if (index < resourceElements.length) { 334 | const allocated = this.selectedConstruction.allocatedResources[resource] || 0; 335 | const amountElement = resourceElements[index].querySelector('.resource-amount'); 336 | if (amountElement) { 337 | amountElement.textContent = `${allocated}/${amount}`; 338 | } 339 | } 340 | }); 341 | } 342 | }, 100); 343 | } 344 | 345 | hideConstructionPanel() { 346 | if (this.constructionUpdateInterval) { 347 | clearInterval(this.constructionUpdateInterval); 348 | this.constructionUpdateInterval = null; 349 | } 350 | 351 | this.selectedConstruction = null; 352 | 353 | if (this.constructionPanel) { 354 | this.constructionPanel.classList.add('hidden'); 355 | } 356 | } 357 | 358 | // Toggle the build menu 359 | toggleBuildMenu() { 360 | console.log('Toggle build menu called. Current state:', this.buildMenuOpen); 361 | if (this.buildMenuOpen) { 362 | this.hideBuildMenu(); 363 | } else { 364 | this.showBuildMenu(); 365 | } 366 | } 367 | 368 | showBuildMenu() { 369 | // Hide other panels 370 | this.hideBuildingPanel(); 371 | this.hideWarehousePanel(); 372 | this.hideConstructionPanel(); 373 | 374 | // Make sure panel exists and is properly initialized 375 | if (!this.buildMenuPanel) { 376 | this.buildMenuPanel = document.getElementById('build-menu-panel'); 377 | if (!this.buildMenuPanel) { 378 | console.error('Build menu panel not found in DOM'); 379 | return; 380 | } 381 | } 382 | 383 | // Re-initialize menu content 384 | this._initBuildMenu(); 385 | 386 | // Show build menu 387 | this.buildMenuPanel.classList.remove('hidden'); 388 | this.buildMenuOpen = true; 389 | console.log('Build menu shown'); 390 | } 391 | 392 | hideBuildMenu() { 393 | if (this.buildMenuPanel) { 394 | this.buildMenuPanel.classList.add('hidden'); 395 | this.buildMenuOpen = false; 396 | console.log('Build menu hidden'); 397 | } 398 | } 399 | 400 | // Select a building to place 401 | selectBuildingToBuild(buildingType) { 402 | // Get costs for the selected building 403 | const costs = buildingCosts[buildingType]; 404 | 405 | // Check if we have enough resources 406 | if (!this.game.resourceManager.hasResources(costs)) { 407 | console.log('Not enough resources to build'); 408 | // TODO: Show error message 409 | return; 410 | } 411 | 412 | // Start building placement mode 413 | this.game.world.startBuildingPlacement(buildingType); 414 | 415 | // Hide the build menu after selection 416 | this.hideBuildMenu(); 417 | 418 | // Show placement instructions 419 | this._showPlacementInstructions(buildingType); 420 | } 421 | 422 | // Show a floating message with placement instructions 423 | _showPlacementInstructions(buildingType) { 424 | const buildingName = buildingNames[buildingType] || 'Building'; 425 | 426 | // Create a message element 427 | const message = document.createElement('div'); 428 | message.className = 'placement-instructions'; 429 | message.textContent = `Click on a valid grass tile to place ${buildingName}. Press Escape to cancel.`; 430 | 431 | // Add to HUD 432 | document.getElementById('hud').appendChild(message); 433 | 434 | // Remove after 5 seconds 435 | setTimeout(() => { 436 | message.remove(); 437 | }, 5000); 438 | 439 | // Add escape key handler to cancel placement 440 | const escHandler = (event) => { 441 | if (event.key === 'Escape') { 442 | this.game.world.cancelBuildingPlacement(); 443 | document.removeEventListener('keydown', escHandler); 444 | message.remove(); 445 | } 446 | }; 447 | 448 | document.addEventListener('keydown', escHandler); 449 | } 450 | 451 | _upgradeBuilding(building) { 452 | // Check if we have resources for upgrade 453 | if (this.game.resourceManager.hasResources(building.upgradeCost)) { 454 | // Consume resources 455 | this.game.resourceManager.consumeResources(building.upgradeCost); 456 | 457 | // Upgrade building 458 | building.upgrade(); 459 | 460 | // Update UI 461 | this.showBuildingPanel(building); 462 | } else { 463 | console.log('Not enough resources for upgrade'); 464 | // TODO: Show error message to user 465 | } 466 | } 467 | } -------------------------------------------------------------------------------- /src/js/core/World.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { TerrainType, VisibilityState, BuildingType, SettlerType } from '../utils/Enums.js'; 3 | import { Warehouse } from '../entities/buildings/Warehouse.js'; 4 | import { Woodcutter } from '../entities/buildings/Woodcutter.js'; 5 | import { Construction } from '../entities/buildings/Construction.js'; 6 | import { Porter } from '../entities/settlers/Porter.js'; 7 | import { Builder } from '../entities/settlers/Builder.js'; 8 | import * as MapGen from '../utils/MapGen.js'; 9 | import { FOG_OF_WAR, buildingCosts } from '../utils/Constants.js'; 10 | 11 | export class World { 12 | constructor(game) { 13 | this.game = game; 14 | this.scene = game.scene; 15 | 16 | // World settings - massive world 17 | this.size = { width: 500, height: 500 }; 18 | this.tileSize = 2; 19 | 20 | // Texture loader for terrain sprites 21 | this.textureLoader = new THREE.TextureLoader(); 22 | this.textures = {}; // Cache for loaded textures 23 | 24 | // World data 25 | this.terrain = []; 26 | this.buildings = []; 27 | this.settlers = []; 28 | this.constructions = []; // Track buildings under construction 29 | 30 | // Fog of War 31 | this.fogOfWar = []; 32 | this.fogOfWarMesh = null; 33 | 34 | // Raycaster for mouse interaction 35 | this.raycaster = new THREE.Raycaster(); 36 | this.mouse = new THREE.Vector2(); 37 | 38 | // Building placement mode 39 | this.buildingPlacementMode = false; 40 | this.buildingTypeToPlace = null; 41 | this.placementPreviewMesh = null; 42 | this.placementValid = false; 43 | 44 | // Bind methods 45 | this._onMouseMove = this._onMouseMove.bind(this); 46 | this._onClick = this._onClick.bind(this); 47 | } 48 | 49 | init() { 50 | // Load terrain textures first 51 | this._loadTerrainTextures(() => { 52 | // Initialize terrain grid 53 | this._initTerrain(); 54 | 55 | // Initialize fog of war 56 | this._initFogOfWar(); 57 | 58 | // Add starting buildings 59 | this._addStartingBuildings(); 60 | 61 | // Add grass sprites 62 | this._addGrassSprites(); 63 | 64 | // Update fog of war for starting area 65 | if (FOG_OF_WAR.ENABLED) { 66 | this._updateFogOfWar(); 67 | } 68 | }); 69 | 70 | // Add event listeners for mouse interaction 71 | window.addEventListener('mousemove', this._onMouseMove); 72 | window.addEventListener('click', this._onClick); 73 | 74 | console.log('World initialized'); 75 | } 76 | 77 | // Load all terrain textures 78 | _loadTerrainTextures(callback) { 79 | const texturesToLoad = [ 80 | // Grass textures with various path formats - trying direct paths first for more reliability 81 | { name: 'grass1', path: '/assets/textures/grass1.png' }, // New grass texture 82 | { name: 'grass2', path: '/assets/textures/grass2.png' }, // New grass texture 83 | { name: 'grass3', path: '/assets/textures/grass3.png' }, // New grass texture 84 | { name: 'grass4', path: '/assets/textures/grass4.png' }, // New grass texture 85 | { name: 'grass5', path: '/assets/textures/grass5.png' }, // New grass texture 86 | { name: 'grass6', path: '/assets/textures/grass6.png' }, // New grass texture 87 | { name: 'grass7', path: '/assets/textures/grass7.png' }, // New grass texture 88 | { name: 'grass8', path: '/assets/textures/grass8.png' }, // New grass texture 89 | { name: 'grass9', path: '/assets/textures/grass9.png' }, // New grass texture 90 | { name: 'grass10', path: '/assets/textures/grass10.png' }, // New grass texture 91 | 92 | // Tree textures for forest areas 93 | { name: 'tree1', path: '/assets/sprites/tree1.png' }, 94 | { name: 'tree2', path: '/assets/sprites/tree2.png' }, 95 | { name: 'tree3', path: '/assets/sprites/tree3.png' }, 96 | { name: 'tree4', path: '/assets/sprites/tree4.png' }, 97 | { name: 'tree5', path: '/assets/sprites/tree5.png' }, 98 | { name: 'tree6', path: '/assets/sprites/tree6.png' }, 99 | { name: 'tree7', path: '/assets/sprites/tree7.png' }, 100 | { name: 'tree8', path: '/assets/sprites/tree8.png' }, 101 | { name: 'tree9', path: '/assets/sprites/tree9.png' }, 102 | { name: 'tree10', path: '/assets/sprites/tree10.png' }, 103 | ]; 104 | 105 | let loadedCount = 0; 106 | const totalToLoad = texturesToLoad.length; 107 | 108 | texturesToLoad.forEach(texture => { 109 | this.textureLoader.load( 110 | texture.path, 111 | (loadedTexture) => { 112 | // Store the loaded texture 113 | this.textures[texture.name] = loadedTexture; 114 | loadedCount++; 115 | 116 | console.log(`Loaded texture: ${texture.name} from ${texture.path}`); 117 | 118 | // If all textures are loaded, call the callback 119 | if (loadedCount === totalToLoad) { 120 | console.log('All terrain textures loaded'); 121 | callback(); 122 | } 123 | }, 124 | undefined, // progress callback 125 | (error) => { 126 | console.error(`Error loading texture ${texture.name}:`, error); 127 | loadedCount++; 128 | 129 | // Continue even if texture loading fails 130 | if (loadedCount === totalToLoad) { 131 | console.log('Completed terrain texture loading with some errors'); 132 | callback(); 133 | } 134 | } 135 | ); 136 | }); 137 | } 138 | 139 | update() { 140 | // Update all settlers 141 | for (const settler of this.settlers) { 142 | settler.update(); 143 | } 144 | 145 | // Update all buildings 146 | for (const building of this.buildings) { 147 | building.update(); 148 | } 149 | 150 | // Update all constructions 151 | for (const construction of this.constructions) { 152 | construction.update(); 153 | } 154 | 155 | // Update building placement preview if in placement mode 156 | if (this.buildingPlacementMode) { 157 | this._updatePlacementPreview(); 158 | } 159 | 160 | // Update fog of war if enabled - uncomment when needed 161 | // Currently updating only when buildings are added 162 | // if (FOG_OF_WAR.ENABLED) { 163 | // this._updateFogOfWar(); 164 | // } 165 | } 166 | 167 | _initTerrain() { 168 | console.log("Generating terrain..."); 169 | 170 | // Create a more detailed terrain geometry 171 | const gridGeometry = new THREE.PlaneGeometry( 172 | this.size.width * this.tileSize, 173 | this.size.height * this.tileSize, 174 | this.size.width, 175 | this.size.height 176 | ); 177 | gridGeometry.rotateX(-Math.PI / 2); // Make it horizontal 178 | 179 | // Get position attribute for modifying heights 180 | const position = gridGeometry.attributes.position; 181 | 182 | // Arrays to store colors and updated positions 183 | const colors = []; 184 | const vertices = []; 185 | 186 | // Generate terrain using MapGen 187 | console.log("Using MapGen to create terrain..."); 188 | this.terrain = MapGen.generateTileMap(this.size.width, this.size.height); 189 | 190 | // Update vertex positions and colors based on terrain data 191 | for (let i = 0; i < position.count; i++) { 192 | const x = Math.floor(i % (this.size.width + 1)); 193 | const y = Math.floor(i / (this.size.width + 1)); 194 | 195 | // Get current vertex 196 | const vertex = new THREE.Vector3( 197 | position.getX(i), 198 | position.getY(i), 199 | position.getZ(i) 200 | ); 201 | 202 | // If within bounds, modify based on terrain height 203 | if (x < this.size.width && y < this.size.height) { 204 | const terrain = this.terrain[y][x]; 205 | 206 | // Modify Y coordinate (height) 207 | // vertex.y = terrain.height; 208 | 209 | // Assign color based on terrain type 210 | switch (terrain) { 211 | case TerrainType.GRASS: 212 | // Varied green for grass, brighter at higher elevations 213 | colors.push(0.21, 0.85, 0.21); 214 | break; 215 | 216 | case TerrainType.WATER: 217 | colors.push(0.0, 0.3, 0.5); 218 | break; 219 | 220 | case TerrainType.SAND: 221 | // Sandy beaches - tan color 222 | colors.push(0.76, 0.7, 0.5); 223 | break; 224 | 225 | case TerrainType.FOREST: 226 | // Darker green for forests 227 | colors.push(0.0, 0.35, 0.0); 228 | break; 229 | 230 | case TerrainType.MOUNTAIN: 231 | // Rocky mountains - gray with subtle variations 232 | colors.push(0.4, 0.4, 0.4); 233 | break; 234 | 235 | case TerrainType.SNOW: 236 | // Snow-capped peaks - white with slight blue tint 237 | colors.push(0.9, 0.9, 1.0); 238 | break; 239 | 240 | case TerrainType.STONE: 241 | // Stone/rock - grey brown 242 | colors.push(0.5, 0.45, 0.4); 243 | break; 244 | 245 | default: 246 | colors.push(1, 1, 1); // White 247 | break; 248 | } 249 | } else { 250 | // Edge vertices - set to lowest surrounding height 251 | const nearX = Math.min(Math.max(x, 0), this.size.width - 1); 252 | const nearY = Math.min(Math.max(y, 0), this.size.height - 1); 253 | 254 | if (this.terrain[nearY] && this.terrain[nearY][nearX]) { 255 | vertex.y = this.terrain[nearY][nearX].height; 256 | } 257 | 258 | colors.push(0.8, 0.8, 0.8); // Light grey for edges 259 | } 260 | 261 | // Store updated position 262 | position.setXYZ(i, vertex.x, vertex.y, vertex.z); 263 | } 264 | 265 | // Update the geometry after modifying positions 266 | position.needsUpdate = true; 267 | gridGeometry.computeVertexNormals(); // Recalculate normals for proper lighting 268 | 269 | // Set vertex colors 270 | gridGeometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); 271 | 272 | // Create grid material with basic material (unaffected by lighting) 273 | const gridMaterial = new THREE.MeshBasicMaterial({ 274 | vertexColors: true, 275 | side: THREE.DoubleSide 276 | }); 277 | 278 | // Create grid mesh 279 | const gridMesh = new THREE.Mesh(gridGeometry, gridMaterial); 280 | gridMesh.name = 'terrain'; 281 | 282 | // Allow terrain to receive shadows 283 | gridMesh.receiveShadow = true; 284 | gridMesh.castShadow = true; 285 | 286 | // Position the grid so (0,0) is at the bottom-left, and the center of the grid is at (0,0,0) in world space 287 | gridMesh.position.x = 0; 288 | gridMesh.position.z = 0; 289 | 290 | this.scene.add(gridMesh); 291 | 292 | // Add grid helper for reference (now at water level) 293 | // For a massive world, we need fewer grid lines for performance 294 | const gridSize = this.size.width * this.tileSize; 295 | const gridDivisions = 50; // Just 50 grid lines instead of hundreds 296 | 297 | const gridHelper = new THREE.GridHelper( 298 | gridSize, 299 | gridDivisions, 300 | 0x000000, // Black color for main grid lines 301 | 0x444444 // Darker gray color for secondary grid lines 302 | ); 303 | gridHelper.position.y = 2.5; // At water level 304 | gridHelper.position.x = 0; 305 | gridHelper.position.z = 0; 306 | gridHelper.material.opacity = 0.2; // More transparent for less visual clutter 307 | gridHelper.material.transparent = true; 308 | this.scene.add(gridHelper); 309 | 310 | // Store the terrain mesh for later reference 311 | this.terrainMesh = gridMesh; 312 | 313 | console.log("Terrain generation complete!"); 314 | } 315 | 316 | // Initialize fog of war system 317 | _initFogOfWar() { 318 | if (!FOG_OF_WAR.ENABLED) return; 319 | 320 | console.log("Initializing fog of war..."); 321 | 322 | // Create a fog of war overlay 323 | const fogGeometry = new THREE.PlaneGeometry( 324 | this.size.width * this.tileSize, 325 | this.size.height * this.tileSize, 326 | this.size.width, 327 | this.size.height 328 | ); 329 | fogGeometry.rotateX(-Math.PI / 2); // Make it horizontal like the terrain 330 | 331 | // Create fog material - black with transparency 332 | const fogMaterial = new THREE.MeshBasicMaterial({ 333 | color: 0x000000, 334 | transparent: true, 335 | opacity: 0.8, 336 | side: THREE.DoubleSide, 337 | depthWrite: false // Don't write to depth buffer so it doesn't interfere with raycasting 338 | }); 339 | 340 | // Create fog mesh 341 | this.fogOfWarMesh = new THREE.Mesh(fogGeometry, fogMaterial); 342 | this.fogOfWarMesh.name = 'fogOfWar'; 343 | 344 | // Position the fog slightly above the terrain to avoid z-fighting 345 | this.fogOfWarMesh.position.x = 0; 346 | this.fogOfWarMesh.position.y = 0.1; // Just slightly above terrain 347 | this.fogOfWarMesh.position.z = 0; 348 | 349 | // Add to scene 350 | this.scene.add(this.fogOfWarMesh); 351 | 352 | // Initialize fog of war data 353 | for (let y = 0; y < this.size.height; y++) { 354 | this.fogOfWar[y] = []; 355 | for (let x = 0; x < this.size.width; x++) { 356 | this.fogOfWar[y][x] = { 357 | visible: false, // Is tile currently visible? 358 | explored: false // Has tile been seen before? 359 | }; 360 | } 361 | } 362 | 363 | console.log("Fog of war initialized"); 364 | } 365 | 366 | // Update fog of war based on building positions 367 | _updateFogOfWar() { 368 | if (!FOG_OF_WAR.ENABLED || !this.fogOfWarMesh) return; 369 | 370 | // Get fog geometry 371 | const fogGeometry = this.fogOfWarMesh.geometry; 372 | const position = fogGeometry.attributes.position; 373 | 374 | // Create color array for vertex colors (black for fog, transparent for visible areas) 375 | const colors = []; 376 | 377 | // First, calculate which tiles are currently visible based on buildings 378 | // Reset visibility first 379 | for (let y = 0; y < this.size.height; y++) { 380 | for (let x = 0; x < this.size.width; x++) { 381 | this.terrain[y][x].visibility = this.fogOfWar[y][x].explored ? 382 | VisibilityState.EXPLORED : VisibilityState.UNEXPLORED; 383 | } 384 | } 385 | 386 | // Then check each building's visibility radius 387 | for (const building of this.buildings) { 388 | const buildingType = building.type; 389 | const radius = FOG_OF_WAR.BUILDING_VISIBILITY_RADIUS[buildingType] || 390 | FOG_OF_WAR.INITIAL_VISIBILITY_RADIUS; 391 | 392 | // Make tiles within radius visible 393 | this._revealArea(building.position.x, building.position.y, radius); 394 | } 395 | 396 | // Update color array based on visibility 397 | for (let i = 0; i < position.count; i++) { 398 | const x = Math.floor(i % (this.size.width + 1)); 399 | const y = Math.floor(i / (this.size.width + 1)); 400 | 401 | if (x < this.size.width && y < this.size.height) { 402 | const visibility = this.terrain[y][x].visibility; 403 | 404 | // Set color based on visibility state 405 | switch (visibility) { 406 | case VisibilityState.VISIBLE: 407 | // Fully visible - transparent 408 | colors.push(0, 0, 0, 0); // RGBA (fully transparent) 409 | break; 410 | case VisibilityState.EXPLORED: 411 | // Previously explored - semi-transparent dark 412 | colors.push(0, 0, 0, 0.5); // RGBA (semi-transparent) 413 | break; 414 | case VisibilityState.UNEXPLORED: 415 | default: 416 | // Unexplored - solid black 417 | colors.push(0, 0, 0, 1); // RGBA (black) 418 | break; 419 | } 420 | } else { 421 | // Edge vertices - fully dark 422 | colors.push(0, 0, 0, 1); 423 | } 424 | } 425 | 426 | // Update the fog mesh with new colors 427 | fogGeometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 4)); 428 | fogGeometry.attributes.color.needsUpdate = true; 429 | } 430 | 431 | // Reveal an area around a point (make tiles visible) 432 | _revealArea(centerX, centerY, radius) { 433 | const radiusSquared = radius * radius; 434 | 435 | // Check all tiles within a square, but apply a circular mask 436 | for (let y = centerY - radius; y <= centerY + radius; y++) { 437 | for (let x = centerX - radius; x <= centerX + radius; x++) { 438 | // Skip if out of bounds 439 | if (y < 0 || y >= this.size.height || x < 0 || x >= this.size.width) { 440 | continue; 441 | } 442 | 443 | // Calculate squared distance 444 | const dx = x - centerX; 445 | const dy = y - centerY; 446 | const distanceSquared = dx * dx + dy * dy; 447 | 448 | // If within radius, make visible and mark as explored 449 | if (distanceSquared <= radiusSquared) { 450 | this.terrain[y][x].visibility = VisibilityState.VISIBLE; 451 | this.fogOfWar[y][x].visible = true; 452 | this.fogOfWar[y][x].explored = true; 453 | } 454 | } 455 | } 456 | } 457 | 458 | _addStartingBuildings() { 459 | console.log("Finding suitable location for starting buildings..."); 460 | 461 | // Always start at the exact center of the map 462 | let startX = Math.floor(this.size.width / 2); 463 | let startY = Math.floor(this.size.height / 2); 464 | 465 | console.log(`Starting at the center of the map: (${startX}, ${startY})`); 466 | 467 | // Force the center to be buildable - don't search for a natural spot 468 | let foundSuitableSpot = false; 469 | 470 | // For debugging only - to see if there's a naturally good spot nearby 471 | // This code will run but won't change our starting position 472 | const searchRadius = Math.min(this.size.width, this.size.height) / 4; 473 | for (let attempts = 0; attempts < 10 && !foundSuitableSpot; attempts++) { 474 | // Try random positions near the center 475 | const testX = Math.floor(startX + (Math.random() * 2 - 1) * searchRadius); 476 | const testY = Math.floor(startY + (Math.random() * 2 - 1) * searchRadius); 477 | 478 | // Check if this position and surrounding area is suitable 479 | if (this._isAreaSuitableForWarehouse(testX, testY, 3, 3)) { 480 | // Don't actually change startX/Y - just log for debugging 481 | console.log(`Found naturally suitable spot at ${testX}, ${testY}, but using center anyway`); 482 | foundSuitableSpot = true; 483 | break; 484 | } 485 | } 486 | 487 | // If no natural spot is found, force-create one 488 | if (!foundSuitableSpot) { 489 | console.log("No natural suitable spot found. Creating a flat area..."); 490 | 491 | // Flatten and prepare a larger area centered exactly on the warehouse position 492 | // Since warehouse is at (startX-1, startY-1) and is 2x2, we center a 30x30 area around it 493 | this._flattenArea(startX - 15, startY - 15, 30, 30, 1.8); // Lower elevation to match new height scale 494 | } 495 | 496 | // Make sure a 3x3 area for the warehouse and immediate surroundings is buildable grass 497 | for (let y = startY - 1; y <= startY + 1; y++) { 498 | for (let x = startX - 1; x <= startX + 1; x++) { 499 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 500 | this.terrain[y][x].type = TerrainType.GRASS; 501 | this.terrain[y][x].buildable = true; 502 | } 503 | } 504 | } 505 | 506 | // Create and add the warehouse exactly at the center of the map 507 | // For a 2x2 building, we place it so its center aligns with the map center 508 | const warehouseX = startX - 1; // Offset by 1 since warehouse is 2x2 509 | const warehouseY = startY - 1; 510 | 511 | // Create warehouse at the exact center 512 | const warehouse = new Warehouse(this, warehouseX, warehouseY); 513 | this.addBuilding(warehouse); 514 | 515 | console.log(`Warehouse placed at grid coordinates: (${warehouseX}, ${warehouseY})`); 516 | console.log(`Map center is at grid coordinates: (${startX}, ${startY})`); 517 | 518 | // Add initial settlers 519 | this._addStartingSettlers(warehouseX, warehouseY); 520 | 521 | 522 | console.log("Starting buildings added!"); 523 | 524 | // Center the camera on the warehouse rather than the exact center 525 | // This ensures we're looking at the warehouse, which is slightly offset from center 526 | this._centerCameraOnStartingArea(startX - 1, startY - 1); 527 | } 528 | 529 | // Check if an area is suitable for building (flat enough, not water) 530 | _isAreaSuitableForWarehouse(startX, startY, width, height) { 531 | // Make sure the area is within the map bounds 532 | if (startX < 0 || startY < 0 || 533 | startX + width > this.size.width || 534 | startY + height > this.size.height) { 535 | return false; 536 | } 537 | 538 | // Check if the entire area is buildable and flat enough 539 | let baseHeight = null; 540 | let maxHeightDiff = 0.3; // Maximum allowable height difference 541 | 542 | for (let y = startY; y < startY + height; y++) { 543 | for (let x = startX; x < startX + width; x++) { 544 | const terrain = this.terrain[y][x]; 545 | 546 | // Reject water or mountains 547 | if (terrain.type === TerrainType.WATER || 548 | terrain.type === TerrainType.MOUNTAIN) { 549 | return false; 550 | } 551 | 552 | // Check height difference 553 | if (baseHeight === null) { 554 | baseHeight = terrain.height; 555 | } else if (Math.abs(terrain.height - baseHeight) > maxHeightDiff) { 556 | return false; 557 | } 558 | } 559 | } 560 | 561 | // Also check surrounding area to make sure it's not isolated on a tiny plateau 562 | const buffer = 2; // Check 2 tiles around the building area 563 | let accessibleTiles = 0; 564 | 565 | for (let y = startY - buffer; y < startY + height + buffer; y++) { 566 | for (let x = startX - buffer; x < startX + width + buffer; x++) { 567 | // Skip the building area itself 568 | if (x >= startX && x < startX + width && y >= startY && y < startY + height) { 569 | continue; 570 | } 571 | 572 | // Check if this position is in bounds 573 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 574 | const terrain = this.terrain[y][x]; 575 | 576 | // Count buildable tiles that are close in height to our base 577 | if (terrain.type === TerrainType.GRASS && 578 | Math.abs(terrain.height - baseHeight) < 1.0) { 579 | accessibleTiles++; 580 | } 581 | } 582 | } 583 | } 584 | 585 | // Ensure we have enough accessible tiles around (at least 50% of the perimeter) 586 | const requiredAccessible = (width + height) * 2; 587 | return accessibleTiles >= requiredAccessible; 588 | } 589 | 590 | // Flatten an area to a specific height 591 | _flattenArea(startX, startY, width, height, targetHeight) { 592 | // Clamp to map bounds 593 | const endX = Math.min(startX + width, this.size.width); 594 | const endY = Math.min(startY + height, this.size.height); 595 | const clampedStartX = Math.max(0, startX); 596 | const clampedStartY = Math.max(0, startY); 597 | 598 | console.log(`Flattening area from (${clampedStartX}, ${clampedStartY}) to (${endX}, ${endY}) at height ${targetHeight}`); 599 | 600 | // Flatten central area completely 601 | for (let y = clampedStartY; y < endY; y++) { 602 | for (let x = clampedStartX; x < endX; x++) { 603 | this.terrain[y][x].height = targetHeight; 604 | this.terrain[y][x].type = TerrainType.GRASS; 605 | this.terrain[y][x].buildable = true; 606 | } 607 | } 608 | 609 | // Create a gradual slope around the flattened area (for a natural look) 610 | const fadeDistance = 5; // Increased distance for a more gradual transition 611 | 612 | for (let fadeLevel = 1; fadeLevel <= fadeDistance; fadeLevel++) { 613 | // Calculate the height adjustment for this fade level 614 | const fadeFactor = 1 - (fadeLevel / (fadeDistance + 1)); 615 | const heightAdjustment = (targetHeight - 1.0) * fadeFactor; 616 | 617 | // Process each side of the rectangle (top, right, bottom, left) 618 | // Top side 619 | for (let x = clampedStartX - fadeLevel; x < endX + fadeLevel; x++) { 620 | const y = clampedStartY - fadeLevel; 621 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 622 | // Blend with existing height 623 | const originalHeight = this.terrain[y][x].height; 624 | this.terrain[y][x].height = 1.0 + heightAdjustment; 625 | 626 | // If it's not water, make it buildable grass 627 | if (this.terrain[y][x].height >= 1.2) { 628 | this.terrain[y][x].type = TerrainType.GRASS; 629 | this.terrain[y][x].buildable = true; 630 | } 631 | } 632 | } 633 | 634 | // Bottom side 635 | for (let x = clampedStartX - fadeLevel; x < endX + fadeLevel; x++) { 636 | const y = endY + fadeLevel - 1; 637 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 638 | const originalHeight = this.terrain[y][x].height; 639 | this.terrain[y][x].height = 1.0 + heightAdjustment; 640 | 641 | if (this.terrain[y][x].height >= 1.2) { 642 | this.terrain[y][x].type = TerrainType.GRASS; 643 | this.terrain[y][x].buildable = true; 644 | } 645 | } 646 | } 647 | 648 | // Left side 649 | for (let y = clampedStartY - fadeLevel + 1; y < endY + fadeLevel - 1; y++) { 650 | const x = clampedStartX - fadeLevel; 651 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 652 | const originalHeight = this.terrain[y][x].height; 653 | this.terrain[y][x].height = 1.0 + heightAdjustment; 654 | 655 | if (this.terrain[y][x].height >= 1.2) { 656 | this.terrain[y][x].type = TerrainType.GRASS; 657 | this.terrain[y][x].buildable = true; 658 | } 659 | } 660 | } 661 | 662 | // Right side 663 | for (let y = clampedStartY - fadeLevel + 1; y < endY + fadeLevel - 1; y++) { 664 | const x = endX + fadeLevel - 1; 665 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 666 | const originalHeight = this.terrain[y][x].height; 667 | this.terrain[y][x].height = 1.0 + heightAdjustment; 668 | 669 | if (this.terrain[y][x].height >= 1.2) { 670 | this.terrain[y][x].type = TerrainType.GRASS; 671 | this.terrain[y][x].buildable = true; 672 | } 673 | } 674 | } 675 | } 676 | 677 | // After modifying terrain, we need to update the terrain mesh 678 | this._updateTerrainMesh(); 679 | } 680 | 681 | // Update the terrain mesh to reflect changes in the terrain data 682 | _updateTerrainMesh() { 683 | // Find the terrain mesh 684 | const terrainMesh = this.scene.getObjectByName('terrain'); 685 | if (!terrainMesh) return; 686 | 687 | // Get the position attribute 688 | const position = terrainMesh.geometry.attributes.position; 689 | 690 | // Update all vertex positions and colors 691 | const colors = []; 692 | 693 | for (let i = 0; i < position.count; i++) { 694 | const x = Math.floor(i % (this.size.width + 1)); 695 | const y = Math.floor(i / (this.size.width + 1)); 696 | 697 | if (x < this.size.width && y < this.size.height) { 698 | const terrain = this.terrain[y][x]; 699 | 700 | // Update height 701 | position.setY(i, terrain.height); 702 | 703 | // Update color 704 | switch (terrain.type) { 705 | case TerrainType.GRASS: 706 | // Varied green for grass, brighter at higher elevations 707 | const greenShade = 0.5 + (terrain.height / 8) * 0.4; 708 | colors.push(0.1, greenShade, 0.1); 709 | break; 710 | 711 | case TerrainType.WATER: 712 | // Deep water is darker blue, shallow water is lighter 713 | if (terrain.height < 1.5) { 714 | // Deep ocean - dark blue 715 | colors.push(0.0, 0.0, 0.4); 716 | } else { 717 | // Shallow water - lighter blue 718 | const blueShade = 0.3 + (terrain.height - 1.5); 719 | colors.push(0.0, 0.3, blueShade); 720 | } 721 | break; 722 | 723 | case TerrainType.SAND: 724 | // Sandy beaches - tan color 725 | colors.push(0.76, 0.7, 0.5); 726 | break; 727 | 728 | case TerrainType.FOREST: 729 | // Darker green for forests 730 | colors.push(0.0, 0.35, 0.0); 731 | break; 732 | 733 | case TerrainType.MOUNTAIN: 734 | // Rocky mountains - gray with subtle variations 735 | const greyShade = 0.4 + (terrain.height - 10) * 0.05; 736 | colors.push(greyShade, greyShade, greyShade); 737 | break; 738 | 739 | case TerrainType.SNOW: 740 | // Snow-capped peaks - white with slight blue tint 741 | const snowBrightness = 0.8 + (terrain.height - 13) * 0.1; 742 | colors.push(snowBrightness, snowBrightness, snowBrightness + 0.1); 743 | break; 744 | 745 | case TerrainType.STONE: 746 | // Stone/rock - grey brown 747 | colors.push(0.5, 0.45, 0.4); 748 | break; 749 | 750 | default: 751 | colors.push(1, 1, 1); // White 752 | break; 753 | } 754 | } else { 755 | // Edge vertices - match heights with closest edge tile 756 | const nearX = Math.min(Math.max(x, 0), this.size.width - 1); 757 | const nearY = Math.min(Math.max(y, 0), this.size.height - 1); 758 | 759 | if (this.terrain[nearY] && this.terrain[nearY][nearX]) { 760 | position.setY(i, this.terrain[nearY][nearX].height); 761 | } 762 | 763 | colors.push(0.8, 0.8, 0.8); 764 | } 765 | } 766 | 767 | // Update position and colors 768 | position.needsUpdate = true; 769 | terrainMesh.geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); 770 | 771 | // Recalculate normals 772 | terrainMesh.geometry.computeVertexNormals(); 773 | } 774 | 775 | addBuilding(building) { 776 | this.buildings.push(building); 777 | 778 | // Mark tiles as occupied 779 | for (let y = building.position.y; y < building.position.y + building.size.height; y++) { 780 | for (let x = building.position.x; x < building.position.x + building.size.width; x++) { 781 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 782 | this.terrain[y][x].building = building; 783 | this.terrain[y][x].buildable = false; 784 | } 785 | } 786 | } 787 | 788 | // Make sure the building has a mesh 789 | if (!building.mesh) { 790 | console.error("Building has no mesh!"); 791 | return; 792 | } 793 | 794 | // Position the building properly on the terrain 795 | const worldPos = this.getWorldPosition(building.position.x, building.position.y); 796 | const terrainHeight = this.terrain[building.position.y][building.position.x].height; 797 | const width = building.size.width * this.tileSize; 798 | const depth = building.size.height * this.tileSize; 799 | 800 | // Set position on the terrain 801 | building.mesh.position.set( 802 | worldPos.x + width / 2, 803 | terrainHeight + 1, // Elevated for visibility 804 | worldPos.z + depth / 2 805 | ); 806 | 807 | console.log(`Building positioned at: (${building.mesh.position.x}, ${building.mesh.position.y}, ${building.mesh.position.z})`); 808 | 809 | // Add visual representation to the scene 810 | this.scene.add(building.mesh); 811 | 812 | // Update fog of war for new building 813 | if (FOG_OF_WAR.ENABLED) { 814 | this._updateFogOfWar(); 815 | } 816 | } 817 | 818 | // Add a construction site 819 | addConstruction(construction) { 820 | this.constructions.push(construction); 821 | 822 | // Mark tiles as occupied 823 | for (let y = construction.position.y; y < construction.position.y + construction.size.height; y++) { 824 | for (let x = construction.position.x; x < construction.position.x + construction.size.width; x++) { 825 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 826 | this.terrain[y][x].building = construction; 827 | this.terrain[y][x].buildable = false; 828 | } 829 | } 830 | } 831 | 832 | // Add visual representation to the scene 833 | this.scene.add(construction.mesh); 834 | 835 | // Update fog of war 836 | if (FOG_OF_WAR.ENABLED) { 837 | this._updateFogOfWar(); 838 | } 839 | } 840 | 841 | // Complete construction and replace with actual building 842 | completeConstruction(construction) { 843 | // Remove construction from the list 844 | this.constructions = this.constructions.filter(c => c !== construction); 845 | 846 | // Create the actual building 847 | let newBuilding; 848 | 849 | switch (construction.targetBuildingType) { 850 | case BuildingType.WAREHOUSE: 851 | newBuilding = new Warehouse(this, construction.position.x, construction.position.y); 852 | break; 853 | case BuildingType.WOODCUTTER: 854 | newBuilding = new Woodcutter(this, construction.position.x, construction.position.y); 855 | break; 856 | // Add cases for other building types as they are implemented 857 | default: 858 | console.error(`Unknown building type: ${construction.targetBuildingType}`); 859 | return; 860 | } 861 | 862 | // Remove construction mesh from scene 863 | this.scene.remove(construction.mesh); 864 | 865 | // Add the new building 866 | this.addBuilding(newBuilding); 867 | } 868 | 869 | // Start building placement mode 870 | startBuildingPlacement(buildingType) { 871 | // Exit placement mode if already active 872 | if (this.buildingPlacementMode) { 873 | this.cancelBuildingPlacement(); 874 | } 875 | 876 | this.buildingPlacementMode = true; 877 | this.buildingTypeToPlace = buildingType; 878 | 879 | // Create preview mesh based on building type 880 | this._createPlacementPreview(); 881 | 882 | console.log(`Started building placement mode for ${buildingType}`); 883 | } 884 | 885 | // Cancel building placement mode 886 | cancelBuildingPlacement() { 887 | if (!this.buildingPlacementMode) return; 888 | 889 | this.buildingPlacementMode = false; 890 | this.buildingTypeToPlace = null; 891 | 892 | // Remove preview mesh 893 | if (this.placementPreviewMesh) { 894 | this.scene.remove(this.placementPreviewMesh); 895 | this.placementPreviewMesh = null; 896 | } 897 | 898 | console.log("Cancelled building placement mode"); 899 | } 900 | 901 | // Create a preview mesh for building placement 902 | _createPlacementPreview() { 903 | // Determine building size 904 | const size = { 905 | width: this.buildingTypeToPlace === BuildingType.WAREHOUSE ? 2 : 1, 906 | height: this.buildingTypeToPlace === BuildingType.WAREHOUSE ? 2 : 1 907 | }; 908 | 909 | const width = size.width * this.tileSize; 910 | const depth = size.height * this.tileSize; 911 | 912 | // Create a group for the preview mesh 913 | this.placementPreviewMesh = new THREE.Group(); 914 | 915 | // Create base outline 916 | const outlineGeometry = new THREE.BoxGeometry(width, 0.1, depth); 917 | const validMaterial = new THREE.MeshBasicMaterial({ 918 | color: 0x00FF00, 919 | transparent: true, 920 | opacity: 0.5 921 | }); 922 | const invalidMaterial = new THREE.MeshBasicMaterial({ 923 | color: 0xFF0000, 924 | transparent: true, 925 | opacity: 0.5 926 | }); 927 | 928 | // Create the outline mesh with valid material initially 929 | this.previewOutline = new THREE.Mesh(outlineGeometry, validMaterial); 930 | this.previewOutline.position.y = 0.05; // Just above ground 931 | this.placementPreviewMesh.add(this.previewOutline); 932 | 933 | // Try to load the building sprite for preview 934 | let spritePath; 935 | switch (this.buildingTypeToPlace) { 936 | case BuildingType.WOODCUTTER: 937 | spritePath = '/assets/sprites/woodcutter.png'; 938 | break; 939 | case BuildingType.WAREHOUSE: 940 | spritePath = '/assets/sprites/warehouse.png'; 941 | break; 942 | default: 943 | // Default to construction sprite 944 | spritePath = '/assets/sprites/construction.png'; 945 | } 946 | 947 | // Load the texture 948 | this.textureLoader.load(spritePath, (texture) => { 949 | // Create sprite material 950 | const spriteMaterial = new THREE.SpriteMaterial({ 951 | map: texture, 952 | transparent: true, 953 | opacity: 0.7, 954 | depthTest: true 955 | }); 956 | 957 | // Create sprite 958 | this.previewSprite = new THREE.Sprite(spriteMaterial); 959 | this.previewSprite.scale.set(width * 1.5, width * 1.5, 1); 960 | this.previewSprite.position.y = width / 2; // Float above the ground 961 | this.placementPreviewMesh.add(this.previewSprite); 962 | }); 963 | 964 | // Add the preview to the scene 965 | this.scene.add(this.placementPreviewMesh); 966 | } 967 | 968 | // Update the placement preview position and validity 969 | _updatePlacementPreview() { 970 | if (!this.placementPreviewMesh) return; 971 | 972 | // Raycast to find mouse position on terrain 973 | this.raycaster.setFromCamera(this.mouse, this.game.camera); 974 | const intersects = this.raycaster.intersectObjects([this.scene.getObjectByName('terrain')]); 975 | 976 | if (intersects.length > 0) { 977 | const intersect = intersects[0]; 978 | 979 | // Get grid position from world position 980 | const gridPos = this.getGridPosition(intersect.point.x, intersect.point.z); 981 | 982 | // Check if placement is valid 983 | this.placementValid = this._isPlacementValid(gridPos.x, gridPos.z); 984 | 985 | // Update outline color based on validity 986 | if (this.previewOutline) { 987 | this.previewOutline.material = this.placementValid ? 988 | new THREE.MeshBasicMaterial({ color: 0x00FF00, transparent: true, opacity: 0.5 }) : 989 | new THREE.MeshBasicMaterial({ color: 0xFF0000, transparent: true, opacity: 0.5 }); 990 | } 991 | 992 | // Determine building size 993 | const size = { 994 | width: this.buildingTypeToPlace === BuildingType.WAREHOUSE ? 2 : 1, 995 | height: this.buildingTypeToPlace === BuildingType.WAREHOUSE ? 2 : 1 996 | }; 997 | 998 | // Get world position for center of building area 999 | const worldPos = this.getWorldPosition(gridPos.x, gridPos.z); 1000 | const width = size.width * this.tileSize; 1001 | const depth = size.height * this.tileSize; 1002 | 1003 | // Determine terrain height at this position 1004 | const terrainHeight = this._getAverageTerrainHeight(gridPos.x, gridPos.z, size.width, size.height); 1005 | 1006 | // Update preview position 1007 | this.placementPreviewMesh.position.set( 1008 | worldPos.x + width / 2, 1009 | terrainHeight + 0.1, // Just above terrain 1010 | worldPos.z + depth / 2 1011 | ); 1012 | 1013 | // Store the grid position for placement 1014 | this.placementGridPosition = { x: gridPos.x, y: gridPos.z }; 1015 | } 1016 | } 1017 | 1018 | // Get average terrain height for an area 1019 | _getAverageTerrainHeight(startX, startY, width, height) { 1020 | let totalHeight = 0; 1021 | let count = 0; 1022 | 1023 | for (let y = startY; y < startY + height; y++) { 1024 | for (let x = startX; x < startX + width; x++) { 1025 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 1026 | totalHeight += this.terrain[y][x].height; 1027 | count++; 1028 | } 1029 | } 1030 | } 1031 | 1032 | return count > 0 ? totalHeight / count : 0; 1033 | } 1034 | 1035 | // Check if a building can be placed at the given position 1036 | _isPlacementValid(x, y) { 1037 | // Determine building size 1038 | const size = { 1039 | width: this.buildingTypeToPlace === BuildingType.WAREHOUSE ? 2 : 1, 1040 | height: this.buildingTypeToPlace === BuildingType.WAREHOUSE ? 2 : 1 1041 | }; 1042 | 1043 | // Check if the entire area is within bounds 1044 | if (x < 0 || y < 0 || x + size.width > this.size.width || y + size.height > this.size.height) { 1045 | return false; 1046 | } 1047 | 1048 | // Check if the entire area is buildable 1049 | for (let gridY = y; gridY < y + size.height; gridY++) { 1050 | for (let gridX = x; gridX < x + size.width; gridX++) { 1051 | const tile = this.terrain[gridY][gridX]; 1052 | 1053 | // Check if tile is grass and not occupied 1054 | if (tile.type !== TerrainType.GRASS || tile.building) { 1055 | return false; 1056 | } 1057 | } 1058 | } 1059 | 1060 | // Check if we can afford to build it 1061 | const costs = buildingCosts[this.buildingTypeToPlace]; 1062 | if (!this.game.resourceManager.hasResources(costs)) { 1063 | return false; 1064 | } 1065 | 1066 | return true; 1067 | } 1068 | 1069 | // Place the building at the current preview position 1070 | _placeBuilding() { 1071 | if (!this.placementValid || !this.placementGridPosition) { 1072 | console.log("Invalid placement"); 1073 | return; 1074 | } 1075 | 1076 | const x = this.placementGridPosition.x; 1077 | const y = this.placementGridPosition.y; 1078 | 1079 | console.log(`Placing ${this.buildingTypeToPlace} at grid position: ${x}, ${y}`); 1080 | 1081 | // Consume resources 1082 | const costs = buildingCosts[this.buildingTypeToPlace]; 1083 | this.game.resourceManager.consumeResources(costs); 1084 | 1085 | // Create a construction site 1086 | const construction = new Construction(this, x, y, this.buildingTypeToPlace); 1087 | this.addConstruction(construction); 1088 | 1089 | // Exit placement mode 1090 | this.cancelBuildingPlacement(); 1091 | } 1092 | 1093 | _onMouseMove(event) { 1094 | // Update mouse position 1095 | this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1; 1096 | this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; 1097 | 1098 | // If in building placement mode, update the preview 1099 | if (this.buildingPlacementMode) { 1100 | this._updatePlacementPreview(); 1101 | } 1102 | } 1103 | 1104 | _onClick(event) { 1105 | // Raycast to find clicked objects 1106 | this.raycaster.setFromCamera(this.mouse, this.game.camera); 1107 | 1108 | const intersects = this.raycaster.intersectObjects(this.scene.children, true); // Use true to check all descendants 1109 | 1110 | if (intersects.length > 0) { 1111 | const intersect = intersects[0]; 1112 | console.log(`Click detected at world position: (${intersect.point.x}, ${intersect.point.z})`); 1113 | 1114 | // Handle building placement if in placement mode 1115 | if (this.buildingPlacementMode) { 1116 | if (this.placementValid) { 1117 | this._placeBuilding(); 1118 | } else { 1119 | console.log("Cannot place building here"); 1120 | } 1121 | return; 1122 | } 1123 | 1124 | // Check if we clicked on terrain or a building 1125 | if (intersect.object.name === 'terrain') { 1126 | // Convert intersection point to grid coordinates using our new method 1127 | const gridPos = this.getGridPosition(intersect.point.x, intersect.point.z); 1128 | const x = gridPos.x; 1129 | const z = gridPos.z; 1130 | 1131 | if (x >= 0 && x < this.size.width && z >= 0 && z < this.size.height) { 1132 | console.log(`Clicked on terrain at grid [${x}, ${z}], type: ${this.terrain[z][x].type}`); 1133 | } 1134 | } else { 1135 | // Find the clicked building - search through parent objects too 1136 | let obj = intersect.object; 1137 | while (obj && !obj.userData.isBuilding) { 1138 | obj = obj.parent; 1139 | } 1140 | 1141 | if (obj && obj.userData.isBuilding) { 1142 | // Check if it's a construction site 1143 | if (obj.userData.isConstruction) { 1144 | const construction = this.constructions.find(c => c.mesh === obj); 1145 | if (construction) { 1146 | console.log(`Clicked on construction: ${construction.targetBuildingType}`); 1147 | this.game.uiManager.showConstructionPanel(construction); 1148 | } 1149 | } else { 1150 | // Regular building 1151 | const building = this.buildings.find(b => b.mesh === obj); 1152 | if (building) { 1153 | console.log(`Clicked on building: ${building.name}`); 1154 | this.game.uiManager.showBuildingPanel(building); 1155 | } 1156 | } 1157 | } 1158 | } 1159 | } 1160 | } 1161 | 1162 | getGridPosition(worldX, worldZ) { 1163 | // Convert world coordinates to grid coordinates 1164 | // Reverse of getWorldPosition calculation 1165 | const x = Math.floor(worldX / this.tileSize + this.size.width / 2); 1166 | const z = Math.floor(worldZ / this.tileSize + this.size.height / 2); 1167 | 1168 | return { x, z }; 1169 | } 1170 | 1171 | getWorldPosition(gridX, gridZ) { 1172 | // Convert grid coordinates to world coordinates (center of the tile) 1173 | // In our new system: 1174 | // - Grid (0,0) corresponds to world coordinates (-width/2, -height/2) 1175 | // - Grid (width/2, height/2) corresponds to world coordinates (0, 0) 1176 | // - Grid (width, height) corresponds to world coordinates (width/2, height/2) 1177 | 1178 | const x = (gridX - this.size.width / 2) * this.tileSize; 1179 | const z = (gridZ - this.size.height / 2) * this.tileSize; 1180 | 1181 | return { x, z }; 1182 | } 1183 | 1184 | // Add terrain sprites (grass, forest, etc.) 1185 | _addGrassSprites() { 1186 | // Check if we have loaded the new grass textures, try all possible fallbacks 1187 | const grassTextures = []; 1188 | for (let i = 1; i <= 10; i++) { 1189 | const grassTexture = this.textures[`grass${i}`]; 1190 | if (grassTexture) { 1191 | grassTextures.push(grassTexture); 1192 | } 1193 | } 1194 | 1195 | // Get all available tree textures 1196 | const treeTextures = []; 1197 | for (let i = 1; i <= 10; i++) { 1198 | const treeTexture = this.textures[`tree${i}`]; 1199 | if (treeTexture) { 1200 | treeTextures.push(treeTexture); 1201 | } 1202 | } 1203 | 1204 | console.log("Adding terrain sprites..."); 1205 | console.log(`Loaded ${treeTextures.length} tree textures for forests`); 1206 | 1207 | // Create materials for different terrain types 1208 | const materials = {}; 1209 | const grassMaterials = []; 1210 | const treeMaterials = []; 1211 | 1212 | // Add all available grass materials to an array for variety 1213 | grassTextures.forEach((texture, index) => { 1214 | grassMaterials.push(new THREE.SpriteMaterial({ 1215 | map: texture, 1216 | transparent: true, 1217 | alphaTest: 0.5, 1218 | depthTest: true, 1219 | sizeAttenuation: true, 1220 | lights: false, // Disable lighting 1221 | color: 0xffffff // Full brightness white to use texture colors exactly 1222 | })); 1223 | console.log("Created grass material", index + 1); 1224 | }); 1225 | 1226 | // Create tree materials from available textures 1227 | treeTextures.forEach((texture, index) => { 1228 | treeMaterials.push(new THREE.SpriteMaterial({ 1229 | map: texture, 1230 | transparent: true, 1231 | alphaTest: 0.5, 1232 | depthTest: true, 1233 | sizeAttenuation: true, 1234 | lights: false, // Disable lighting 1235 | color: 0xffffff // Full brightness white to use texture colors exactly 1236 | })); 1237 | console.log(`Created tree material ${index + 1}`); 1238 | }); 1239 | 1240 | // Fallback to legacy forest textures if no tree textures are available 1241 | if (treeMaterials.length === 0) { 1242 | if (forestTexture) { 1243 | treeMaterials.push(new THREE.SpriteMaterial({ 1244 | map: forestTexture, 1245 | transparent: true, 1246 | alphaTest: 0.5, 1247 | depthTest: true, 1248 | sizeAttenuation: true, 1249 | lights: false, // Disable lighting 1250 | color: 0xffffff // Full brightness white to use texture colors exactly 1251 | })); 1252 | console.log("Created legacy forest material"); 1253 | } else if (forest1Texture) { 1254 | treeMaterials.push(new THREE.SpriteMaterial({ 1255 | map: forest1Texture, 1256 | transparent: true, 1257 | alphaTest: 0.5, 1258 | depthTest: true, 1259 | sizeAttenuation: true, 1260 | lights: false, // Disable lighting 1261 | color: 0xffffff // Full brightness white to use texture colors exactly 1262 | })); 1263 | console.log("Created alternate legacy forest material"); 1264 | } 1265 | } 1266 | 1267 | // Store grass materials for use in the terrain generation 1268 | if (grassMaterials.length > 0) { 1269 | materials[TerrainType.GRASS] = grassMaterials; 1270 | // Also use grass textures for forest ground 1271 | materials[TerrainType.FOREST] = grassMaterials; 1272 | } 1273 | 1274 | // Store tree materials separately 1275 | if (treeMaterials.length > 0) { 1276 | materials['TREES'] = treeMaterials; 1277 | } 1278 | 1279 | // Group for all terrain sprites 1280 | this.spriteGroup = new THREE.Group(); 1281 | this.spriteGroup.name = "terrainSprites"; 1282 | 1283 | // Stats for logging 1284 | const stats = { 1285 | grass: 0, 1286 | forest: 0, 1287 | trees: 0, 1288 | total: 0 1289 | }; 1290 | 1291 | // Create sprites for terrain tiles 1292 | // Focus on a reasonable area around the center for performance 1293 | const centerX = Math.floor(this.size.width / 2); 1294 | const centerY = Math.floor(this.size.height / 2); 1295 | const radius = 25; // Only add sprites in a 50x50 area around center 1296 | 1297 | for (let y = centerY - radius; y < centerY + radius; y++) { 1298 | for (let x = centerX - radius; x < centerX + radius; x++) { 1299 | // Check if coordinates are valid 1300 | if (y >= 0 && y < this.size.height && x >= 0 && x < this.size.width) { 1301 | const terrainType = this.terrain[y][x].type; 1302 | 1303 | // Only add sprites if we have a material for this terrain type 1304 | if (materials[terrainType]) { 1305 | // Get world position for this tile 1306 | const worldPos = this.getWorldPosition(x, y); 1307 | const terrainHeight = this.terrain[y][x].height; 1308 | 1309 | // Handle the different terrain types 1310 | if ((terrainType === TerrainType.GRASS || terrainType === TerrainType.FOREST) && 1311 | Array.isArray(materials[terrainType])) { 1312 | 1313 | // Add grass sprites (for both grass and forest terrains) 1314 | // Randomly select a grass material from the array for each sprite 1315 | const randomIndex = Math.floor(Math.random() * materials[terrainType].length); 1316 | const sprite = new THREE.Sprite(materials[terrainType][randomIndex]); 1317 | 1318 | sprite.scale.set(this.tileSize, this.tileSize, 1); 1319 | 1320 | // Create grass as a horizontal plane instead of a sprite 1321 | // Create a small plane geometry 1322 | const planeGeometry = new THREE.PlaneGeometry( 1323 | this.tileSize, 1324 | this.tileSize, 1325 | ); 1326 | 1327 | // Create plane material with the grass texture 1328 | const planeMaterial = new THREE.MeshBasicMaterial({ 1329 | map: materials[terrainType][randomIndex].map, 1330 | transparent: true, 1331 | alphaTest: 0.5, 1332 | side: THREE.DoubleSide 1333 | }); 1334 | 1335 | // Create mesh with the plane geometry and material 1336 | const grassPlane = new THREE.Mesh(planeGeometry, planeMaterial); 1337 | 1338 | // Rotate the plane to be flat on the ground (rotated around X-axis) 1339 | grassPlane.rotation.x = -Math.PI / 2; 1340 | // rotate 90, 180, 270 degrees randomly 1341 | grassPlane.rotation.z = Math.random() > 0.75 ? Math.PI / 2 : 1342 | Math.random() > 0.5 ? Math.PI : Math.random() > 0.25 ? -Math.PI / 2 : 0; 1343 | 1344 | // Position the grass plane 1345 | grassPlane.position.set( 1346 | worldPos.x, 1347 | terrainHeight, 1348 | worldPos.z, 1349 | ); 1350 | 1351 | // Add to the sprite group 1352 | this.spriteGroup.add(grassPlane); 1353 | stats.grass++; 1354 | stats.total++; 1355 | 1356 | // Add trees only for forest terrain 1357 | if (terrainType === TerrainType.FOREST && materials['TREES']) { 1358 | // Add 1-2 trees per forest tile 1359 | const treeCount = 1; // + Math.floor(Math.random() * 0.99); // 1-2 trees 1360 | 1361 | for (let i = 0; i < treeCount; i++) { 1362 | // Randomly select a tree material 1363 | const randomTreeIndex = Math.floor(Math.random() * materials['TREES'].length); 1364 | const treeSprite = new THREE.Sprite(materials['TREES'][randomTreeIndex]); 1365 | 1366 | // Trees should be moderately sized but tall enough to be visible 1367 | const treeScale = 0.7 + Math.random() * 0.3; // Slight size variation (0.7-1.0) 1368 | const treeSize = this.tileSize * 2.0 * treeScale; // Slightly larger to compensate for height 1369 | treeSprite.scale.set(treeSize, treeSize, 1); 1370 | 1371 | // Random rotation for trees 1372 | // treeSprite.material.rotation = Math.random() * Math.PI * 0.2; // Slight rotation 1373 | 1374 | // Position the tree with offset within the tile 1375 | const treeOffsetX = (Math.random() - 0.5) * this.tileSize * 0.7; 1376 | const treeOffsetZ = (Math.random() - 0.5) * this.tileSize * 0.7; 1377 | const treeHeightOffset = 1.5; // Higher offset to show trees above grass 1378 | 1379 | treeSprite.position.set( 1380 | worldPos.x + treeOffsetX, 1381 | terrainHeight + treeHeightOffset, 1382 | worldPos.z + treeOffsetZ 1383 | ); 1384 | 1385 | // Add to the sprite group 1386 | this.spriteGroup.add(treeSprite); 1387 | stats.trees++; 1388 | stats.total++; 1389 | } 1390 | } 1391 | } else if (materials[terrainType]) { 1392 | // For other terrain types, create planes aligned with terrain 1393 | const planeSize = this.tileSize * 1.5; 1394 | const heightOffset = 0.1; 1395 | 1396 | // Create plane geometry 1397 | const planeGeometry = new THREE.PlaneGeometry(planeSize, planeSize); 1398 | 1399 | // Create material with the texture 1400 | const planeMaterial = new THREE.MeshBasicMaterial({ 1401 | map: materials[terrainType].map, 1402 | transparent: true, 1403 | alphaTest: 0.5, 1404 | side: THREE.DoubleSide 1405 | }); 1406 | 1407 | // Create the mesh 1408 | const plane = new THREE.Mesh(planeGeometry, planeMaterial); 1409 | 1410 | // Rotate to lie flat on the ground 1411 | plane.rotation.x = -Math.PI / 2; 1412 | 1413 | // Add some random rotation for natural look 1414 | plane.rotation.z = Math.random() * Math.PI * 2; 1415 | 1416 | // Position the plane on the terrain 1417 | plane.position.set( 1418 | worldPos.x, 1419 | terrainHeight + heightOffset, 1420 | worldPos.z 1421 | ); 1422 | 1423 | // Add to the sprite group 1424 | this.spriteGroup.add(plane); 1425 | stats.total++; 1426 | } 1427 | } 1428 | } 1429 | } 1430 | } 1431 | 1432 | // Add all sprites to the scene 1433 | this.scene.add(this.spriteGroup); 1434 | console.log(`Added ${stats.total} terrain sprites (${stats.grass} grass, ${stats.trees} trees, ${stats.forest} forest patches)`); 1435 | } 1436 | 1437 | // Add initial settlers 1438 | _addStartingSettlers(warehouseX, warehouseY) { 1439 | // Add 3 porters around the warehouse 1440 | for (let i = 0; i < 3; i++) { 1441 | const offsetX = Math.floor(Math.random() * 3) - 1; 1442 | const offsetY = Math.floor(Math.random() * 3) - 1; 1443 | 1444 | const porterX = warehouseX + offsetX; 1445 | const porterY = warehouseY + offsetY; 1446 | 1447 | // Make sure the position is valid 1448 | if (porterX >= 0 && porterX < this.size.width && 1449 | porterY >= 0 && porterY < this.size.height) { 1450 | this._addSettler(new Porter(this, porterX, porterY)); 1451 | } 1452 | } 1453 | 1454 | // Add 2 builders around the warehouse 1455 | for (let i = 0; i < 2; i++) { 1456 | const offsetX = Math.floor(Math.random() * 3) - 1; 1457 | const offsetY = Math.floor(Math.random() * 3) - 1; 1458 | 1459 | const builderX = warehouseX + offsetX; 1460 | const builderY = warehouseY + offsetY; 1461 | 1462 | // Make sure the position is valid 1463 | if (builderX >= 0 && builderX < this.size.width && 1464 | builderY >= 0 && builderY < this.size.height) { 1465 | this._addSettler(new Builder(this, builderX, builderY)); 1466 | } 1467 | } 1468 | } 1469 | 1470 | // Add a new settler 1471 | _addSettler(settler) { 1472 | this.settlers.push(settler); 1473 | 1474 | // Initialize and add to scene 1475 | settler.init(); 1476 | 1477 | if (settler.mesh) { 1478 | this.scene.add(settler.mesh); 1479 | 1480 | // Make sure the settler is properly initialized 1481 | const worldPos = this.getWorldPosition(settler.position.x, settler.position.y); 1482 | const terrainHeight = this.terrain[settler.position.y][settler.position.x].height; 1483 | settler.mesh.position.set(worldPos.x, terrainHeight + 0.1, worldPos.z); 1484 | 1485 | // Create a marker to help locate the settler 1486 | const markerGeometry = new THREE.SphereGeometry(0.2, 8, 8); 1487 | const markerMaterial = new THREE.MeshBasicMaterial({ color: 0xFFFF00 }); 1488 | const marker = new THREE.Mesh(markerGeometry, markerMaterial); 1489 | marker.position.y = 1.5; // Above the settler 1490 | settler.mesh.add(marker); 1491 | 1492 | // Make the marker blink to make it extra visible 1493 | const blinkInterval = setInterval(() => { 1494 | marker.visible = !marker.visible; 1495 | }, 500); 1496 | 1497 | // Remove the marker after 10 seconds 1498 | setTimeout(() => { 1499 | clearInterval(blinkInterval); 1500 | settler.mesh.remove(marker); 1501 | }, 10000); 1502 | } 1503 | 1504 | console.log(`Added ${settler.type} at (${settler.position.x}, ${settler.position.y})`); 1505 | } 1506 | 1507 | // Position the camera to focus on the starting area 1508 | _centerCameraOnStartingArea(centerX, centerY) { 1509 | // Get the world position of the starting tile 1510 | const worldPos = this.getWorldPosition(centerX, centerY); 1511 | 1512 | // Calculate the offset from the center 1513 | const offsetX = worldPos.x; 1514 | const offsetZ = worldPos.z; 1515 | 1516 | // Move the camera's target to this position 1517 | // We need to adjust the orbit controls target point 1518 | const controls = this.game.controls; 1519 | 1520 | // Set the target to the world position of our starting area 1521 | controls.target.set(offsetX, 0, offsetZ); 1522 | 1523 | // Update controls to apply the new target 1524 | controls.update(); 1525 | 1526 | console.log(`Camera centered on starting area at world position: ${offsetX}, ${offsetZ}`); 1527 | console.log(`Center coordinates: Grid (${centerX}, ${centerY}), World (${offsetX}, ${offsetZ})`); 1528 | } 1529 | } --------------------------------------------------------------------------------