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
+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();
}
}