Add levelling up mechanics through experience gained via killing enemies
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
|
||||
import { type World, type Tile } from '../../core/types';
|
||||
import { GAME_CONFIG } from '../../core/config/GameConfig';
|
||||
|
||||
|
||||
describe('World Utilities', () => {
|
||||
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
|
||||
@@ -44,9 +46,10 @@ describe('World Utilities', () => {
|
||||
|
||||
describe('isWall', () => {
|
||||
it('should return true for wall tiles', () => {
|
||||
const tiles: Tile[] = new Array(100).fill(0);
|
||||
tiles[0] = 1; // wall at 0,0
|
||||
tiles[55] = 1; // wall at 5,5
|
||||
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
|
||||
tiles[0] = GAME_CONFIG.terrain.wall; // wall at 0,0
|
||||
tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5
|
||||
|
||||
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
@@ -55,11 +58,12 @@ describe('World Utilities', () => {
|
||||
});
|
||||
|
||||
it('should return false for floor tiles', () => {
|
||||
const tiles: Tile[] = new Array(100).fill(0);
|
||||
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
expect(isWall(world, 3, 3)).toBe(false);
|
||||
expect(isWall(world, 7, 7)).toBe(false);
|
||||
|
||||
});
|
||||
|
||||
it('should return false for out of bounds coordinates', () => {
|
||||
@@ -72,8 +76,9 @@ describe('World Utilities', () => {
|
||||
|
||||
describe('isBlocked', () => {
|
||||
it('should return true for walls', () => {
|
||||
const tiles: Tile[] = new Array(100).fill(0);
|
||||
tiles[55] = 1; // wall at 5,5
|
||||
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
|
||||
tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5
|
||||
|
||||
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ACTION_COST, ENERGY_THRESHOLD } from "../../core/constants";
|
||||
import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types";
|
||||
|
||||
import { isBlocked } from "../world/world-logic";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
|
||||
|
||||
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
|
||||
const actor = w.actors.get(actorId);
|
||||
@@ -22,11 +24,59 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
||||
}
|
||||
|
||||
// Spend energy for any action (move/wait/attack)
|
||||
actor.energy -= ACTION_COST;
|
||||
actor.energy -= GAME_CONFIG.gameplay.actionCost;
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function handleExpCollection(w: World, player: Actor, events: SimEvent[]) {
|
||||
const orbs = [...w.actors.values()].filter(a => a.type === "exp_orb" && a.pos.x === player.pos.x && a.pos.y === player.pos.y);
|
||||
|
||||
for (const orb of orbs) {
|
||||
const amount = (orb as any).expAmount || 0;
|
||||
if (player.stats) {
|
||||
player.stats.exp += amount;
|
||||
events.push({
|
||||
type: "exp-collected",
|
||||
actorId: player.id,
|
||||
amount,
|
||||
x: player.pos.x,
|
||||
y: player.pos.y
|
||||
});
|
||||
|
||||
checkLevelUp(player, events);
|
||||
}
|
||||
w.actors.delete(orb.id);
|
||||
}
|
||||
}
|
||||
|
||||
function checkLevelUp(player: Actor, events: SimEvent[]) {
|
||||
if (!player.stats) return;
|
||||
const s = player.stats;
|
||||
|
||||
while (s.exp >= s.expToNextLevel) {
|
||||
s.level++;
|
||||
s.exp -= s.expToNextLevel;
|
||||
|
||||
// Growth
|
||||
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
|
||||
s.hp = s.maxHp; // Heal on level up
|
||||
s.attack += GAME_CONFIG.leveling.attackGainPerLevel;
|
||||
|
||||
// Scale requirement
|
||||
s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier);
|
||||
|
||||
events.push({
|
||||
type: "leveled-up",
|
||||
actorId: player.id,
|
||||
level: s.level,
|
||||
x: player.pos.x,
|
||||
y: player.pos.y
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] {
|
||||
const from = { ...actor.pos };
|
||||
const nx = actor.pos.x + action.dx;
|
||||
@@ -36,12 +86,19 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }):
|
||||
actor.pos.x = nx;
|
||||
actor.pos.y = ny;
|
||||
const to = { ...actor.pos };
|
||||
return [{ type: "moved", actorId: actor.id, from, to }];
|
||||
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
||||
|
||||
if (actor.isPlayer) {
|
||||
handleExpCollection(w, actor, events);
|
||||
}
|
||||
|
||||
return events;
|
||||
} else {
|
||||
return [{ type: "waited", actorId: actor.id }];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] {
|
||||
const target = w.actors.get(action.targetId);
|
||||
if (target && target.stats && actor.stats) {
|
||||
@@ -69,12 +126,29 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
|
||||
victimType: target.type
|
||||
});
|
||||
w.actors.delete(target.id);
|
||||
|
||||
// Spawn EXP Orb
|
||||
const expAmount = target.type === "rat" ? GAME_CONFIG.enemy.ratExp : GAME_CONFIG.enemy.batExp;
|
||||
const orbId = Math.max(0, ...w.actors.keys(), target.id) + 1;
|
||||
w.actors.set(orbId, {
|
||||
|
||||
id: orbId,
|
||||
isPlayer: false,
|
||||
type: "exp_orb",
|
||||
pos: { ...target.pos },
|
||||
speed: 0,
|
||||
energy: 0,
|
||||
expAmount // Hidden property for simulation
|
||||
} as any);
|
||||
|
||||
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
return [{ type: "waited", actorId: actor.id }];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Very basic enemy AI:
|
||||
* - if adjacent to player, attack
|
||||
@@ -120,11 +194,12 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla
|
||||
const events: SimEvent[] = [];
|
||||
|
||||
while (true) {
|
||||
while (![...w.actors.values()].some(a => a.energy >= ENERGY_THRESHOLD)) {
|
||||
while (![...w.actors.values()].some(a => a.energy >= GAME_CONFIG.gameplay.energyThreshold)) {
|
||||
for (const a of w.actors.values()) a.energy += a.speed;
|
||||
}
|
||||
|
||||
const ready = [...w.actors.values()].filter(a => a.energy >= ENERGY_THRESHOLD);
|
||||
const ready = [...w.actors.values()].filter(a => a.energy >= GAME_CONFIG.gameplay.energyThreshold);
|
||||
|
||||
ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id));
|
||||
const actor = ready[0];
|
||||
|
||||
|
||||
@@ -12,33 +12,27 @@ interface Room {
|
||||
|
||||
/**
|
||||
* Generates a procedural dungeon world with rooms and corridors
|
||||
* @param level The level number (affects difficulty and randomness seed)
|
||||
* @param runState Player's persistent state across levels
|
||||
* @param floor The floor number (affects difficulty)
|
||||
* @param runState Player's persistent state across floors
|
||||
* @returns Generated world and player ID
|
||||
*/
|
||||
export function generateWorld(level: number, runState: RunState): { world: World; playerId: EntityId } {
|
||||
export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId } {
|
||||
const width = GAME_CONFIG.map.width;
|
||||
const height = GAME_CONFIG.map.height;
|
||||
const tiles: Tile[] = new Array(width * height).fill(GAME_CONFIG.terrain.wall); // Start with all walls
|
||||
const tiles: Tile[] = new Array(width * height).fill(GAME_CONFIG.terrain.wall);
|
||||
|
||||
const random = seededRandom(floor * 12345);
|
||||
|
||||
const random = seededRandom(level * 12345);
|
||||
|
||||
const rooms = generateRooms(width, height, tiles, random);
|
||||
|
||||
// Place player in first room
|
||||
const firstRoom = rooms[0];
|
||||
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
|
||||
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
|
||||
|
||||
// Place exit in last room
|
||||
const lastRoom = rooms[rooms.length - 1];
|
||||
const exitX = lastRoom.x + Math.floor(lastRoom.width / 2);
|
||||
const exitY = lastRoom.y + Math.floor(lastRoom.height / 2);
|
||||
const exit: Vec2 = { x: exitX, y: exitY };
|
||||
|
||||
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
|
||||
const playerId = 1;
|
||||
|
||||
actors.set(playerId, {
|
||||
id: playerId,
|
||||
isPlayer: true,
|
||||
@@ -50,13 +44,23 @@ export function generateWorld(level: number, runState: RunState): { world: World
|
||||
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
|
||||
});
|
||||
|
||||
placeEnemies(level, rooms, actors, random);
|
||||
// Place exit in last room
|
||||
const lastRoom = rooms[rooms.length - 1];
|
||||
const exit: Vec2 = {
|
||||
x: lastRoom.x + Math.floor(lastRoom.width / 2),
|
||||
y: lastRoom.y + Math.floor(lastRoom.height / 2)
|
||||
};
|
||||
|
||||
placeEnemies(floor, rooms, actors, random);
|
||||
decorate(width, height, tiles, random, exit);
|
||||
|
||||
return { world: { width, height, tiles, actors, exit }, playerId };
|
||||
|
||||
return {
|
||||
world: { width, height, tiles, actors, exit },
|
||||
playerId
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] {
|
||||
const rooms: Room[] = [];
|
||||
const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1));
|
||||
@@ -204,9 +208,9 @@ function generatePatch(width: number, height: number, fillChance: number, iterat
|
||||
return map;
|
||||
}
|
||||
|
||||
function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
||||
function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
||||
let enemyId = 2;
|
||||
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
|
||||
const numEnemies = GAME_CONFIG.enemy.baseCount + floor * GAME_CONFIG.enemy.baseCountPerFloor; // Simplified for now
|
||||
|
||||
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
|
||||
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||
@@ -215,8 +219,8 @@ function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>
|
||||
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||
|
||||
const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel;
|
||||
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels;
|
||||
const baseHp = GAME_CONFIG.enemy.baseHp + floor * GAME_CONFIG.enemy.baseHpPerFloor;
|
||||
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemy.attackPerTwoFloors;
|
||||
|
||||
actors.set(enemyId, {
|
||||
id: enemyId,
|
||||
@@ -229,11 +233,15 @@ function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>
|
||||
maxHp: baseHp + Math.floor(random() * 4),
|
||||
hp: baseHp + Math.floor(random() * 4),
|
||||
attack: baseAttack + Math.floor(random() * 2),
|
||||
defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1))
|
||||
defense: 0,
|
||||
level: 0,
|
||||
exp: 0,
|
||||
expToNextLevel: 0
|
||||
}
|
||||
});
|
||||
enemyId++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const makeTestWorld = generateWorld;
|
||||
|
||||
@@ -19,11 +19,12 @@ export function isBlocked(w: World, x: number, y: number): boolean {
|
||||
if (isWall(w, x, y)) return true;
|
||||
|
||||
for (const a of w.actors.values()) {
|
||||
if (a.pos.x === x && a.pos.y === y) return true;
|
||||
if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
||||
const p = w.actors.get(playerId);
|
||||
if (!p) return false;
|
||||
|
||||
Reference in New Issue
Block a user