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:
arch_agent
2026-07-17 20:14:16 +02:00
commit 5cf42a5e5d
12 changed files with 2038 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
// camera.js — Camera follows player
class Camera {
constructor() {
this.x = 0;
this.y = 0;
this.targetX = 0;
this.targetY = 0;
this.smoothing = 0.1;
}
follow(targetX, targetY) {
this.targetX = targetX;
this.targetY = targetY;
}
update() {
this.x += (this.targetX - this.x) * this.smoothing;
this.y += (this.targetY - this.y) * this.smoothing;
}
}
+444
View File
@@ -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();
});
+85
View File
@@ -0,0 +1,85 @@
// input.js — Mouse and keyboard input handling
class InputHandler {
constructor(canvas, game) {
this.canvas = canvas;
this.game = game;
this.mouseX = 0;
this.mouseY = 0;
canvas.addEventListener('click', (e) => this.handleClick(e));
canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
document.addEventListener('keydown', (e) => this.handleKey(e));
}
handleClick(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Convert screen to world coords
const worldX = mx + this.game.camera.x - 480;
const worldY = my + this.game.camera.y - 320;
// Convert to tile coords
const tile = this.game.map.screenToTile(worldX, worldY);
// Check if clicking a monster
for (const monster of this.game.monsters) {
if (monster.tileX === tile.x && monster.tileY === tile.y) {
this.game.player.attackTarget = monster;
this.game.addEvent(`Targeting ${monster.name}`, 'combat');
return;
}
}
// Check if clicking an NPC
for (const npc of this.game.npcs) {
if (npc.tileX === tile.x && npc.tileY === tile.y) {
this.game.interactNPC(npc);
return;
}
}
// Otherwise, move to clicked tile
if (!this.game.map.isSolid(tile.x, tile.y)) {
const playerTile = { x: Math.floor(this.game.player.x), y: Math.floor(this.game.player.y) };
const path = this.game.findPath(playerTile, tile);
if (path) {
this.game.player.setPath(path);
this.game.player.attackTarget = null;
}
}
}
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
this.mouseX = e.clientX - rect.left;
this.mouseY = e.clientY - rect.top;
}
handleKey(e) {
// Skill bar (1-0)
if (e.key >= '0' && e.key <= '9') {
const slot = e.key === '0' ? 10 : parseInt(e.key);
this.game.useSkill(slot);
}
// Other keys
switch(e.key) {
case 'i':
case 'I':
this.game.toggleInventory();
break;
case 'q':
case 'Q':
this.game.toggleQuestLog();
break;
case 'Escape':
this.game.closeMenus();
break;
}
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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;
+261
View File
@@ -0,0 +1,261 @@
// tilemap.js — Isometric Tilemap System
const TILE_SIZE = 32;
const TILE_WIDTH = 64; // iso tile width
const TILE_HEIGHT = 32; // iso tile height
// Tile types
const TILES = {
GRASS: 0,
PATH: 1,
WATER: 2,
WALL: 3,
SAND: 4,
FLOWER: 5,
TREE: 6,
BUILDING: 7,
BRIDGE: 8,
DARK_GRASS: 9,
};
// Tile colors (procedural, no sprites needed for prototype)
const TILE_COLORS = {
[TILES.GRASS]: '#3a6b2a',
[TILES.PATH]: '#8a7a5a',
[TILES.WATER]: '#2a5a8a',
[TILES.WALL]: '#555555',
[TILES.SAND]: '#c4b07a',
[TILES.FLOWER]: '#3a6b2a',
[TILES.TREE]: '#2a4a1a',
[TILES.BUILDING]: '#6a5a4a',
[TILES.BRIDGE]: '#8a6a4a',
[TILES.DARK_GRASS]: '#2a5b1a',
};
// Tile properties
const TILE_SOLID = {
[TILES.GRASS]: false,
[TILES.PATH]: false,
[TILES.WATER]: true,
[TILES.WALL]: true,
[TILES.SAND]: false,
[TILES.FLOWER]: false,
[TILES.TREE]: true,
[TILES.BUILDING]: true,
[TILES.BRIDGE]: false,
[TILES.DARK_GRASS]: false,
};
class TileMap {
constructor(width, height) {
this.width = width;
this.height = height;
this.data = new Array(width * height).fill(TILES.GRASS);
}
getTile(x, y) {
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return TILES.WALL;
return this.data[y * this.width + x];
}
setTile(x, y, tile) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.data[y * this.width + x] = tile;
}
}
isSolid(x, y) {
return TILE_SOLID[this.getTile(x, y)] || false;
}
// Convert tile coords to screen coords (isometric)
tileToScreen(tx, ty) {
return {
x: (tx - ty) * (TILE_WIDTH / 2),
y: (tx + ty) * (TILE_HEIGHT / 2)
};
}
// Convert screen coords to tile coords
screenToTile(sx, sy) {
return {
x: Math.floor((sx / (TILE_WIDTH / 2) + sy / (TILE_HEIGHT / 2)) / 2),
y: Math.floor((sy / (TILE_HEIGHT / 2) - sx / (TILE_WIDTH / 2)) / 2)
};
}
// Generate Eichenhafen (starting city)
static generateEichenhafen() {
const map = new TileMap(40, 40);
// Fill with grass
for (let i = 0; i < map.data.length; i++) map.data[i] = TILES.GRASS;
// Water on the left (harbor)
for (let y = 0; y < 40; y++) {
for (let x = 0; x < 8; x++) {
map.setTile(x, y, TILES.WATER);
}
}
// Sand beach
for (let y = 0; y < 40; y++) {
map.setTile(8, y, TILES.SAND);
map.setTile(9, y, TILES.SAND);
}
// Main path (horizontal)
for (let x = 10; x < 35; x++) {
map.setTile(x, 20, TILES.PATH);
map.setTile(x, 21, TILES.PATH);
}
// Main path (vertical)
for (let y = 5; y < 35; y++) {
map.setTile(20, y, TILES.PATH);
map.setTile(21, y, TILES.PATH);
}
// Buildings (NPC areas)
for (let x = 14; x < 18; x++) {
for (let y = 14; y < 18; y++) {
map.setTile(x, y, TILES.BUILDING);
}
}
for (let x = 24; x < 28; x++) {
for (let y = 14; y < 18; y++) {
map.setTile(x, y, TILES.BUILDING);
}
}
for (let x = 14; x < 18; x++) {
for (let y = 24; y < 28; y++) {
map.setTile(x, y, TILES.BUILDING);
}
}
// Trees scattered
const treePositions = [
[12,5],[13,6],[11,7],[30,8],[31,9],[33,10],
[5,30],[33,30],[34,35],[12,35],[13,36],[30,35],
[25,30],[26,31],[15,32],[16,33]
];
treePositions.forEach(([x,y]) => map.setTile(x, y, TILES.TREE));
// Flowers
for (let i = 0; i < 30; i++) {
const x = 10 + Math.floor(Math.random() * 28);
const y = 5 + Math.floor(Math.random() * 30);
if (map.getTile(x, y) === TILES.GRASS) {
map.setTile(x, y, TILES.FLOWER);
}
}
// Dark grass patches
for (let i = 0; i < 15; i++) {
const x = 10 + Math.floor(Math.random() * 28);
const y = 5 + Math.floor(Math.random() * 30);
if (map.getTile(x, y) === TILES.GRASS) {
map.setTile(x, y, TILES.DARK_GRASS);
}
}
return map;
}
// Render the tilemap
render(ctx, camera) {
const startX = Math.max(0, Math.floor((camera.x - 480) / (TILE_WIDTH / 2)) - 2);
const startY = Math.max(0, Math.floor((camera.y - 320) / TILE_HEIGHT) - 2);
const endX = Math.min(this.width, Math.ceil((camera.x + 480) / (TILE_WIDTH / 2)) + 2);
const endY = Math.min(this.height, Math.ceil((camera.y + 320) / TILE_HEIGHT) + 2);
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
const tile = this.getTile(x, y);
const screen = this.tileToScreen(x, y);
const drawX = screen.x - camera.x + 480;
const drawY = screen.y - camera.y + 320;
// Draw isometric diamond
ctx.fillStyle = TILE_COLORS[tile];
ctx.beginPath();
ctx.moveTo(drawX, drawY);
ctx.lineTo(drawX + TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2);
ctx.lineTo(drawX, drawY + TILE_HEIGHT);
ctx.lineTo(drawX - TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2);
ctx.closePath();
ctx.fill();
// Tile details
if (tile === TILES.WATER) {
ctx.strokeStyle = '#3a7a9a';
ctx.lineWidth = 1;
ctx.stroke();
} else if (tile === TILES.TREE) {
// Draw tree on top of grass base
ctx.fillStyle = TILE_COLORS[TILES.GRASS];
ctx.beginPath();
ctx.moveTo(drawX, drawY);
ctx.lineTo(drawX + TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2);
ctx.lineTo(drawX, drawY + TILE_HEIGHT);
ctx.lineTo(drawX - TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2);
ctx.closePath();
ctx.fill();
// Tree trunk + canopy
ctx.fillStyle = '#4a3a2a';
ctx.fillRect(drawX - 3, drawY + TILE_HEIGHT/2 - 5, 6, 10);
ctx.fillStyle = '#1a3a0a';
ctx.beginPath();
ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 8, 12, 0, Math.PI * 2);
ctx.fill();
} else if (tile === TILES.FLOWER) {
// Grass base + flower dot
ctx.fillStyle = TILE_COLORS[TILES.GRASS];
ctx.beginPath();
ctx.moveTo(drawX, drawY);
ctx.lineTo(drawX + TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2);
ctx.lineTo(drawX, drawY + TILE_HEIGHT);
ctx.lineTo(drawX - TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2);
ctx.closePath();
ctx.fill();
const colors = ['#ff6b6b', '#ffd93d', '#6bcf7f', '#a78bfa'];
ctx.fillStyle = colors[(x + y) % colors.length];
ctx.beginPath();
ctx.arc(drawX, drawY + TILE_HEIGHT/2, 3, 0, Math.PI * 2);
ctx.fill();
} else if (tile === TILES.BUILDING) {
// Building with roof
ctx.fillStyle = '#5a4a3a';
ctx.fillRect(drawX - TILE_WIDTH/2 + 2, drawY + 4, TILE_WIDTH - 4, TILE_HEIGHT);
ctx.fillStyle = '#8a3a2a';
ctx.beginPath();
ctx.moveTo(drawX - TILE_WIDTH/2 + 2, drawY + 8);
ctx.lineTo(drawX, drawY);
ctx.lineTo(drawX + TILE_WIDTH/2 - 2, drawY + 8);
ctx.lineTo(drawX, drawY + 16);
ctx.closePath();
ctx.fill();
}
}
}
}
// Render minimap
renderMinimap(ctx, player) {
const scale = 3;
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const tile = this.getTile(x, y);
ctx.fillStyle = TILE_COLORS[tile];
ctx.fillRect(x * scale, y * scale, scale, scale);
}
}
// Player dot
ctx.fillStyle = '#ffcc44';
ctx.beginPath();
ctx.arc(player.tileX * scale, player.tileY * scale, 2, 0, Math.PI * 2);
ctx.fill();
}
}