├── 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 | }
--------------------------------------------------------------------------------