5cf42a5e5d
Frontend: - HTML5 Canvas isometric tilemap renderer - Eichenhafen starting city (procedural) - Click-to-move A* pathfinding - Player stats, HP/SP/EXP bars, skill bar, minimap - 3 NPCs (merchant, class master, innkeeper) - 6 monster spawns with aggro AI - Combat: auto-attack, damage numbers, EXP/gold - Player death + respawn Backend: - FastAPI server with SQLite - Ollama LLM bridge for events + dialogue - Event validation (no soft-lock) - NPC dialogue generation Design: - Full DESIGN.md with architecture, formulas, milestones - 6 base classes + 6 advanced classes - 3 fixed cities, dynamic events
196 lines
5.4 KiB
JavaScript
196 lines
5.4 KiB
JavaScript
// player.js — Player character with stats
|
|
|
|
class Player {
|
|
constructor(x, y) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.tileX = 0;
|
|
this.tileY = 0;
|
|
this.speed = 2;
|
|
|
|
// Stats
|
|
this.name = 'Wanderer';
|
|
this.class = 'wanderer';
|
|
this.level = 1;
|
|
this.exp = 0;
|
|
this.expNeeded = 100;
|
|
|
|
this.maxHp = 100;
|
|
this.hp = 100;
|
|
this.maxSp = 50;
|
|
this.sp = 50;
|
|
|
|
this.str = 10;
|
|
this.agi = 10;
|
|
this.int = 10;
|
|
this.vit = 10;
|
|
this.dex = 10;
|
|
this.luk = 5;
|
|
|
|
this.atk = 15;
|
|
this.def = 5;
|
|
this.gold = 100;
|
|
|
|
// Movement
|
|
this.path = [];
|
|
this.targetX = null;
|
|
this.targetY = null;
|
|
this.moving = false;
|
|
|
|
// Combat
|
|
this.attackTarget = null;
|
|
this.attackRange = 1.5;
|
|
this.attackCooldown = 0;
|
|
this.attackSpeed = 30; // frames between attacks
|
|
|
|
// Visual
|
|
this.facing = 'down';
|
|
this.animFrame = 0;
|
|
this.animTimer = 0;
|
|
}
|
|
|
|
getScreenPos(map) {
|
|
const screen = map.tileToScreen(Math.floor(this.x), Math.floor(this.y));
|
|
return screen;
|
|
}
|
|
|
|
update(map) {
|
|
// Update tile position
|
|
this.tileX = Math.floor(this.x);
|
|
this.tileY = Math.floor(this.y);
|
|
|
|
// Movement along path
|
|
if (this.path.length > 0) {
|
|
const next = this.path[0];
|
|
const dx = next.x - this.x;
|
|
const dy = next.y - this.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist < 0.1) {
|
|
this.path.shift();
|
|
if (this.path.length === 0) {
|
|
this.moving = false;
|
|
}
|
|
} else {
|
|
this.x += (dx / dist) * this.speed * 0.1;
|
|
this.y += (dy / dist) * this.speed * 0.1;
|
|
|
|
if (Math.abs(dx) > Math.abs(dy)) {
|
|
this.facing = dx > 0 ? 'right' : 'left';
|
|
} else {
|
|
this.facing = dy > 0 ? 'down' : 'up';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Animation
|
|
if (this.moving) {
|
|
this.animTimer++;
|
|
if (this.animTimer > 10) {
|
|
this.animTimer = 0;
|
|
this.animFrame = (this.animFrame + 1) % 4;
|
|
}
|
|
}
|
|
|
|
// Combat
|
|
if (this.attackCooldown > 0) this.attackCooldown--;
|
|
}
|
|
|
|
setPath(path) {
|
|
this.path = path;
|
|
this.moving = path.length > 0;
|
|
}
|
|
|
|
takeDamage(amount) {
|
|
this.hp -= amount;
|
|
if (this.hp < 0) this.hp = 0;
|
|
return this.hp <= 0;
|
|
}
|
|
|
|
heal(amount) {
|
|
this.hp = Math.min(this.maxHp, this.hp + amount);
|
|
}
|
|
|
|
gainExp(amount) {
|
|
this.exp += amount;
|
|
if (this.exp >= this.expNeeded) {
|
|
this.levelUp();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
levelUp() {
|
|
this.exp -= this.expNeeded;
|
|
this.level++;
|
|
this.expNeeded = Math.floor(100 * Math.pow(this.level, 1.5));
|
|
this.maxHp += 20;
|
|
this.maxSp += 10;
|
|
this.hp = this.maxHp;
|
|
this.sp = this.maxSp;
|
|
this.str += 2;
|
|
this.agi += 2;
|
|
this.int += 1;
|
|
this.vit += 2;
|
|
this.dex += 1;
|
|
this.atk += 3;
|
|
this.def += 1;
|
|
}
|
|
|
|
render(ctx, map, camera) {
|
|
const screen = map.tileToScreen(this.x, this.y);
|
|
const drawX = screen.x - camera.x + 480;
|
|
const drawY = screen.y - camera.y + 320;
|
|
|
|
// Shadow
|
|
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
|
ctx.beginPath();
|
|
ctx.ellipse(drawX, drawY + TILE_HEIGHT/2 + 4, 12, 4, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Body (simple character)
|
|
const bobOffset = this.moving ? Math.sin(this.animFrame * Math.PI / 2) * 2 : 0;
|
|
|
|
// Legs
|
|
ctx.fillStyle = '#4a4a6a';
|
|
ctx.fillRect(drawX - 5, drawY + TILE_HEIGHT/2 - 8 + bobOffset, 4, 8);
|
|
ctx.fillRect(drawX + 1, drawY + TILE_HEIGHT/2 - 8 + bobOffset, 4, 8);
|
|
|
|
// Body
|
|
ctx.fillStyle = '#5566aa';
|
|
ctx.fillRect(drawX - 7, drawY + TILE_HEIGHT/2 - 18 + bobOffset, 14, 12);
|
|
|
|
// Head
|
|
ctx.fillStyle = '#e0b890';
|
|
ctx.beginPath();
|
|
ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 22 + bobOffset, 7, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Hair (black)
|
|
ctx.fillStyle = '#1a1a1a';
|
|
ctx.beginPath();
|
|
ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 24 + bobOffset, 7, Math.PI, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillRect(drawX - 7, drawY + TILE_HEIGHT/2 - 22 + bobOffset, 14, 3);
|
|
|
|
// Name label
|
|
ctx.fillStyle = '#ffcc44';
|
|
ctx.font = '11px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(this.name, drawX, drawY + TILE_HEIGHT/2 - 32);
|
|
|
|
// HP bar above character
|
|
const hpPercent = this.hp / this.maxHp;
|
|
const barWidth = 24;
|
|
const barX = drawX - barWidth / 2;
|
|
const barY = drawY + TILE_HEIGHT/2 - 38;
|
|
|
|
ctx.fillStyle = '#333';
|
|
ctx.fillRect(barX, barY, barWidth, 3);
|
|
ctx.fillStyle = hpPercent > 0.5 ? '#44dd44' : hpPercent > 0.25 ? '#ddaa44' : '#dd4444';
|
|
ctx.fillRect(barX, barY, barWidth * hpPercent, 3);
|
|
}
|
|
}
|
|
|
|
// TILE_WIDTH constant for player rendering
|
|
const TILE_HEIGHT = 32; |