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,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Aether Chronicles</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container">
|
||||
<canvas id="game-canvas" width="960" height="640"></canvas>
|
||||
|
||||
<!-- UI Overlay -->
|
||||
<div id="ui-overlay">
|
||||
<!-- HP/SP Bars -->
|
||||
<div id="status-bars">
|
||||
<div class="bar-row">
|
||||
<span class="bar-label">HP</span>
|
||||
<div class="bar-container">
|
||||
<div id="hp-bar" class="bar-fill hp-fill"></div>
|
||||
<span id="hp-text" class="bar-text">100/100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-row">
|
||||
<span class="bar-label">SP</span>
|
||||
<div class="bar-container">
|
||||
<div id="sp-bar" class="bar-fill sp-fill"></div>
|
||||
<span id="sp-text" class="bar-text">50/50</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-row">
|
||||
<span class="bar-label">EXP</span>
|
||||
<div class="bar-container">
|
||||
<div id="exp-bar" class="bar-fill exp-fill"></div>
|
||||
<span id="exp-text" class="bar-text">0/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skill Bar -->
|
||||
<div id="skill-bar">
|
||||
<div class="skill-slot" data-slot="1">1</div>
|
||||
<div class="skill-slot" data-slot="2">2</div>
|
||||
<div class="skill-slot" data-slot="3">3</div>
|
||||
<div class="skill-slot" data-slot="4">4</div>
|
||||
<div class="skill-slot" data-slot="5">5</div>
|
||||
<div class="skill-slot" data-slot="6">6</div>
|
||||
<div class="skill-slot" data-slot="7">7</div>
|
||||
<div class="skill-slot" data-slot="8">8</div>
|
||||
<div class="skill-slot" data-slot="9">9</div>
|
||||
<div class="skill-slot" data-slot="0">0</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimap -->
|
||||
<div id="minimap-container">
|
||||
<canvas id="minimap" width="120" height="120"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Chat / Event Log -->
|
||||
<div id="event-log">
|
||||
<div id="event-messages"></div>
|
||||
</div>
|
||||
|
||||
<!-- Character Info -->
|
||||
<div id="char-info">
|
||||
<div id="char-name">Wanderer</div>
|
||||
<div id="char-level">Lv. 1</div>
|
||||
<div id="char-class">Wanderer</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/tilemap.js"></script>
|
||||
<script src="js/camera.js"></script>
|
||||
<script src="js/player.js"></script>
|
||||
<script src="js/input.js"></script>
|
||||
<script src="js/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/* Aether Chronicles — Stylesheet */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Segoe UI', Tahoma, sans-serif;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#game-container {
|
||||
position: relative;
|
||||
width: 960px;
|
||||
height: 640px;
|
||||
margin: 0 auto;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
#game-canvas {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
#ui-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Status Bars */
|
||||
#status-bars {
|
||||
position: absolute;
|
||||
top: 10px; left: 10px;
|
||||
width: 200px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
width: 30px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #aaa;
|
||||
text-align: right;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.hp-fill { background: linear-gradient(to right, #c44, #e55); }
|
||||
.sp-fill { background: linear-gradient(to right, #44c, #66e); }
|
||||
.exp-fill { background: linear-gradient(to right, #cc4, #ee5); }
|
||||
|
||||
.bar-text {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 1px #000;
|
||||
}
|
||||
|
||||
/* Skill Bar */
|
||||
#skill-bar {
|
||||
position: absolute;
|
||||
bottom: 10px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.skill-slot {
|
||||
width: 40px; height: 40px;
|
||||
background: rgba(20,20,30,0.85);
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.skill-slot:hover {
|
||||
border-color: #88aaff;
|
||||
background: rgba(40,40,60,0.9);
|
||||
}
|
||||
|
||||
.skill-slot.active {
|
||||
border-color: #ffcc44;
|
||||
background: rgba(60,50,20,0.9);
|
||||
}
|
||||
|
||||
.skill-slot.on-cooldown {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Minimap */
|
||||
#minimap-container {
|
||||
position: absolute;
|
||||
top: 10px; right: 10px;
|
||||
width: 120px; height: 120px;
|
||||
border: 1px solid #555;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#minimap { display: block; }
|
||||
|
||||
/* Event Log */
|
||||
#event-log {
|
||||
position: absolute;
|
||||
bottom: 60px; left: 10px;
|
||||
width: 300px; height: 100px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#event-messages {
|
||||
padding: 5px;
|
||||
font-size: 12px;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-msg {
|
||||
margin-bottom: 2px;
|
||||
color: #ccc;
|
||||
}
|
||||
.event-msg.system { color: #ffcc44; }
|
||||
.event-msg.combat { color: #ff6666; }
|
||||
.event-msg.quest { color: #66ff66; }
|
||||
.event-msg.llm { color: #aa88ff; }
|
||||
|
||||
/* Character Info */
|
||||
#char-info {
|
||||
position: absolute;
|
||||
top: 10px; left: 220px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#char-name { font-size: 14px; font-weight: bold; color: #ffcc44; }
|
||||
#char-level { font-size: 12px; color: #aaa; }
|
||||
#char-class { font-size: 11px; color: #88aaff; }
|
||||
Reference in New Issue
Block a user