v0.1.0 — M1: Project Setup + Tilemap + Combat + Backend
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
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
// game.js — Main Game Loop
|
||||
|
||||
const TILE_WIDTH = 64;
|
||||
const TILE_HEIGHT = 32;
|
||||
|
||||
class Game {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('game-canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.minimapCanvas = document.getElementById('minimap');
|
||||
this.minimapCtx = this.minimapCanvas.getContext('2d');
|
||||
|
||||
this.camera = new Camera();
|
||||
this.map = TileMap.generateEichenhafen();
|
||||
this.player = new Player(20, 20);
|
||||
this.monsters = [];
|
||||
this.npcs = [];
|
||||
this.damageNumbers = [];
|
||||
this.events = [];
|
||||
|
||||
// Spawn some NPCs
|
||||
this.spawnNPCs();
|
||||
|
||||
// Spawn some monsters outside the city
|
||||
this.spawnMonsters();
|
||||
|
||||
this.input = new InputHandler(this.canvas, this);
|
||||
|
||||
this.running = true;
|
||||
this.frameCount = 0;
|
||||
|
||||
this.updateUI();
|
||||
this.addEvent('Willkommen in Eichenhafen!', 'system');
|
||||
this.addEvent('Klicke um dich zu bewegen. Klicke auf Monster zum angreifen.', 'system');
|
||||
|
||||
this.loop();
|
||||
}
|
||||
|
||||
spawnNPCs() {
|
||||
// NPC near the first building
|
||||
this.npcs.push({
|
||||
id: 'merchant_eichenhafen',
|
||||
name: 'Händler Bruno',
|
||||
tileX: 22,
|
||||
tileY: 19,
|
||||
type: 'merchant',
|
||||
dialogue: 'Willkommen! Schau dich in meinem Laden um.'
|
||||
});
|
||||
// NPC near second building
|
||||
this.npcs.push({
|
||||
id: 'class_master',
|
||||
name: 'Meisterin Vera',
|
||||
tileX: 15,
|
||||
tileY: 19,
|
||||
type: 'class_master',
|
||||
dialogue: 'Wenn du Level 10 erreichst, kann ich dir eine Klasse zuweisen.'
|
||||
});
|
||||
// NPC near third building
|
||||
this.npcs.push({
|
||||
id: 'innkeeper',
|
||||
name: 'Wirtin Helga',
|
||||
tileX: 15,
|
||||
tileY: 29,
|
||||
type: 'innkeeper',
|
||||
dialogue: 'Ruhe dich aus. Deine Wunden heilen in der Schenke.'
|
||||
});
|
||||
}
|
||||
|
||||
spawnMonsters() {
|
||||
// Spawn wolves outside the city area
|
||||
const spawnArea = [
|
||||
{x: 30, y: 5}, {x: 32, y: 8}, {x: 28, y: 10},
|
||||
{x: 35, y: 15}, {x: 33, y: 25}, {x: 30, y: 30}
|
||||
];
|
||||
|
||||
for (const pos of spawnArea) {
|
||||
this.monsters.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: 'Junger Wolf',
|
||||
tileX: pos.x,
|
||||
tileY: pos.y,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
hp: 30,
|
||||
maxHp: 30,
|
||||
atk: 8,
|
||||
def: 2,
|
||||
level: 1,
|
||||
exp: 15,
|
||||
gold: 5,
|
||||
aggroRange: 4,
|
||||
attackCooldown: 0,
|
||||
attackSpeed: 45,
|
||||
alive: true,
|
||||
respawnTimer: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
findPath(start, end) {
|
||||
// Simple A* pathfinding
|
||||
const openSet = [{x: start.x, y: start.y, g: 0, h: 0, f: 0, parent: null}];
|
||||
const closedSet = [];
|
||||
const gridSize = this.map.width * this.map.height;
|
||||
|
||||
while (openSet.length > 0) {
|
||||
// Find lowest f
|
||||
openSet.sort((a, b) => a.f - b.f);
|
||||
const current = openSet.shift();
|
||||
|
||||
if (current.x === end.x && current.y === end.y) {
|
||||
// Reconstruct path
|
||||
const path = [];
|
||||
let node = current;
|
||||
while (node.parent) {
|
||||
path.unshift({x: node.x, y: node.y});
|
||||
node = node.parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
closedSet.push(current);
|
||||
|
||||
// Check neighbors
|
||||
const neighbors = [
|
||||
{x: current.x + 1, y: current.y},
|
||||
{x: current.x - 1, y: current.y},
|
||||
{x: current.x, y: current.y + 1},
|
||||
{x: current.x, y: current.y - 1},
|
||||
// Diagonal
|
||||
{x: current.x + 1, y: current.y + 1},
|
||||
{x: current.x - 1, y: current.y - 1},
|
||||
{x: current.x + 1, y: current.y - 1},
|
||||
{x: current.x - 1, y: current.y + 1}
|
||||
];
|
||||
|
||||
for (const n of neighbors) {
|
||||
if (n.x < 0 || n.x >= this.map.width || n.y < 0 || n.y >= this.map.height) continue;
|
||||
if (this.map.isSolid(n.x, n.y)) continue;
|
||||
|
||||
// Skip if in closed set
|
||||
if (closedSet.find(c => c.x === n.x && c.y === n.y)) continue;
|
||||
|
||||
const g = current.g + 1;
|
||||
const h = Math.abs(n.x - end.x) + Math.abs(n.y - end.y);
|
||||
const f = g + h;
|
||||
|
||||
// Skip if already in open set with lower f
|
||||
const existing = openSet.find(o => o.x === n.x && o.y === n.y);
|
||||
if (existing && existing.f <= f) continue;
|
||||
|
||||
openSet.push({x: n.x, y: n.y, g, h, f, parent: current});
|
||||
}
|
||||
|
||||
// Prevent infinite loop
|
||||
if (closedSet.length > 500) break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.frameCount++;
|
||||
|
||||
// Update player
|
||||
this.player.update(this.map);
|
||||
|
||||
// Camera follows player
|
||||
const playerScreen = this.map.tileToScreen(this.player.x, this.player.y);
|
||||
this.camera.follow(playerScreen.x - TILE_WIDTH/2, playerScreen.y - TILE_HEIGHT/2);
|
||||
this.camera.update();
|
||||
|
||||
// Update monsters
|
||||
for (const monster of this.monsters) {
|
||||
if (!monster.alive) {
|
||||
monster.respawnTimer--;
|
||||
if (monster.respawnTimer <= 0) {
|
||||
monster.alive = true;
|
||||
monster.hp = monster.maxHp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aggro check
|
||||
const dx = this.player.x - monster.x;
|
||||
const dy = this.player.y - monster.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < monster.aggroRange) {
|
||||
// Chase player
|
||||
if (dist > 1.2) {
|
||||
monster.x += (dx / dist) * 0.05;
|
||||
monster.y += (dy / dist) * 0.05;
|
||||
monster.tileX = Math.floor(monster.x);
|
||||
monster.tileY = Math.floor(monster.y);
|
||||
} else {
|
||||
// Attack player
|
||||
if (monster.attackCooldown <= 0) {
|
||||
const damage = Math.max(1, monster.atk - this.player.def);
|
||||
this.player.takeDamage(damage);
|
||||
this.addDamageNumber(this.player.x, this.player.y, damage, '#ff4444');
|
||||
this.addEvent(`Wolf greift an: -${damage} HP`, 'combat');
|
||||
monster.attackCooldown = monster.attackSpeed;
|
||||
|
||||
if (this.player.hp <= 0) {
|
||||
this.onPlayerDeath();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (monster.attackCooldown > 0) monster.attackCooldown--;
|
||||
}
|
||||
|
||||
// Player auto-attack
|
||||
if (this.player.attackTarget && this.player.attackTarget.alive) {
|
||||
const target = this.player.attackTarget;
|
||||
const dx = target.x - this.player.x;
|
||||
const dy = target.y - this.player.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist <= this.player.attackRange) {
|
||||
// In range — attack
|
||||
if (this.player.attackCooldown <= 0) {
|
||||
const damage = Math.max(1, this.player.atk - target.def);
|
||||
target.hp -= damage;
|
||||
this.addDamageNumber(target.x, target.y, damage, '#ffff44');
|
||||
this.addEvent(`Du trifst ${target.name}: -${damage} HP`, 'combat');
|
||||
this.player.attackCooldown = this.player.attackSpeed;
|
||||
|
||||
if (target.hp <= 0) {
|
||||
target.alive = false;
|
||||
target.respawnTimer = 300; // 5 seconds at 60fps
|
||||
this.player.gainExp(target.exp);
|
||||
this.player.gold += target.gold;
|
||||
this.addEvent(`${target.name} besiegt! +${target.exp} EXP, +${target.gold} Gold`, 'combat');
|
||||
this.player.attackTarget = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Move towards target
|
||||
const tile = {x: Math.floor(target.x), y: Math.floor(target.y)};
|
||||
const playerTile = {x: Math.floor(this.player.x), y: Math.floor(this.player.y)};
|
||||
const path = this.findPath(playerTile, tile);
|
||||
if (path) this.player.setPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Update damage numbers
|
||||
this.damageNumbers = this.damageNumbers.filter(d => {
|
||||
d.y -= 0.5;
|
||||
d.life--;
|
||||
return d.life > 0;
|
||||
});
|
||||
|
||||
// Update UI
|
||||
if (this.frameCount % 10 === 0) {
|
||||
this.updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Clear
|
||||
this.ctx.fillStyle = '#1a1a2e';
|
||||
this.ctx.fillRect(0, 0, 960, 640);
|
||||
|
||||
// Render map
|
||||
this.map.render(this.ctx, this.camera);
|
||||
|
||||
// Render NPCs
|
||||
for (const npc of this.npcs) {
|
||||
const screen = this.map.tileToScreen(npc.tileX, npc.tileY);
|
||||
const drawX = screen.x - this.camera.x + 480;
|
||||
const drawY = screen.y - this.camera.y + 320;
|
||||
|
||||
// NPC body (different colors by type)
|
||||
const colors = {
|
||||
merchant: '#4a8a4a',
|
||||
class_master: '#8a4a8a',
|
||||
innkeeper: '#8a8a4a'
|
||||
};
|
||||
ctx = this.ctx;
|
||||
ctx.fillStyle = colors[npc.type] || '#666';
|
||||
ctx.fillRect(drawX - 5, drawY + TILE_HEIGHT/2 - 15, 10, 12);
|
||||
ctx.fillStyle = '#e0b890';
|
||||
ctx.beginPath();
|
||||
ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 18, 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Name
|
||||
ctx.fillStyle = '#88ff88';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(npc.name, drawX, drawY + TILE_HEIGHT/2 - 26);
|
||||
|
||||
// "!" indicator for interactable
|
||||
ctx.fillStyle = '#ffcc44';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillText('!', drawX + 8, drawY + TILE_HEIGHT/2 - 22);
|
||||
}
|
||||
|
||||
// Render monsters
|
||||
for (const monster of this.monsters) {
|
||||
if (!monster.alive) continue;
|
||||
|
||||
const screen = this.map.tileToScreen(monster.x, monster.y);
|
||||
const drawX = screen.x - this.camera.x + 480;
|
||||
const drawY = screen.y - this.camera.y + 320;
|
||||
|
||||
// Shadow
|
||||
this.ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
this.ctx.beginPath();
|
||||
this.ctx.ellipse(drawX, drawY + TILE_HEIGHT/2 + 2, 8, 3, 0, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
|
||||
// Wolf body (gray)
|
||||
this.ctx.fillStyle = '#666';
|
||||
this.ctx.fillRect(drawX - 6, drawY + TILE_HEIGHT/2 - 10, 12, 8);
|
||||
this.ctx.fillStyle = '#555';
|
||||
ctx.beginPath();
|
||||
ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 12, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Eyes (red)
|
||||
this.ctx.fillStyle = '#ff3333';
|
||||
this.ctx.fillRect(drawX - 3, drawY + TILE_HEIGHT/2 - 13, 2, 2);
|
||||
this.ctx.fillRect(drawX + 1, drawY + TILE_HEIGHT/2 - 13, 2, 2);
|
||||
|
||||
// Name + HP bar
|
||||
this.ctx.fillStyle = '#ff8888';
|
||||
this.ctx.font = '9px sans-serif';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.fillText(monster.name, drawX, drawY + TILE_HEIGHT/2 - 22);
|
||||
|
||||
const hpPercent = monster.hp / monster.maxHp;
|
||||
this.ctx.fillStyle = '#333';
|
||||
this.ctx.fillRect(drawX - 12, drawY + TILE_HEIGHT/2 - 20, 24, 2);
|
||||
this.ctx.fillStyle = '#dd4444';
|
||||
this.ctx.fillRect(drawX - 12, drawY + TILE_HEIGHT/2 - 20, 24 * hpPercent, 2);
|
||||
}
|
||||
|
||||
// Render player
|
||||
this.player.render(this.ctx, this.map, this.camera);
|
||||
|
||||
// Render damage numbers
|
||||
for (const dmg of this.damageNumbers) {
|
||||
const screen = this.map.tileToScreen(dmg.x, dmg.y);
|
||||
const drawX = screen.x - this.camera.x + 480;
|
||||
const drawY = screen.y - this.camera.y + 320 - (30 - dmg.life);
|
||||
this.ctx.fillStyle = dmg.color;
|
||||
this.ctx.font = 'bold 14px sans-serif';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.globalAlpha = dmg.life / 30;
|
||||
this.ctx.fillText(dmg.value, drawX, drawY);
|
||||
this.ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Render minimap
|
||||
this.map.renderMinimap(this.minimapCtx, this.player);
|
||||
}
|
||||
|
||||
addDamageNumber(x, y, value, color) {
|
||||
this.damageNumbers.push({x, y, value: Math.floor(value), color, life: 30});
|
||||
}
|
||||
|
||||
addEvent(text, type = 'normal') {
|
||||
const messages = document.getElementById('event-messages');
|
||||
const div = document.createElement('div');
|
||||
div.className = `event-msg ${type}`;
|
||||
div.textContent = text;
|
||||
messages.appendChild(div);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
|
||||
// Keep only last 20 messages
|
||||
while (messages.children.length > 20) {
|
||||
messages.removeChild(messages.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
// HP
|
||||
const hpPercent = (this.player.hp / this.player.maxHp) * 100;
|
||||
document.getElementById('hp-bar').style.width = hpPercent + '%';
|
||||
document.getElementById('hp-text').textContent = `${this.player.hp}/${this.player.maxHp}`;
|
||||
|
||||
// SP
|
||||
const spPercent = (this.player.sp / this.player.maxSp) * 100;
|
||||
document.getElementById('sp-bar').style.width = spPercent + '%';
|
||||
document.getElementById('sp-text').textContent = `${this.player.sp}/${this.player.maxSp}`;
|
||||
|
||||
// EXP
|
||||
const expPercent = (this.player.exp / this.player.expNeeded) * 100;
|
||||
document.getElementById('exp-bar').style.width = expPercent + '%';
|
||||
document.getElementById('exp-text').textContent = `${this.player.exp}/${this.player.expNeeded}`;
|
||||
|
||||
// Char info
|
||||
document.getElementById('char-name').textContent = this.player.name;
|
||||
document.getElementById('char-level').textContent = `Lv. ${this.player.level}`;
|
||||
document.getElementById('char-class').textContent = this.player.class;
|
||||
}
|
||||
|
||||
interactNPC(npc) {
|
||||
this.addEvent(`${npc.name}: "${npc.dialogue}"`, 'quest');
|
||||
}
|
||||
|
||||
useSkill(slot) {
|
||||
this.addEvent(`Skill ${slot} (noch nicht implementiert)`, 'system');
|
||||
}
|
||||
|
||||
toggleInventory() {
|
||||
this.addEvent('Inventar (noch nicht implementiert)', 'system');
|
||||
}
|
||||
|
||||
toggleQuestLog() {
|
||||
this.addEvent('Quest-Log (noch nicht implementiert)', 'system');
|
||||
}
|
||||
|
||||
closeMenus() {
|
||||
// Close any open menus
|
||||
}
|
||||
|
||||
onPlayerDeath() {
|
||||
this.addEvent('Du bist gestorben! Respawne in Eichenhafen...', 'system');
|
||||
this.player.hp = this.player.maxHp;
|
||||
this.player.sp = this.player.maxSp;
|
||||
this.player.x = 20;
|
||||
this.player.y = 20;
|
||||
this.player.path = [];
|
||||
this.player.attackTarget = null;
|
||||
this.player.exp = Math.max(0, this.player.exp - this.player.expNeeded * 0.1);
|
||||
}
|
||||
|
||||
loop() {
|
||||
if (!this.running) return;
|
||||
this.update();
|
||||
this.render();
|
||||
requestAnimationFrame(() => this.loop());
|
||||
}
|
||||
}
|
||||
|
||||
// Start the game when page loads
|
||||
window.addEventListener('load', () => {
|
||||
new Game();
|
||||
});
|
||||
Reference in New Issue
Block a user