Refactor build process to use index.js as entry point, enabling module splitting and outputting to dist directory. Introduce new game logic in index.js, including team builder and game viewport components. Add JSON class definitions for new character classes and implement item and skill tree systems. Enhance unit tests for item and explorer functionalities.

This commit is contained in:
Matthew Mone 2025-12-19 08:38:22 -08:00
parent 921a93d989
commit 391abd6ea6
24 changed files with 1451 additions and 60 deletions

View file

@ -11,10 +11,11 @@ mkdirSync("dist", { recursive: true });
// Build JavaScript // Build JavaScript
await build({ await build({
entryPoints: ["src/game-viewport.js"], entryPoints: ["src/index.js"],
bundle: true, bundle: true,
splitting: true,
format: "esm", format: "esm",
outfile: "dist/game-viewport.js", outdir: "dist",
sourcemap: true, sourcemap: true,
platform: "browser", platform: "browser",
}); });

View file

@ -0,0 +1,38 @@
{
"id": "CLASS_AETHER_SENTINEL",
"name": "Aether Sentinel",
"tier": 2,
"unlock_requirements": { "CLASS_CUSTODIAN": 5, "CLASS_VANGUARD": 3 },
"base_stats": {
"health": 130,
"attack": 8,
"defense": 9,
"magic": 6,
"speed": 5,
"willpower": 14,
"movement": 3
},
"growth_rates": {
"health": 11,
"defense": 1,
"willpower": 2
},
"starting_equipment": ["ITEM_GREATSHIELD", "ITEM_SANCTIFIED_MACE"],
"skillTreeData": {
"primary_stat": "willpower",
"secondary_stat": "defense",
"active_skills": [
"SKILL_GUARDIAN_LINK",
"SKILL_HOLY_NOVA",
"SKILL_VOW_SILENCE",
"SKILL_AETHER_CLEANSE",
"SKILL_DIVINE_INTERVENTION"
],
"passive_skills": [
"PASSIVE_THORN_HEAL",
"PASSIVE_MARTYRDOM",
"PASSIVE_SACRED_GROUND",
"PASSIVE_UNSHAKEABLE"
]
}
}

View file

@ -0,0 +1,38 @@
{
"id": "CLASS_WEAVER",
"name": "Aether Weaver",
"tier": 1,
"unlock_requirements": null,
"base_stats": {
"health": 80,
"attack": 5,
"defense": 4,
"magic": 12,
"speed": 10,
"willpower": 8,
"movement": 3
},
"growth_rates": {
"health": 6,
"magic": 2,
"speed": 1
},
"starting_equipment": ["ITEM_APPRENTICE_WAND", "ITEM_ROBES"],
"skillTreeData": {
"primary_stat": "magic",
"secondary_stat": "speed",
"active_skills": [
"SKILL_FIREBALL",
"SKILL_ICE_WALL",
"SKILL_TELEPORT",
"SKILL_CHAIN_LIGHTNING",
"SKILL_METEOR"
],
"passive_skills": [
"PASSIVE_GLASS_CANNON",
"PASSIVE_MANA_SYPHON",
"PASSIVE_ELEMENTAL_AFFINITY",
"PASSIVE_DOUBLE_CAST"
]
}
}

View file

@ -0,0 +1,38 @@
{
"id": "CLASS_ARCANE_SCOURGE",
"name": "Arcane Scourge",
"tier": 2,
"unlock_requirements": { "CLASS_WEAVER": 5, "CLASS_SCAVENGER": 3 },
"base_stats": {
"health": 70,
"attack": 6,
"defense": 3,
"magic": 15,
"speed": 12,
"willpower": 6,
"movement": 4
},
"growth_rates": {
"health": 5,
"magic": 3,
"speed": 1
},
"starting_equipment": ["ITEM_VOID_STAFF", "ITEM_TATTERED_ROBES"],
"skillTreeData": {
"primary_stat": "magic",
"secondary_stat": "speed",
"active_skills": [
"SKILL_LIFE_TAP",
"SKILL_VOID_RAY",
"SKILL_SHARD_BLAST",
"SKILL_CHAOS_BOLT",
"SKILL_VOID_SINGULARITY"
],
"passive_skills": [
"PASSIVE_RISK_TAKER",
"PASSIVE_SHARD_MAGNET",
"PASSIVE_OVERCHARGE",
"PASSIVE_DESPERATE_POWER"
]
}
}

View file

@ -0,0 +1,39 @@
{
"id": "CLASS_BATTLE_MAGE",
"name": "Battle Mage",
"tier": 2,
"unlock_requirements": { "CLASS_VANGUARD": 5, "CLASS_WEAVER": 3 },
"base_stats": {
"health": 110,
"attack": 10,
"defense": 7,
"magic": 10,
"speed": 9,
"willpower": 7,
"movement": 3
},
"growth_rates": {
"health": 9,
"attack": 1,
"magic": 1,
"defense": 1
},
"starting_equipment": ["ITEM_RUNE_BLADE", "ITEM_SPELL_SHIELD"],
"skillTreeData": {
"primary_stat": "attack",
"secondary_stat": "magic",
"active_skills": [
"SKILL_FLAME_STRIKE",
"SKILL_AEGIS",
"SKILL_WARP_STRIKE",
"SKILL_ARCANE_SLASH",
"SKILL_RUNE_STORM"
],
"passive_skills": [
"PASSIVE_BATTLE_RHYTHM",
"PASSIVE_ARCANE_ARMOR",
"PASSIVE_SPELL_PARRY",
"PASSIVE_CONJURED_WEAPON"
]
}
}

View file

