feat: Recreate Spire Combat Page — full spire climbing experience
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- Add guardian-encounters.ts: Extended guardian definitions for all mana types (compound, exotic, combo) with dynamic name generation - Add spire-utils.ts: Spire-specific utilities (room generation, enemy stat scaling, insight calculation) - Add enemy-generator.ts: Enemy generation with combinable modifiers (mage, shield, armored, swarm, agile) - Add SpireCombatPage/ directory with modular sub-components: - SpireHeader.tsx: Floor info, climb controls, exit button, HP/room progress bars - RoomDisplay.tsx: Current room info with enemies, barriers, armor, dodge stats - SpireCombatControls.tsx: Spell selection panel, golem status panel - SpireActivityLog.tsx: Combat activity log - SpireManaDisplay.tsx: Compact mana display with elemental pools - Modify page.tsx: Conditionally render SpireCombatPage when spireMode is true - Add comprehensive tests (49 tests) for spire utilities, guardian encounters, and enemy generation
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
// ─── Spire Utility Functions ───────────────────────────────────────────────────
|
||||
// Spire-specific utility functions for room generation, enemy stat scaling, etc.
|
||||
|
||||
import type { RoomType, FloorState, EnemyState } from '../types';
|
||||
import { GUARDIANS, FLOOR_ELEM_CYCLE, PUZZLE_ROOMS } from '../constants';
|
||||
import { getFloorMaxHP, getFloorElement } from './floor-utils';
|
||||
import { getEnemyName } from './enemy-utils';
|
||||
import { isGuardianFloor, getExtendedGuardian } from '../data/guardian-encounters';
|
||||
|
||||
// ─── Spire Room Configuration ─────────────────────────────────────────────────
|
||||
|
||||
export const SPIRE_CONFIG = {
|
||||
minRoomsPerFloor: 5,
|
||||
maxRoomsPerFloor: 15,
|
||||
guardianRooms: 1,
|
||||
puzzleRoomChance: 0.12,
|
||||
rareRoomChance: 0.05,
|
||||
recoveryRoomChance: 0.4,
|
||||
libraryRoomChance: 0.3,
|
||||
treasureRoomChance: 0.3,
|
||||
};
|
||||
|
||||
// ─── Room Count ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function getRoomsForFloor(floor: number): number {
|
||||
if (isGuardianFloor(floor)) return SPIRE_CONFIG.guardianRooms;
|
||||
const base = SPIRE_CONFIG.minRoomsPerFloor;
|
||||
const range = SPIRE_CONFIG.maxRoomsPerFloor - SPIRE_CONFIG.minRoomsPerFloor;
|
||||
// Slight increase in rooms at higher floors
|
||||
const floorBonus = Math.min(range, Math.floor(floor / 20));
|
||||
const randomVariation = Math.floor(Math.random() * 3);
|
||||
return base + floorBonus + randomVariation;
|
||||
}
|
||||
|
||||
// ─── Spire Room Types ─────────────────────────────────────────────────────────
|
||||
|
||||
export type SpireRoomType = RoomType | 'recovery' | 'library' | 'treasure';
|
||||
|
||||
// ─── Room Generation ──────────────────────────────────────────────────────────
|
||||
|
||||
export function generateSpireRoomType(floor: number, roomIndex: number, totalRooms: number): SpireRoomType {
|
||||
// Last room on guardian floors is always guardian
|
||||
if (isGuardianFloor(floor) && roomIndex === totalRooms - 1) {
|
||||
return 'guardian';
|
||||
}
|
||||
|
||||
// First room on a floor is never a special room (always combat)
|
||||
if (roomIndex === 0) {
|
||||
return generateCombatRoomType(floor);
|
||||
}
|
||||
|
||||
// Rare rooms (mid-floor)
|
||||
if (roomIndex === Math.floor(totalRooms / 2) && Math.random() < SPIRE_CONFIG.rareRoomChance) {
|
||||
return generateRareRoomType();
|
||||
}
|
||||
|
||||
// Puzzle rooms
|
||||
if (floor % 7 === 0 && Math.random() < SPIRE_CONFIG.puzzleRoomChance) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
return generateCombatRoomType(floor);
|
||||
}
|
||||
|
||||
function generateCombatRoomType(floor: number): RoomType {
|
||||
const roll = Math.random();
|
||||
if (roll < 0.12) return 'swarm';
|
||||
if (roll < 0.22) return 'speed';
|
||||
return 'combat';
|
||||
}
|
||||
|
||||
function generateRareRoomType(): SpireRoomType {
|
||||
const roll = Math.random();
|
||||
if (roll < SPIRE_CONFIG.recoveryRoomChance) return 'recovery';
|
||||
if (roll < SPIRE_CONFIG.recoveryRoomChance + SPIRE_CONFIG.libraryRoomChance) return 'library';
|
||||
return 'treasure';
|
||||
}
|
||||
|
||||
// ─── Floor State Generation ───────────────────────────────────────────────────
|
||||
|
||||
export function generateSpireFloorState(floor: number, roomIndex: number, totalRooms: number): FloorState {
|
||||
const roomType = generateSpireRoomType(floor, roomIndex, totalRooms);
|
||||
const element = getFloorElement(floor);
|
||||
const baseHP = getFloorMaxHP(floor);
|
||||
|
||||
switch (roomType) {
|
||||
case 'guardian': {
|
||||
const guardian = GUARDIANS[floor] || getExtendedGuardian(floor);
|
||||
if (guardian) {
|
||||
return {
|
||||
roomType: 'guardian',
|
||||
enemies: [{
|
||||
id: 'guardian',
|
||||
name: guardian.name,
|
||||
hp: guardian.hp,
|
||||
maxHP: guardian.hp,
|
||||
armor: guardian.armor || 0,
|
||||
dodgeChance: 0,
|
||||
barrier: 0,
|
||||
element: guardian.element,
|
||||
}],
|
||||
};
|
||||
}
|
||||
// Fallback if no guardian defined for this floor
|
||||
return generateCombatRoom(floor, element, baseHP);
|
||||
}
|
||||
|
||||
case 'swarm':
|
||||
return generateSwarmRoom(floor, element, baseHP);
|
||||
|
||||
case 'speed':
|
||||
return generateSpeedRoom(floor, element, baseHP);
|
||||
|
||||
case 'puzzle': {
|
||||
const puzzleKeys = Object.keys(PUZZLE_ROOMS);
|
||||
const selectedPuzzle = puzzleKeys[Math.floor(Math.random() * puzzleKeys.length)];
|
||||
const puzzle = PUZZLE_ROOMS[selectedPuzzle];
|
||||
return {
|
||||
roomType: 'puzzle',
|
||||
enemies: [],
|
||||
puzzleProgress: 0,
|
||||
puzzleRequired: 1,
|
||||
puzzleId: selectedPuzzle,
|
||||
puzzleAttunements: puzzle.attunements,
|
||||
};
|
||||
}
|
||||
|
||||
case 'recovery':
|
||||
return {
|
||||
roomType: 'recovery',
|
||||
enemies: [],
|
||||
recoveryProgress: 0,
|
||||
recoveryRequired: 1,
|
||||
} as unknown as FloorState;
|
||||
|
||||
case 'library':
|
||||
return {
|
||||
roomType: 'library',
|
||||
enemies: [],
|
||||
libraryProgress: 0,
|
||||
libraryRequired: 1,
|
||||
} as unknown as FloorState;
|
||||
|
||||
case 'treasure':
|
||||
return {
|
||||
roomType: 'treasure',
|
||||
enemies: [],
|
||||
} as unknown as FloorState;
|
||||
|
||||
default:
|
||||
return generateCombatRoom(floor, element, baseHP);
|
||||
}
|
||||
}
|
||||
|
||||
function generateCombatRoom(floor: number, element: string, baseHP: number): FloorState {
|
||||
const armor = getSpireEnemyArmor(floor);
|
||||
const barrier = getSpireEnemyBarrier(floor, element);
|
||||
const enemyName = getEnemyName(element, floor);
|
||||
|
||||
return {
|
||||
roomType: 'combat',
|
||||
enemies: [{
|
||||
id: 'enemy',
|
||||
name: enemyName,
|
||||
hp: baseHP,
|
||||
maxHP: baseHP,
|
||||
armor,
|
||||
dodgeChance: 0,
|
||||
barrier,
|
||||
element,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function generateSwarmRoom(floor: number, element: string, baseHP: number): FloorState {
|
||||
const numEnemies = 3 + Math.floor(Math.random() * 5); // 3-7 enemies
|
||||
const enemies: EnemyState[] = [];
|
||||
|
||||
for (let i = 0; i < numEnemies; i++) {
|
||||
enemies.push({
|
||||
id: `swarm_${i}`,
|
||||
name: `${getEnemyName(element, floor)} ${i + 1}`,
|
||||
hp: Math.floor(baseHP * 0.35),
|
||||
maxHP: Math.floor(baseHP * 0.35),
|
||||
armor: Math.floor(floor / 15) * 0.02,
|
||||
dodgeChance: 0,
|
||||
barrier: 0,
|
||||
element,
|
||||
});
|
||||
}
|
||||
|
||||
return { roomType: 'swarm', enemies };
|
||||
}
|
||||
|
||||
function generateSpeedRoom(floor: number, element: string, baseHP: number): FloorState {
|
||||
const dodgeChance = Math.min(0.55, 0.20 + floor * 0.005);
|
||||
const armor = getSpireEnemyArmor(floor);
|
||||
|
||||
return {
|
||||
roomType: 'speed',
|
||||
enemies: [{
|
||||
id: 'agile_enemy',
|
||||
name: `Agile ${getEnemyName(element, floor)}`,
|
||||
hp: baseHP,
|
||||
maxHP: baseHP,
|
||||
armor,
|
||||
dodgeChance,
|
||||
barrier: getSpireEnemyBarrier(floor, element),
|
||||
element,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Enemy Stat Scaling ───────────────────────────────────────────────────────
|
||||
|
||||
export function getSpireEnemyArmor(floor: number): number {
|
||||
if (floor < 10) return 0;
|
||||
const baseChance = Math.min(0.5, (floor - 10) * 0.01);
|
||||
if (Math.random() > baseChance) return 0;
|
||||
const minArmor = 0.05;
|
||||
const maxArmor = 0.30;
|
||||
const progress = Math.min(1, (floor - 10) / 90);
|
||||
return minArmor + (maxArmor - minArmor) * progress * Math.random();
|
||||
}
|
||||
|
||||
export function getSpireEnemyBarrier(floor: number, element: string): number {
|
||||
if (floor < 15) return 0;
|
||||
const barrierElements = ['light', 'water', 'earth'];
|
||||
const baseChance = barrierElements.includes(element) ? 0.12 : 0.06;
|
||||
const floorBonus = Math.min(0.2, (floor - 15) * 0.003);
|
||||
if (Math.random() > Math.min(0.35, baseChance + floorBonus)) return 0;
|
||||
const progress = Math.min(1, (floor - 15) / 85);
|
||||
return 0.1 + progress * 0.2;
|
||||
}
|
||||
|
||||
// ─── Insight Calculation ──────────────────────────────────────────────────────
|
||||
|
||||
export function calcInsight(floor: number, isGuardian: boolean): number {
|
||||
const base = Math.floor(Math.pow(floor, 1.2));
|
||||
return isGuardian ? Math.floor(base * 2.5) : base;
|
||||
}
|
||||
|
||||
// ─── Room Type Display ────────────────────────────────────────────────────────
|
||||
|
||||
export function getSpireRoomTypeDisplay(roomType: SpireRoomType): { label: string; icon: string; color: string } {
|
||||
const displays: Record<string, { label: string; icon: string; color: string }> = {
|
||||
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
|
||||
swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
|
||||
speed: { label: 'Speed', icon: '💨', color: '#3B82F6' },
|
||||
guardian: { label: 'Guardian', icon: '🛡️', color: '#EF4444' },
|
||||
puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
|
||||
recovery: { label: 'Recovery', icon: '💚', color: '#10B981' },
|
||||
library: { label: 'Ancient Library', icon: '📚', color: '#6366F1' },
|
||||
treasure: { label: 'Treasure', icon: '💎', color: '#F59E0B' },
|
||||
};
|
||||
return displays[roomType] || { label: 'Unknown', icon: '❓', color: '#6B7280' };
|
||||
}
|
||||
Reference in New Issue
Block a user