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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user