@ -0,0 +1,38 @@
{
"id": "CLASS_CUSTODIAN",
"name": "Custodian",
"tier": 1,
"unlock_requirements": null,
"base_stats": {
"health": 95,
"attack": 6,
"defense": 5,
"magic": 8,
"speed": 6,
"willpower": 12,
"movement": 3
},
"growth_rates": {
"health": 8,
"willpower": 2,
"magic": 1
},
"starting_equipment": ["ITEM_STAFF", "ITEM_LEAF_ROBES"],
"skillTreeData": {
"primary_stat": "willpower",
"secondary_stat": "health",
"active_skills": [
"SKILL_MEND",
"SKILL_PURIFY",
"SKILL_SANCTUARY",
"SKILL_HASTE",
"SKILL_RESURRECT"
],
"passive_skills": [
"PASSIVE_AURA_OF_PEACE",
"PASSIVE_FEEDBACK_LOOP",
"PASSIVE_OVERHEAL",
"PASSIVE_PACIFIST"
]
}
}

View file

@ -0,0 +1,43 @@
{
"id": "CLASS_FIELD_ENGINEER",
"name": "Field Engineer",
"tier": 2,
"unlock_requirements": { "CLASS_TINKER": 5, "CLASS_SCAVENGER": 3 },
"base_stats": {
"health": 105,
"attack": 9,
"defense": 6,
"magic": 0,
"speed": 10,
"willpower": 6,
"movement": 4,
"tech": 12
},
"growth_rates": {
"health": 8,
"speed": 1,
"tech": 2
},
"starting_equipment": [
"ITEM_AUTO_RIFLE",
"ITEM_REINFORCED_VEST",
"ITEM_REPAIR_DRONE"
],
"skillTreeData": {
"primary_stat": "tech",
"secondary_stat": "speed",
"active_skills": [
"SKILL_SCRAP_CANNON",
"SKILL_PORTABLE_COVER",
"SKILL_REMOTE_OP",
"SKILL_EMP_BLAST",
"SKILL_ORBITAL_DROP"
],
"passive_skills": [
"PASSIVE_COMBAT_SALVAGE",
"PASSIVE_FIELD_REFIT",
"PASSIVE_FAST_HANDS",
"PASSIVE_IMPROVISED_EXPLOSIVES"
]
}
}

View file

@ -0,0 +1,39 @@
{
"id": "CLASS_SAPPER",
"name": "Sapper",
"tier": 2,
"unlock_requirements": { "CLASS_VANGUARD": 5, "CLASS_TINKER": 3 },
"base_stats": {
"health": 115,
"attack": 13,
"defense": 7,
"magic": 0,
"speed": 9,
"willpower": 5,
"movement": 4,
"tech": 8
},
"growth_rates": {
"health": 9,
"attack": 2,
"speed": 1
},
"starting_equipment": ["ITEM_EXPLOSIVE_HAMMER", "ITEM_BLAST_ARMOR"],
"skillTreeData": {
"primary_stat": "attack",
"secondary_stat": "tech",
"active_skills": [
"SKILL_BREACH_CHARGE",
"SKILL_ROCKET_JUMP",
"SKILL_TUNNEL_VISION",
"SKILL_CLUSTER_BOMB",
"SKILL_BIG_RED_BUTTON"
],
"passive_skills": [
"PASSIVE_DEMOLITIONIST",
"PASSIVE_ANTI_COVER",
"PASSIVE_BLAST_SHIELD",
"PASSIVE_RECHARGE_BOMB"
]
}
}

View file

@ -0,0 +1,42 @@
{
"id": "CLASS_SCAVENGER",
"name": "Scavenger",
"tier": 1,
"unlock_requirements": null,
"base_stats": {
"health": 90,
"attack": 8,
"defense": 5,
"magic": 2,
"speed": 12,
"willpower": 4,
"movement": 5
},
"growth_rates": {
"health": 7,
"speed": 2,
"attack": 1
},
"starting_equipment": [
"ITEM_DAGGER",
"ITEM_PADDED_VEST",
"ITEM_LOCKPICK_SET"
],
"skillTreeData": {
"primary_stat": "speed",
"secondary_stat": "movement",
"active_skills": [
"SKILL_FLASHBANG",
"SKILL_GRAPPLE_HOOK",
"SKILL_STEALTH",
"SKILL_COIN_TOSS",
"SKILL_ASSASSINATE"
],
"passive_skills": [
"PASSIVE_LUCKY_FIND",
"PASSIVE_BACKSTAB",
"PASSIVE_LIGHT_STEP",
"PASSIVE_HAGGLER"
]
}
}

View file

@ -0,0 +1,43 @@
{
"id": "CLASS_TINKER",
"name": "Tinker",
"tier": 1,
"unlock_requirements": null,
"base_stats": {
"health": 100,
"attack": 10,
"defense": 6,
"magic": 0,
"speed": 7,
"willpower": 5,
"movement": 3,
"tech": 10
},
"growth_rates": {
"health": 8,
"attack": 1,
"tech": 2
},
"starting_equipment": [
"ITEM_WRENCH",
"ITEM_LEATHER_APRON",
"ITEM_TURRET_KIT"
],
"skillTreeData": {
"primary_stat": "tech",
"secondary_stat": "defense",
"active_skills": [
"SKILL_DEPLOY_TURRET",
"SKILL_REPAIR_BOT",
"SKILL_OVERCLOCK",
"SKILL_SHOCK_GRENADE",
"SKILL_MECH_SUIT"
],
"passive_skills": [
"PASSIVE_SCRAP_SHIELD",
"PASSIVE_EFFICIENT_BUILD",
"PASSIVE_RECYCLE",
"PASSIVE_CONDUCTIVE"
]
}
}

