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:
parent
921a93d989
commit
391abd6ea6
24 changed files with 1451 additions and 60 deletions
5
build.js
5
build.js
|
|
@ -11,10 +11,11 @@ mkdirSync("dist", { recursive: true });
|
|||
|
||||
// Build JavaScript
|
||||
await build({
|
||||
entryPoints: ["src/game-viewport.js"],
|
||||
entryPoints: ["src/index.js"],
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
format: "esm",
|
||||
outfile: "dist/game-viewport.js",
|
||||
outdir: "dist",
|
||||
sourcemap: true,
|
||||
platform: "browser",
|
||||
});
|
||||
|
|
|
|||
38
src/assets/data/classes/aether_sentinel.json
Normal file
38
src/assets/data/classes/aether_sentinel.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
38
src/assets/data/classes/aether_weaver.json
Normal file
38
src/assets/data/classes/aether_weaver.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
38
src/assets/data/classes/arcane_scourge.json
Normal file
38
src/assets/data/classes/arcane_scourge.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
39
src/assets/data/classes/battle_mage.json
Normal file
39
src/assets/data/classes/battle_mage.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
38
src/assets/data/classes/custodian.json
Normal file
38
src/assets/data/classes/custodian.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
43
src/assets/data/classes/field_engineer.json
Normal file
43
src/assets/data/classes/field_engineer.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
39
src/assets/data/classes/sapper.json
Normal file
39
src/assets/data/classes/sapper.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
42
src/assets/data/classes/scavenger.json
Normal file
42
src/assets/data/classes/scavenger.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
43
src/assets/data/classes/tinker.json
Normal file
43
src/assets/data/classes/tinker.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
38
src/assets/data/classes/vanguard.json
Normal file
38
src/assets/data/classes/vanguard.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
103
src/factories/SkillTreeFactory.js
Normal file
103
src/factories/SkillTreeFactory.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;700&family=Cinzel:wght@700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<script type="module" src="index.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
/* Palette Definition */
|
||||
|
|
@ -341,61 +341,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<team-builder class="hidden" aria-label="Team Builder"></team-builder>
|
||||
<!-- GAME VIEWPORT CONTAINER -->
|
||||
<game-viewport class="hidden" aria-label="Game World"></game-viewport>
|
||||
|
||||
<!-- 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>
|
||||
</html>
|
||||
|
|
|
|||
63
src/index.js
Normal file
63
src/index.js
Normal 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
54
src/items/Item.js
Normal 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
117
src/items/tier1_gear.json
Normal 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."
|
||||
}
|
||||
]
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { LitElement, html, css } from "lit";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
import { VoxelGrid } from "./grid/VoxelGrid.js";
|
||||
import { VoxelManager } from "./grid/VoxelManager.js";
|
||||
import { VoxelGrid } from "../grid/VoxelGrid.js";
|
||||
import { VoxelManager } from "../grid/VoxelManager.js";
|
||||
|
||||
export class GameViewport extends LitElement {
|
||||
static styles = css`
|
||||
|
|
@ -75,10 +75,10 @@ export class GameViewport extends LitElement {
|
|||
// 1. Create Data Grid
|
||||
this.voxelGrid = new VoxelGrid(20, 8, 20);
|
||||
|
||||
const { CaveGenerator } = await import("./generation/CaveGenerator.js");
|
||||
const { RuinGenerator } = await import("./generation/RuinGenerator.js");
|
||||
const { CaveGenerator } = await import("../generation/CaveGenerator.js");
|
||||
const { RuinGenerator } = await import("../generation/RuinGenerator.js");
|
||||
const { CrystalSpiresGenerator } = await import(
|
||||
"./generation/CrystalSpiresGenerator.js"
|
||||
"../generation/CrystalSpiresGenerator.js"
|
||||
);
|
||||
const crystalSpiresGen = new CrystalSpiresGenerator(this.voxelGrid, 12345);
|
||||
crystalSpiresGen.generate(5, 8);
|
||||
320
src/ui/team-builder.js
Normal file
320
src/ui/team-builder.js
Normal 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
114
src/units/Explorer.js
Normal 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
60
src/units/Unit.js
Normal 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;
|
||||
}
|
||||
}
|
||||
68
test/factories/SkillTreeFactory.test.js
Normal file
68
test/factories/SkillTreeFactory.test.js
Normal 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
73
test/items/Item.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
73
test/units/Explorer.test.js
Normal file
73
test/units/Explorer.test.js
Normal 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
0
test/units/Unit.test.js
Normal file
Loading…
Reference in a new issue