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
261 lines
8.7 KiB
JavaScript
261 lines
8.7 KiB
JavaScript
// 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();
|
|
}
|
|
} |