View file

@ -0,0 +1,38 @@
{
"id": "CLASS_VANGUARD",
"name": "Vanguard",
"tier": 1,
"unlock_requirements": null,
"base_stats": {
"health": 120,
"attack": 12,
"defense": 8,
"magic": 0,
"speed": 8,
"willpower": 5,
"movement": 3
},
"growth_rates": {
"health": 10,
"attack": 1,
"defense": 1
},
"starting_equipment": ["ITEM_RUSTY_BLADE", "ITEM_SCRAP_PLATE"],
"skillTreeData": {
"primary_stat": "health",
"secondary_stat": "defense",
"active_skills": [
"SKILL_SHIELD_BASH",
"SKILL_TAUNT",
"SKILL_INTERCEPT",
"SKILL_EXECUTE",
"SKILL_AVATAR_OF_IRON"
],
"passive_skills": [
"PASSIVE_IRON_SKIN",
"PASSIVE_THORNS",
"PASSIVE_UNYIELDING",
"PASSIVE_TITANS_GRIP"
]
}
}

View file

@ -0,0 +1,103 @@
/**
* SkillTreeFactory.js
* Generates class-specific skill trees by merging a Master Topology with Class Configuration.
*/
export class SkillTreeFactory {
/**
* @param {Object} templateRegistry - Map of Template IDs to JSON structures.
* @param {Object} skillRegistry - Map of Skill IDs to Skill Definitions.
*/
constructor(templateRegistry, skillRegistry) {
this.templates = templateRegistry;
this.skills = skillRegistry;
}
/**
* Creates a fully hydrated Skill Tree for a specific class.
* @param {Object} classConfig - The Class definition containing 'skillTreeData'.
* @param {string} templateId - The ID of the topology to use (default: 'TEMPLATE_STANDARD_30').
*/
createTree(classConfig, templateId = "TEMPLATE_STANDARD_30") {
const template = this.templates[templateId];
if (!template) throw new Error(`Template not found: ${templateId}`);
const config = classConfig.skillTreeData;
if (!config)
throw new Error(`Class ${classConfig.id} missing skillTreeData`);
const newTree = {
id: `TREE_${classConfig.id}`,
nodes: {},
};
// Iterate and Inject
for (const [nodeId, templateNode] of Object.entries(template.nodes)) {
// Clone the node structure to avoid mutating the template
let realNode = JSON.parse(JSON.stringify(templateNode));
// Hydrate based on Slot Type
this.hydrateNode(realNode, config, templateNode.tier);
newTree.nodes[nodeId] = realNode;
}
return newTree;
}
hydrateNode(node, config, tier) {
// Scaling Logic for Stats
const statValue = this.getTierStatValue(tier);
switch (node.type) {
case "SLOT_STAT_PRIMARY":
node.type = "STAT_BOOST";
node.data = {
stat: config.primary_stat,
value: statValue * 2, // Primary gets double value
};
break;
case "SLOT_STAT_SECONDARY":
node.type = "STAT_BOOST";
node.data = {
stat: config.secondary_stat,
value: statValue,
};
break;
case "SLOT_SKILL_ACTIVE_1":
node.type = "ACTIVE_SKILL";
// Map tier/slot to specific index in the config array
// Example: Slot 1 is the 0th skill
node.data = this.getSkillData(config.active_skills[0]);
break;
case "SLOT_SKILL_ACTIVE_2":
node.type = "ACTIVE_SKILL";
node.data = this.getSkillData(config.active_skills[1]);
break;
case "SLOT_SKILL_PASSIVE_1":
node.type = "PASSIVE_ABILITY";
node.data = { effect_id: config.passive_skills[0] };
break;
// ... Add cases for other slots (ULTIMATE, etc)
default:
// If it's already a concrete type (e.g. fixed layout), leave it alone
break;
}
}
getSkillData(skillId) {
const skill = this.skills[skillId];
if (!skill) return { id: skillId, name: "Unknown Skill" }; // Fallback
return skill;
}
getTierStatValue(tier) {
// Scaling logic: Tier 1 = 1, Tier 5 = 5
return tier;
}
}

View file

