diff --git a/build.js b/build.js index 1ad5fbc..1d6dbc3 100644 --- a/build.js +++ b/build.js @@ -1,11 +1,38 @@ import { build } from "esbuild"; -import { copyFileSync, mkdirSync } from "fs"; -import { dirname } from "path"; +import { copyFileSync, mkdirSync, readdirSync } from "fs"; +import { dirname, join } from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// Image file extensions to copy +const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]; + +// Recursively copy image files from src to dist +function copyImages(srcDir, distDir) { + const entries = readdirSync(srcDir, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = join(srcDir, entry.name); + const distPath = join(distDir, entry.name); + + if (entry.isDirectory()) { + mkdirSync(distPath, { recursive: true }); + copyImages(srcPath, distPath); + } else if (entry.isFile()) { + const lastDot = entry.name.lastIndexOf("."); + if (lastDot !== -1) { + const ext = entry.name.toLowerCase().substring(lastDot); + if (IMAGE_EXTENSIONS.includes(ext)) { + mkdirSync(distDir, { recursive: true }); + copyFileSync(srcPath, distPath); + } + } + } + } +} + // Ensure dist directory exists mkdirSync("dist", { recursive: true }); @@ -23,4 +50,7 @@ await build({ // Copy HTML file copyFileSync("src/index.html", "dist/index.html"); +// Copy images +copyImages("src", "dist"); + console.log("Build complete!"); diff --git a/src/assets/images/portraits/custodian.png b/src/assets/images/portraits/custodian.png new file mode 100644 index 0000000..9875122 Binary files /dev/null and b/src/assets/images/portraits/custodian.png differ diff --git a/src/assets/images/portraits/tinker.png b/src/assets/images/portraits/tinker.png new file mode 100644 index 0000000..62ec1ba Binary files /dev/null and b/src/assets/images/portraits/tinker.png differ diff --git a/src/assets/images/portraits/vanguard.png b/src/assets/images/portraits/vanguard.png new file mode 100644 index 0000000..8eb554b Binary files /dev/null and b/src/assets/images/portraits/vanguard.png differ diff --git a/src/assets/images/portraits/weaver.png b/src/assets/images/portraits/weaver.png new file mode 100644 index 0000000..f371a1c Binary files /dev/null and b/src/assets/images/portraits/weaver.png differ diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js new file mode 100644 index 0000000..22e6bc0 --- /dev/null +++ b/src/core/GameLoop.js @@ -0,0 +1,111 @@ +import * as THREE from "three"; +import { VoxelGrid } from "../grid/VoxelGrid.js"; +import { VoxelManager } from "../grid/VoxelManager.js"; +// import { UnitManager } from "../managers/UnitManager.js"; +import { CaveGenerator } from "../generation/CaveGenerator.js"; +import { RuinGenerator } from "../generation/RuinGenerator.js"; +// import { TurnSystem } from '../systems/TurnSystem.js'; + +export class GameLoop { + constructor() { + this.isRunning = false; + + // 1. Core Systems + this.scene = new THREE.Scene(); + this.camera = null; + this.renderer = null; + + this.grid = null; + this.voxelManager = null; + this.unitManager = null; + + // 2. State + this.runData = null; + } + + init(container) { + // Setup Three.js + this.camera = new THREE.PerspectiveCamera( + 45, + window.innerWidth / window.innerHeight, + 0.1, + 1000 + ); + this.camera.position.set(20, 20, 20); + this.camera.lookAt(0, 0, 0); + + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer.setSize(window.innerWidth, window.innerHeight); + this.renderer.setClearColor(0x111111); // Dark background + container.appendChild(this.renderer.domElement); + + // Lighting + const ambient = new THREE.AmbientLight(0xffffff, 0.6); + const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); + dirLight.position.set(10, 20, 10); + this.scene.add(ambient); + this.scene.add(dirLight); + + // Handle Resize + window.addEventListener("resize", () => { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(window.innerWidth, window.innerHeight); + }); + + this.animate = this.animate.bind(this); + } + + /** + * Starts a Level based on Run Data (New or Loaded). + */ + async startLevel(runData) { + console.log("GameLoop: Starting Level..."); + this.runData = runData; + this.isRunning = true; + + // 1. Initialize Grid (20x10x20 for prototype) + this.grid = new VoxelGrid(20, 10, 20); + + // 2. Generate World (Using saved seed) + // TODO: Switch generator based on runData.biome_id + const generator = new RuinGenerator(this.grid, runData.seed); + generator.generate(); + + // 3. Initialize Visuals + this.voxelManager = new VoxelManager(this.grid, this.scene); + + // Apply textures generated by the biome logic + this.voxelManager.updateMaterials(generator.generatedAssets); + this.voxelManager.update(); + this.voxelManager.focusCamera(this.controls); + + // 4. Initialize Units + // this.unitManager = new UnitManager(); + // this.spawnSquad(runData.squad); + + // Start Loop + this.animate(); + } + + spawnSquad(squadManifest) { + // TODO: Loop manifest and unitManager.createUnit() + // Place them at spawn points defined by Generator + } + + animate() { + if (!this.isRunning) return; + requestAnimationFrame(this.animate); + + // Update Logic + // TWEEN.update(); + + // Render + this.renderer.render(this.scene, this.camera); + } + + stop() { + this.isRunning = false; + // Cleanup Three.js resources if needed + } +} diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js new file mode 100644 index 0000000..3affd89 --- /dev/null +++ b/src/core/GameStateManager.js @@ -0,0 +1,119 @@ +import { Persistence } from "./Persistence.js"; + +class GameStateManagerClass { + static STATES = { + INIT: "STATE_INIT", + MAIN_MENU: "STATE_MAIN_MENU", + TEAM_BUILDER: "STATE_TEAM_BUILDER", + GAME_RUN: "STATE_GAME_RUN", + }; + + constructor() { + this.currentState = GameStateManagerClass.STATES.INIT; + this.gameLoop = null; + this.persistence = new Persistence(); + this.activeRunData = null; + this.gameLoopSet = Promise.withResolvers(); + + this.handleEmbark = this.handleEmbark.bind(this); + } + + /** + * For Testing: Resets the manager to a clean state. + */ + reset() { + this.currentState = GameStateManagerClass.STATES.INIT; + this.gameLoop = null; + this.activeRunData = null; + } + + setGameLoop(loop) { + this.gameLoop = loop; + this.gameLoopSet.resolve(loop); + } + + async init() { + console.log("System: Initializing State Manager..."); + await this.persistence.init(); + this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU); + } + + async transitionTo(newState, payload = null) { + console.log(`State Transition: ${this.currentState} -> ${newState}`); + const oldState = this.currentState; + this.currentState = newState; + + window.dispatchEvent( + new CustomEvent("gamestate-changed", { + detail: { oldState, newState, payload }, + }) + ); + + switch (newState) { + case GameStateManagerClass.STATES.MAIN_MENU: + if (this.gameLoop) this.gameLoop.stop(); + await this._checkSaveGame(); + break; + + case GameStateManagerClass.STATES.GAME_RUN: + if (!this.activeRunData && payload) { + await this._initializeRun(payload); + } else { + await this._resumeRun(); + } + break; + } + } + + startNewGame() { + this.transitionTo(GameStateManagerClass.STATES.TEAM_BUILDER); + } + + async continueGame() { + const save = await this.persistence.loadRun(); + if (save) { + this.activeRunData = save; + this.transitionTo(GameStateManagerClass.STATES.GAME_RUN); + } + } + + handleEmbark(e) { + this.transitionTo(GameStateManagerClass.STATES.GAME_RUN, e.detail.squad); + } + + // --- INTERNAL HELPERS --- + + async _initializeRun(squadManifest) { + await this.gameLoopSet.promise; + + this.activeRunData = { + seed: Math.floor(Math.random() * 999999), + depth: 1, + squad: squadManifest, + world_state: {}, + }; + + await this.persistence.saveRun(this.activeRunData); + this.gameLoop.startLevel(this.activeRunData); + } + + async _resumeRun() { + await this.gameLoopSet.promise; + if (this.activeRunData) { + this.gameLoop.startLevel(this.activeRunData); + } + } + + async _checkSaveGame() { + const save = await this.persistence.loadRun(); + window.dispatchEvent( + new CustomEvent("save-check-complete", { detail: { hasSave: !!save } }) + ); + } +} + +// Export the Singleton Instance +export const gameStateManager = new GameStateManagerClass(); + +// Export Class ref for constants/testing +export const GameStateManager = GameStateManagerClass; diff --git a/src/core/Persistence.js b/src/core/Persistence.js new file mode 100644 index 0000000..0fcc6a8 --- /dev/null +++ b/src/core/Persistence.js @@ -0,0 +1,71 @@ +/** + * Persistence.js + * Handles asynchronous saving and loading using IndexedDB. + */ +const DB_NAME = "AetherShardsDB"; +const STORE_NAME = "Runs"; +const VERSION = 1; + +export class Persistence { + constructor() { + this.db = null; + } + + async init() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, VERSION); + + request.onerror = (e) => reject("DB Error: " + e.target.error); + + request.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "id" }); + } + }; + + request.onsuccess = (e) => { + this.db = e.target.result; + resolve(); + }; + }); + } + + async saveRun(runData) { + if (!this.db) await this.init(); + return new Promise((resolve, reject) => { + const tx = this.db.transaction([STORE_NAME], "readwrite"); + const store = tx.objectStore(STORE_NAME); + // Always use ID 'active_run' for the single active session + runData.id = "active_run"; + const req = store.put(runData); + + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } + + async loadRun() { + if (!this.db) await this.init(); + return new Promise((resolve, reject) => { + const tx = this.db.transaction([STORE_NAME], "readonly"); + const store = tx.objectStore(STORE_NAME); + const req = store.get("active_run"); + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } + + async clearRun() { + if (!this.db) await this.init(); + return new Promise((resolve, reject) => { + const tx = this.db.transaction([STORE_NAME], "readwrite"); + const store = tx.objectStore(STORE_NAME); + const req = store.delete("active_run"); + + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } +} diff --git a/src/generation/CaveGenerator.js b/src/generation/CaveGenerator.js index 8d580f6..bf67bee 100644 --- a/src/generation/CaveGenerator.js +++ b/src/generation/CaveGenerator.js @@ -95,6 +95,11 @@ export class CaveGenerator extends BaseGenerator { // 3. Apply Texture/Material Logic // This replaces the placeholder IDs with our specific texture IDs (100-109, 200-209) this.applyTextures(); + + // 4. Scatter Cover (Post-Texturing) + // ID 10 = Destructible Cover (Mushrooms/Rocks) + // 5% Density for open movement + this.scatterCover(10, 0.05); } smooth() { diff --git a/src/generation/RuinGenerator.js b/src/generation/RuinGenerator.js index 05d3242..bf3af65 100644 --- a/src/generation/RuinGenerator.js +++ b/src/generation/RuinGenerator.js @@ -86,6 +86,11 @@ export class RuinGenerator extends BaseGenerator { // 3. Apply Texture/Material Logic this.applyTextures(); + + // 4. Scatter Cover (Post-Texturing) + // ID 10 = Scrap/Crates + // 10% Density for Ruins (More cover than caves) + this.scatterCover(10, 0.1); } roomsOverlap(room, rooms) { diff --git a/src/index.html b/src/index.html index dd70910..acc246b 100644 --- a/src/index.html +++ b/src/index.html @@ -98,7 +98,7 @@ } /* Hidden state for transitions */ - .hidden { + [hidden] { display: none !important; opacity: 0; pointer-events: none; @@ -320,7 +320,7 @@
-