@ -12,7 +12,7 @@
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;700&family=Cinzel:wght@700&display=swap" href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;700&family=Cinzel:wght@700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<script type="module" src="index.js"></script>
<style> <style>
:root { :root {
/* Palette Definition */ /* Palette Definition */
@ -341,61 +341,10 @@
</div> </div>
</div> </div>
<team-builder class="hidden" aria-label="Team Builder"></team-builder>
<!-- GAME VIEWPORT CONTAINER --> <!-- GAME VIEWPORT CONTAINER -->
<game-viewport class="hidden" aria-label="Game World"></game-viewport> <game-viewport class="hidden" aria-label="Game World"></game-viewport>
<!-- GAME LOGIC (MODULE SCRIPT) --> <!-- GAME LOGIC (MODULE SCRIPT) -->
<script type="module">
// --- 2. Accessibility Helper ---
function announce(message) {
const announcer = document.getElementById("a11y-announcer");
announcer.textContent = message;
}
// --- 3. New Descent Logic (Using Dynamic Import) ---
// We attach listener inside the module script because module scope is local
document
.getElementById("btn-start")
.addEventListener("click", startNewDescent);
async function startNewDescent() {
// A. Update State & UI
const landingUI = document.getElementById("landing-ui");
const loader = document.getElementById("loading-overlay");
const loadingMsg = document.getElementById("loading-message");
landingUI.classList.add("hidden");
loader.classList.remove("hidden");
// B. Accessibility Updates
announce("Starting new game. Loading game engine.");
loadingMsg.textContent = "LOADING GAME COMPONENT...";
// C. Lazy Load logic (Components registered via import above)
try {
// Simulate loading time
setTimeout(() => {
loadingMsg.textContent = "GENERATING VOXEL GRID...";
initializeGameWorld();
}, 1000);
} catch (error) {
console.error("Failed to load game:", error);
loadingMsg.textContent = "ERROR LOADING ENGINE. PLEASE REFRESH.";
announce("Error loading game engine. Please refresh.");
}
}
// --- 4. Game Initialization ---
async function initializeGameWorld() {
const loader = document.getElementById("loading-overlay");
const gameViewport = document.querySelector("game-viewport");
await import("./game-viewport.js");
// D. Transition to Game
loader.classList.add("hidden");
gameViewport.classList.remove("hidden");
announce("Game loaded. Tactical grid active.");
}
</script>
</body> </body>
</html> </html>

63
src/index.js Normal file
View file

@ -0,0 +1,63 @@
const loader = document.getElementById("loading-overlay");
const landingUI = document.getElementById("landing-ui");
const loadingMsg = document.getElementById("loading-message");
// --- 2. Accessibility Helper ---
function announce(message) {
const announcer = document.getElementById("a11y-announcer");
announcer.textContent = message;
}
// --- 3. New Descent Logic (Using Dynamic Import) ---
// We attach listener inside the module script because module scope is local
document.getElementById("btn-start").addEventListener("click", startNewDescent);
async function startNewDescent() {
landingUI.classList.add("hidden");
loader.classList.remove("hidden");
// B. Accessibility Updates
announce("Starting new game. Entering the Team Builder.");
loadingMsg.textContent = "LOADING TEAM BUILDER COMPONENT...";
// C. Lazy Load logic (Components registered via import above)
try {
initiateTeamBuilder();
} catch (error) {
console.error("Failed to load team builder:", error);
loadingMsg.textContent = "ERROR LOADING TEAM BUILDER. PLEASE REFRESH.";
announce("Error loading team builder. Please refresh.");
}
// try {
// // Simulate loading time
// setTimeout(() => {
// loadingMsg.textContent = "GENERATING VOXEL GRID...";
// initializeGameWorld();
// }, 1000);
// } catch (error) {
// console.error("Failed to load game:", error);
// loadingMsg.textContent = "ERROR LOADING ENGINE. PLEASE REFRESH.";
// announce("Error loading game engine. Please refresh.");
// }
}
async function initiateTeamBuilder() {
await import("./ui/team-builder.js");
const teamBuilder = document.querySelector("team-builder");
document.startViewTransition(() => {
teamBuilder.classList.remove("hidden");
loader.classList.add("hidden");
});
announce("Team Builder loaded. Ready to build your team.");
}
// --- 4. Game Initialization ---
async function initializeGameWorld() {
const gameViewport = document.querySelector("game-viewport");
await import("./ui/game-viewport.js");
// D. Transition to Game
loader.classList.add("hidden");
gameViewport.classList.remove("hidden");
announce("Game loaded. Tactical grid active.");
}

54
src/items/Item.js Normal file
View file

@ -0,0 +1,54 @@
/**
* Item.js
* Represents a piece of equipment or consumable.
*/
export class Item {
/**
* @param {Object} def - The JSON definition of the item.
*/
constructor(def) {
this.id = def.id;
this.name = def.name;
this.type = def.type; // WEAPON, ARMOR, UTILITY, RELIC
this.rarity = def.rarity || "COMMON";
this.tags = def.tags || [];
// Base Stats (e.g. { attack: 5, defense: 2 })
this.stats = def.stats || {};
// Passive Effects (Event Listeners)
// e.g. { trigger: "ON_HIT", action: "APPLY_STATUS", ... }
this.passives = def.passive_effects || [];
// Active Ability (Grants a new action button)
// e.g. { ability_id: "SKILL_FIREBALL" }
this.activeAbility = def.active_ability || null;
// Requirements to equip
this.requirements = def.requirements || {};
}
/**
* Returns true if the unit meets the requirements to equip this.
*/
canEquip(unit) {
if (this.requirements.class_lock) {
// Check if unit's active class is in the allowed list
if (!this.requirements.class_lock.includes(unit.activeClassId)) {
return false;
}
}
if (this.requirements.min_stat) {
for (const [stat, value] of Object.entries(this.requirements.min_stat)) {
if (unit.baseStats[stat] < value) return false;
}
}
return true;
}
getStat(statName) {
return this.stats[statName] || 0;
}
}

117
src/items/tier1_gear.json Normal file
View file

@ -0,0 +1,117 @@
[
{
"id": "ITEM_RUSTY_BLADE",
"name": "Rusty Infantry Blade",
"type": "WEAPON",
"rarity": "COMMON",
"tags": ["PHYSICAL", "MELEE"],
"stats": { "attack": 3 },
"description": "Standard issue jagged metal. Reliable but heavy."
},
{
"id": "ITEM_SCRAP_PLATE",
"name": "Scrap Plate Armor",
"type": "ARMOR",
"rarity": "COMMON",
"tags": ["HEAVY"],
"stats": { "defense": 3, "speed": -1 },
"description": "Cobbled together from ruin debris."
},
{
"id": "ITEM_APPRENTICE_WAND",
"name": "Apprentice Spark-Wand",
"type": "WEAPON",
"rarity": "COMMON",
"tags": ["MAGIC", "RANGED"],
"stats": { "magic": 4 },
"description": "A cracked crystal on a stick. Leaks sparks."
},
{
"id": "ITEM_ROBES",
"name": "Novice Robes",
"type": "ARMOR",
"rarity": "COMMON",
"tags": ["LIGHT"],
"stats": { "willpower": 3, "magic": 1 },
"description": "Standard issue for the Arcane Dominion."
},
{
"id": "ITEM_DAGGER",
"name": "Scavenger's Shiv",
"type": "WEAPON",
"rarity": "COMMON",
"tags": ["PHYSICAL", "MELEE", "LIGHT"],
"stats": { "attack": 2, "speed": 1 },
"description": "Sharp, rusty, and easy to hide."
},
{
"id": "ITEM_PADDED_VEST",
"name": "Padded Vest",
"type": "ARMOR",
"rarity": "COMMON",
"tags": ["LIGHT"],
"stats": { "defense": 1, "movement": 1 },
"description": "Doesn't stop a sword, but helps with the cold."
},
{
"id": "ITEM_LOCKPICK_SET",
"name": "Lockpick Set",
"type": "UTILITY",
"rarity": "COMMON",
"tags": ["TECH"],
"stats": { "tech": 2 },
"passive_effects": [
{
"trigger": "ON_INTERACT",
"condition": "TARGET_LOCKED",
"action": "UNLOCK",
"chance": 0.5
}
]
},
{
"id": "ITEM_WRENCH",
"name": "Heavy Wrench",
"type": "WEAPON",
"rarity": "COMMON",
"tags": ["PHYSICAL", "TECH", "BLUNT"],
"stats": { "attack": 4, "tech": 2 },
"description": "Good for fixing turrets and breaking knees."
},
{
"id": "ITEM_LEATHER_APRON",
"name": "Smith's Apron",
"type": "ARMOR",
"rarity": "COMMON",
"tags": ["MEDIUM"],
"stats": { "defense": 2, "fire_resist": 5 },
"description": "Thick leather to protect against sparks."
},
{
"id": "ITEM_TURRET_KIT",
"name": "Turret Construction Kit",
"type": "UTILITY",
"rarity": "COMMON",
"tags": ["TECH"],
"stats": { "tech": 3 },
"active_ability": { "ability_id": "SKILL_DEPLOY_TURRET" }
},
{
"id": "ITEM_STAFF",
"name": "Quarterstaff",
"type": "WEAPON",
"rarity": "COMMON",
"tags": ["PHYSICAL", "MAGIC", "BLUNT"],
"stats": { "attack": 3, "willpower": 2 },
"description": "Simple wood, balanced for defense."
},
{
"id": "ITEM_LEAF_ROBES",
"name": "Sanctuary Robes",
"type": "ARMOR",
"rarity": "COMMON",
"tags": ["LIGHT", "ORGANIC"],
"stats": { "willpower": 4, "health": 10 },
"description": "Woven from living vines."
}
]

View file

@ -1,8 +1,8 @@
import { LitElement, html, css } from "lit"; import { LitElement, html, css } from "lit";
import * as THREE from "three"; import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { VoxelGrid } from "./grid/VoxelGrid.js"; import { VoxelGrid } from "../grid/VoxelGrid.js";
import { VoxelManager } from "./grid/VoxelManager.js"; import { VoxelManager } from "../grid/VoxelManager.js";
export class GameViewport extends LitElement { export class GameViewport extends LitElement {
static styles = css` static styles = css`
@ -75,10 +75,10 @@ export class GameViewport extends LitElement {
// 1. Create Data Grid // 1. Create Data Grid
this.voxelGrid = new VoxelGrid(20, 8, 20); this.voxelGrid = new VoxelGrid(20, 8, 20);
const { CaveGenerator } = await import("./generation/CaveGenerator.js"); const { CaveGenerator } = await import("../generation/CaveGenerator.js");
const { RuinGenerator } = await import("./generation/RuinGenerator.js"); const { RuinGenerator } = await import("../generation/RuinGenerator.js");
const { CrystalSpiresGenerator } = await import( const { CrystalSpiresGenerator } = await import(
"./generation/CrystalSpiresGenerator.js" "../generation/CrystalSpiresGenerator.js"
); );
const crystalSpiresGen = new CrystalSpiresGenerator(this.voxelGrid, 12345); const crystalSpiresGen = new CrystalSpiresGenerator(this.voxelGrid, 12345);
crystalSpiresGen.generate(5, 8); crystalSpiresGen.generate(5, 8);

320
src/ui/team-builder.js Normal file
View file

@ -0,0 +1,320 @@
import { LitElement, html, css } from "lit";
export class TeamBuilder extends LitElement {
static get styles() {
return css`
:host {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
font-family: "Courier New", monospace; /* Placeholder for Voxel Font */
color: white;
pointer-events: none; /* Let clicks pass through to 3D scene where empty */
z-index: 10;
}
.container {
display: grid;
grid-template-columns: 250px 1fr 250px;
grid-template-rows: 1fr 80px;
height: 100%;
width: 100%;
pointer-events: auto;
background: rgba(0, 0, 0, 0.4); /* Dim background */
}
/* --- LEFT PANEL: ROSTER --- */
.roster-panel {
background: rgba(20, 20, 30, 0.9);
border-right: 2px solid #555;
padding: 1rem;
overflow-y: auto;
}
.class-card {
background: #333;
border: 2px solid #555;
padding: 10px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 10px;
}
.class-card:hover {
border-color: #00ffff;
background: #444;
}
.class-card.locked {
opacity: 0.5;
pointer-events: none;
filter: grayscale(1);
}
/* --- CENTER PANEL: SLOTS --- */
.squad-panel {
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: 2rem;
gap: 20px;
}
.squad-slot {
width: 120px;
height: 150px;
background: rgba(0, 0, 0, 0.6);
border: 2px dashed #666;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
}
.squad-slot.filled {
border: 2px solid #00ff00;
background: rgba(0, 50, 0, 0.6);
}
.squad-slot.selected {
border-color: #00ffff;
box-shadow: 0 0 10px #00ffff;
}
.remove-btn {
position: absolute;
top: -10px;
right: -10px;
background: red;
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
font-weight: bold;
}
/* --- RIGHT PANEL: DETAILS --- */
.details-panel {
background: rgba(20, 20, 30, 0.9);
border-left: 2px solid #555;
padding: 1rem;
}
/* --- FOOTER --- */
.footer {
grid-column: 1 / -1;
display: flex;
justify-content: center;
align-items: center;
background: rgba(10, 10, 20, 0.95);
border-top: 2px solid #555;
}
.embark-btn {
padding: 15px 40px;
font-size: 1.5rem;
background: #008800;
color: white;
border: 2px solid #00ff00;
cursor: pointer;
text-transform: uppercase;
font-weight: bold;
}
.embark-btn:disabled {
background: #333;
border-color: #555;
color: #777;
cursor: not-allowed;
}
`;
}
static get properties() {
return {
availableClasses: { type: Array }, // Input: List of class definition objects
squad: { type: Array }, // Internal State: The 4 slots
selectedSlotIndex: { type: Number },
hoveredClass: { type: Object },
};
}
constructor() {
super();
this.squad = [null, null, null, null];
this.selectedSlotIndex = 0; // Default to first slot
this.availableClasses = []; // Passed in by parent
this.hoveredClass = null;
}
render() {
const isSquadValid = this.squad.some((u) => u !== null);
return html`
<div class="container">
<!-- ROSTER LIST -->
<div class="roster-panel">
<h3>Roster</h3>
${this.availableClasses.map(
(cls) => html`
<div
class="class-card ${cls.unlocked ? "" : "locked"}"
@click="${() => this._assignClass(cls)}"
@mouseenter="${() => (this.hoveredClass = cls)}"
@mouseleave="${() => (this.hoveredClass = null)}"
>
<div class="icon">${cls.icon || "⚔️"}</div>
<div>
<strong>${cls.name}</strong><br />
<small>${cls.role}</small>
</div>
</div>
`
)}
</div>
<!-- CENTER SQUAD SLOTS -->
<div class="squad-panel">
${this.squad.map(
(unit, index) => html`
<div
class="squad-slot ${unit ? "filled" : ""} ${this
.selectedSlotIndex === index
? "selected"
: ""}"
@click="${() => this._selectSlot(index)}"
>
${unit
? html`
<div class="icon" style="font-size: 2rem;">
${unit.icon || "🛡️"}
</div>
<span>${unit.name}</span>
<button
class="remove-btn"
@click="${(e) => this._removeUnit(e, index)}"
>
X
</button>
`
: html`<span
>Slot ${index + 1}<br /><small>Select Class</small></span
>`}
</div>
`
)}
</div>
<!-- RIGHT DETAILS PANEL -->
<div class="details-panel">
${this.hoveredClass
? html`
<h2>${this.hoveredClass.name}</h2>
<p><em>${this.hoveredClass.role}</em></p>
<hr />
<p>
${this.hoveredClass.description ||
"No description available."}
</p>
<h4>Base Stats</h4>
<ul>
<li>HP: ${this.hoveredClass.base_stats?.health}</li>
<li>AP: ${this.hoveredClass.base_stats?.speed}</li>
<!-- Simplified AP calc -->
<li>Move: ${this.hoveredClass.base_stats?.movement}</li>
</ul>
`
: html`<p>Hover over a class to see details.</p>`}
</div>
<!-- FOOTER -->
<div class="footer">
<button
class="embark-btn"
?disabled="${!isSquadValid}"
@click="${this._handleEmbark}"
>
DESCEND
</button>
</div>
</div>
`;
}
// --- LOGIC ---
_selectSlot(index) {
this.selectedSlotIndex = index;
}
_assignClass(classDef) {
if (!classDef.unlocked) return;
// 1. Create a lightweight manifest for the slot
const unitManifest = {
classId: classDef.id,
name: classDef.name, // In real app, auto-generate name
icon: classDef.icon,
};
// 2. Update State (Trigger Re-render)
const newSquad = [...this.squad];
newSquad[this.selectedSlotIndex] = unitManifest;
this.squad = newSquad;
// 3. Auto-advance selection
if (this.selectedSlotIndex < 3) {
this.selectedSlotIndex++;
}
// 4. Dispatch Event (For 3D Scene to show model)
this.dispatchEvent(
new CustomEvent("squad-update", {
detail: { slot: this.selectedSlotIndex, unit: unitManifest },
bubbles: true,
composed: true,
})
);
}
_removeUnit(e, index) {
e.stopPropagation(); // Prevent slot selection
const newSquad = [...this.squad];
newSquad[index] = null;
this.squad = newSquad;
this.selectedSlotIndex = index; // Select the empty slot
// Dispatch Event (To clear 3D model)
this.dispatchEvent(
new CustomEvent("squad-update", {
detail: { slot: index, unit: null },
bubbles: true,
composed: true,
})
);
}
_handleEmbark() {
const manifest = this.squad.filter((u) => u !== null);
this.dispatchEvent(
new CustomEvent("embark", {
detail: { squad: manifest },
bubbles: true,
composed: true,
})
);
}
}
customElements.define("team-builder", TeamBuilder);

114
src/units/Explorer.js Normal file
View file

@ -0,0 +1,114 @@
import { Unit } from "./Unit.js";
/**
* Explorer.js
* Player character class supporting Multi-Class Mastery and Persistent Progression.
*/
export class Explorer extends Unit {
constructor(id, name, startingClassId, classDefinition) {
super(id, name, "EXPLORER", `${startingClassId}_MODEL`);
this.activeClassId = startingClassId;
// Persistent Mastery: Tracks progress for EVERY class this character has played
// Key: ClassID, Value: { level, xp, skillPoints, unlockedNodes[] }
this.classMastery = {};
// Initialize the starting class entry
this.initializeMastery(startingClassId);
// Hydrate stats based on the provided definition
if (classDefinition) {
this.recalculateBaseStats(classDefinition);
this.currentHealth = this.baseStats.health;
}
// Inventory
this.equipment = {
weapon: null,
armor: null,
utility: null,
relic: null,
};
// Active Skills (Populated by Skill Tree)
this.actions = [];
this.passives = [];
}
initializeMastery(classId) {
if (!this.classMastery[classId]) {
this.classMastery[classId] = {
level: 1,
xp: 0,
skillPoints: 0,
unlockedNodes: [],
};
}
}
/**
* Updates base stats based on the active class's base + growth rates * level.
* @param {Object} classDef - The JSON definition of the class stats.
*/
recalculateBaseStats(classDef) {
if (classDef.id !== this.activeClassId) {
console.warn(
`Mismatch: Recalculating stats for ${this.activeClassId} using definition for ${classDef.id}`
);
}
const mastery = this.classMastery[this.activeClassId];
// 1. Start with Class Defaults
let stats = { ...classDef.base_stats };
// 2. Add Level Growth
// (Level 1 is base, so growth applies for levels 2+)
const levelsGained = mastery.level - 1;
if (levelsGained > 0) {
for (let stat in classDef.growth_rates) {
if (stats[stat] !== undefined) {
stats[stat] += classDef.growth_rates[stat] * levelsGained;
}
}
}
this.baseStats = stats;
this.maxHealth = stats.health; // Update MaxHP cap
}
/**
* Swaps the active class logic.
* NOTE: Does NOT check unlock requirements (handled by UI/MetaSystem).
*/
changeClass(newClassId, newClassDef) {
// 1. Ensure mastery record exists
this.initializeMastery(newClassId);
// 2. Switch ID
this.activeClassId = newClassId;
// 3. Update Model ID (Visuals)
this.voxelModelID = `${newClassId}_MODEL`;
// 4. Recalculate Stats for the new job
this.recalculateBaseStats(newClassDef);
// 5. Reset Current HP to new Max (or keep percentage? Standard is reset)
this.currentHealth = this.baseStats.health;
}
/**
* Adds XP to the *current* class.
*/
gainExperience(amount) {
const mastery = this.classMastery[this.activeClassId];
mastery.xp += amount;
// Level up logic would be handled by a system checking XP curves
}
getLevel() {
return this.classMastery[this.activeClassId].level;
}
}

60
src/units/Unit.js Normal file
View file

@ -0,0 +1,60 @@
/**
* Unit.js
* Base class for all entities on the grid (Explorers, Enemies, Structures).
*/
export class Unit {
constructor(id, name, type, voxelModelID) {
this.id = id;
this.name = name;
this.type = type; // 'EXPLORER', 'ENEMY', 'STRUCTURE'
this.voxelModelID = voxelModelID;
// Grid State
this.position = { x: 0, y: 0, z: 0 };
this.facing = "NORTH";
// Combat State
this.currentHealth = 100;
this.maxHealth = 100; // Derived from effective stats later
this.currentAP = 0; // Action Points for current turn
this.chargeMeter = 0; // Dynamic Initiative (0-100)
this.statusEffects = []; // Active debuffs/buffs
// Base Stats (Raw values before gear/buffs)
this.baseStats = {
health: 100,
attack: 10,
defense: 5,
magic: 0,
speed: 10,
willpower: 5,
movement: 4,
tech: 0,
};
}
/**
* Updates position.
* Note: Validation happens in VoxelGrid/MovementSystem.
*/
setPosition(x, y, z) {
this.position = { x, y, z };
}
/**
* Consumes AP. Returns true if successful.
*/
spendAP(amount) {
if (this.currentAP >= amount) {
this.currentAP -= amount;
return true;
}
return false;
}
isAlive() {
return this.currentHealth > 0;
}
}

View file

@ -0,0 +1,68 @@
import { expect } from "@esm-bundle/chai";
import { SkillTreeFactory } from "../../src/factories/SkillTreeFactory.js";
describe("System: Skill Tree Factory", () => {
let factory;
let mockTemplates;
let mockSkills;
let mockClassConfig;
beforeEach(() => {
// 1. Setup Mock Data
mockTemplates = {
TEMPLATE_STANDARD_30: {
nodes: {
ROOT_NODE: {
tier: 1,
type: "SLOT_STAT_PRIMARY",
children: ["CHILD_NODE"],
},
CHILD_NODE: { tier: 1, type: "SLOT_SKILL_ACTIVE_1", children: [] },
},
},
};
mockSkills = {
SKILL_FIREBALL: { id: "SKILL_FIREBALL", name: "Fireball", damage: 10 },
};
mockClassConfig = {
id: "TEST_CLASS",
skillTreeData: {
primary_stat: "attack",
secondary_stat: "defense",
active_skills: ["SKILL_FIREBALL"],
passive_skills: ["PASSIVE_BURNING"],
},
};
// 2. Initialize Factory
factory = new SkillTreeFactory(mockTemplates, mockSkills);
});
it("CoA 1: Should maintain the topology (structure) of the template", () => {
const tree = factory.createTree(mockClassConfig);
expect(tree.nodes).to.have.property("ROOT_NODE");
expect(tree.nodes).to.have.property("CHILD_NODE");
expect(tree.nodes["ROOT_NODE"].children).to.include("CHILD_NODE");
});
it("CoA 2: Should inject Primary Stats based on Class Config", () => {
const tree = factory.createTree(mockClassConfig);
const rootNode = tree.nodes["ROOT_NODE"];
expect(rootNode.type).to.equal("STAT_BOOST");
expect(rootNode.data.stat).to.equal("attack"); // Injected from config
expect(rootNode.data.value).to.equal(2); // Tier 1 * 2
});
it("CoA 3: Should inject Active Skills based on Class Config", () => {
const tree = factory.createTree(mockClassConfig);
const childNode = tree.nodes["CHILD_NODE"];
expect(childNode.type).to.equal("ACTIVE_SKILL");
// Should resolve the full skill object from registry
expect(childNode.data.name).to.equal("Fireball");
});
});

73
test/items/Item.test.js Normal file
View file

@ -0,0 +1,73 @@
import { expect } from "@esm-bundle/chai";
import { Item } from "../../src/items/Item.js";
// Mock Unit for Requirement Checking
const mockVanguard = {
activeClassId: "CLASS_VANGUARD",
baseStats: { attack: 10, tech: 0 },
};
const mockTinker = {
activeClassId: "CLASS_TINKER",
baseStats: { attack: 5, tech: 10 },
};
describe("System: Items", () => {
let swordDef;
let techGunDef;
beforeEach(() => {
swordDef = {
id: "ITEM_TEST_SWORD",
name: "Test Sword",
type: "WEAPON",
stats: { attack: 5 },
requirements: { min_stat: { attack: 8 } },
};
techGunDef = {
id: "ITEM_TEST_GUN",
type: "WEAPON",
stats: { attack: 8 },
requirements: { class_lock: ["CLASS_TINKER"] },
};
});
it("CoA 1: Should store basic stats correctly", () => {
const item = new Item(swordDef);
expect(item.getStat("attack")).to.equal(5);
expect(item.getStat("magic")).to.equal(0); // Undefined stat
});
it("CoA 2: Should enforce Min Stat requirements", () => {
const item = new Item(swordDef);
// Vanguard has 10 Atk (Passes > 8)
expect(item.canEquip(mockVanguard)).to.be.true;
// Tinker has 5 Atk (Fails < 8)
expect(item.canEquip(mockTinker)).to.be.false;
});
it("CoA 3: Should enforce Class Lock requirements", () => {
const item = new Item(techGunDef);
// Vanguard cannot equip Tinker items
expect(item.canEquip(mockVanguard)).to.be.false;
// Tinker can
expect(item.canEquip(mockTinker)).to.be.true;
});
it("CoA 4: Should handle Active Ability grants", () => {
const kitDef = {
id: "ITEM_KIT",
type: "UTILITY",
active_ability: { ability_id: "SKILL_DEPLOY" },
};
const item = new Item(kitDef);
expect(item.activeAbility).to.exist;
expect(item.activeAbility.ability_id).to.equal("SKILL_DEPLOY");
});
});

View file

@ -0,0 +1,73 @@
import { expect } from "@esm-bundle/chai";
import { Explorer } from "../../src/units/Explorer.js";
// Mock Class Definitions
const CLASS_VANGUARD = {
id: "CLASS_VANGUARD",
base_stats: { health: 100, attack: 10, speed: 5 },
growth_rates: { health: 10, attack: 1 },
};
const CLASS_TINKER = {
id: "CLASS_TINKER",
base_stats: { health: 80, attack: 8, speed: 7 },
growth_rates: { health: 5, attack: 2 },
};
describe("Unit: Explorer Class Logic", () => {
it("CoA 1: Should initialize with base stats from definition", () => {
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
expect(hero.baseStats.health).to.equal(100);
expect(hero.baseStats.attack).to.equal(10);
expect(hero.classMastery["CLASS_VANGUARD"]).to.exist;
expect(hero.classMastery["CLASS_VANGUARD"].level).to.equal(1);
});
it("CoA 2: Should calculate stats based on Level Growth", () => {
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
// Manually level up to 3
hero.classMastery["CLASS_VANGUARD"].level = 3;
hero.recalculateBaseStats(CLASS_VANGUARD);
// Level 3 means 2 level-ups.
// Health: 100 + (10 * 2) = 120
// Attack: 10 + (1 * 2) = 12
expect(hero.baseStats.health).to.equal(120);
expect(hero.baseStats.attack).to.equal(12);
});
it("CoA 3: changeClass should switch stats and persist old progress", () => {
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
// Level up Vanguard
hero.classMastery["CLASS_VANGUARD"].level = 5;
hero.recalculateBaseStats(CLASS_VANGUARD);
expect(hero.baseStats.health).to.equal(140); // 100 + 40
// Switch to Tinker (New Job)
hero.changeClass("CLASS_TINKER", CLASS_TINKER);
// Should have Level 1 Tinker Stats
expect(hero.activeClassId).to.equal("CLASS_TINKER");
expect(hero.baseStats.health).to.equal(80); // Base Tinker
// Verify Vanguard history is saved
expect(hero.classMastery["CLASS_VANGUARD"].level).to.equal(5);
});
it("CoA 4: Switching BACK to old class should restore high stats", () => {
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
hero.classMastery["CLASS_VANGUARD"].level = 5;
// Switch Away
hero.changeClass("CLASS_TINKER", CLASS_TINKER);
// Switch Back
hero.changeClass("CLASS_VANGUARD", CLASS_VANGUARD);
// Should be back to Level 5 Stats
expect(hero.baseStats.health).to.equal(140);
});
});

0
test/units/Unit.test.js Normal file
View file