source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Wizrad Map + Card Roguelike</title>
<style>
html, body { margin: 0; padding: 0; background: #0e1116; color: #e6e6e6; font-family: monospace; overflow: hidden; }
canvas { display: block; }
#cardUiRoot {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: var(--card-ui-height, 250px);
background: rgb(30, 30, 50);
border-top: 1px solid rgb(70, 70, 100);
z-index: 10;
display: none;
}
#cardUiRoot.visible { display: block; }
.card-panel {
position: absolute;
top: 10px;
bottom: 10px;
border-radius: 8px;
background: rgb(40, 40, 60);
border: 2px solid rgb(200, 200, 220);
box-sizing: border-box;
padding: 10px;
}
#cardStatsPanel { left: 10px; width: var(--card-stats-width, 180px); border: 0; }
#cardEnemyPanel { right: 10px; width: var(--card-enemy-width, 180px); text-align: center; }
#cardHandPanel {
position: absolute;
left: calc(var(--card-stats-width, 180px) + 20px);
right: calc(var(--card-enemy-width, 180px) + 30px);
top: 20px;
display: flex;
gap: 10px;
align-items: flex-start;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
}
.card {
flex: 0 0 auto;
width: 140px;
height: 200px;
border-radius: 8px;
border: 2px solid rgb(200, 200, 220);
background: rgb(50, 50, 80);
color: #fff;
box-sizing: border-box;
padding: 8px;
cursor: pointer;
user-select: none;
font: inherit;
text-align: left;
}
.card:hover { background: rgb(70, 70, 100); }
.card.selected { background: rgb(100, 150, 255); }
.card.disabled {
opacity: 0.55;
cursor: default;
outline: 2px solid rgba(255, 0, 0, 0.35);
outline-offset: -2px;
}
.cardHeader { display: flex; justify-content: space-between; align-items: center; font-size: 12px; font-family: "Segoe UI Emoji", monospace; }
.cardEmoji { text-align: center; font-size: 56px; line-height: 1; margin-top: 18px; margin-bottom: 14px; font-family: "Segoe UI Emoji", sans-serif; }
.cardDesc { text-align: center; font-size: 11px; color: rgb(220, 220, 220); line-height: 1.25; }
.hidden { display: none; }
.card-help { margin-top: 10px; color: rgb(180, 180, 180); font-size: 10px; line-height: 1.5; }
.enemyEmoji { font-size: 48px; margin: 12px 0 10px 0; font-family: "Segoe UI Emoji", sans-serif; }
#gameCanvas {
position: fixed; left: 0; top: 0; width: 100%; height: 100%;
z-index: 1; pointer-events: auto;
}
#hudPanel {
position: fixed; left: 12px; top: 12px; z-index: 5;
padding: 10px; border-radius: 8px; background: rgba(0,0,0,0.67);
width: fit-content; max-width: 280px; font-size: 13px; line-height: 1.4;
}
.shop-btn { padding: 4px 10px; border-radius: 6px; background: rgb(68,86,110); color: #f0f0f0; cursor: pointer; border: none; font: inherit; font-size: 12px; }
.shop-btn:hover { background: rgb(80,98,125); }
#messageToast {
position: fixed; left: 12px; z-index: 8; padding: 11px 12px; border-radius: 8px;
background: rgba(30,25,55,0.86); font-size: 16px; max-width: 680px;
transition: bottom 0.2s; pointer-events: none;
}
#messageToast.hidden { display: none; }
#encounterReportOverlay {
position: fixed; left: 0; top: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.72); z-index: 20; display: flex; align-items: center; justify-content: center;
}
#encounterReportOverlay.hidden { display: none; }
#encounterReportPanel {
background: rgb(20,24,34); padding: 18px; border-radius: 12px;
max-width: 620px; max-height: 90vh; overflow-y: auto;
}
#encounterReportPanel h2 { margin: 0 0 12px 0; font-size: 24px; }
#encounterReportPanel .report-btn { padding: 8px 24px; border-radius: 8px; background: rgb(66,84,112); color: #f0f0f0; cursor: pointer; border: none; float: right; margin-top: 12px; }
#encounterReportPanel .report-btn:hover { background: rgb(80,100,130); }
#deckViewOverlay {
position: fixed; left: 0; top: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.72); z-index: 20; display: flex; align-items: center; justify-content: center;
}
#deckViewOverlay.hidden { display: none; }
#deckViewPanel {
background: rgb(20,24,34); padding: 18px; border-radius: 12px;
max-width: 90vw; max-height: 90vh; overflow-y: auto;
}
#deckViewPanel h2 { margin: 0 0 12px 0; font-size: 22px; }
#deckViewPanel .deck-grid {
display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0;
}
#deckViewPanel .deck-card {
width: 120px; min-height: 170px; padding: 8px; border-radius: 8px;
background: rgb(50,50,80); border: 2px solid rgb(200,200,220);
cursor: default; font-size: 11px; box-sizing: border-box;
}
#deckViewPanel .deck-card.removable { cursor: pointer; }
#deckViewPanel .deck-card.removable:hover { background: rgb(70,70,100); border-color: rgb(255,200,100); }
#deckViewPanel .deck-card .deck-card-emoji { font-size: 40px; text-align: center; margin: 8px 0; }
#deckViewPanel .deck-card .deck-card-count { font-size: 10px; color: rgb(180,180,180); margin-top: 4px; }
#deckViewPanel .deck-remove-hint { color: rgb(200,180,120); font-size: 12px; margin-top: 8px; }
</style>
</head>
<body>
<div id="hudPanel"></div>
<div id="messageToast" class="hidden"></div>
<div id="encounterReportOverlay" class="hidden">
<div id="encounterReportPanel"></div>
</div>
<div id="deckViewOverlay" class="hidden">
<div id="deckViewPanel"></div>
</div>
<canvas id="gameCanvas"></canvas>
<div id="cardUiRoot">
<div id="cardStatsPanel" class="card-panel"></div>
<div id="cardHandPanel"></div>
<div id="cardEnemyPanel" class="card-panel"></div>
</div>
<script>
"use strict";
/* ========== VANILLA UTILITIES (replace p5) ========== */
let randSeed = 12345;
function randomSeed(s) { randSeed = s >>> 0; }
function random() {
const r = () => (randSeed = (randSeed * 1103515245 + 12345) >>> 0) / 0x100000000;
if (arguments.length === 0) return r();
const a = arguments[0];
if (Array.isArray(a)) return a[Math.floor(r() * a.length)];
if (arguments.length === 1) return r() * a;
const lo = Math.min(a, arguments[1]), hi = Math.max(a, arguments[1]);
return lo + r() * (hi - lo);
}
function constrain(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function floor(n) { return Math.floor(n); }
function abs(n) { return Math.abs(n); }
function dist(x1, y1, x2, y2) { return Math.hypot(x2 - x1, y2 - y1); }
function sq(n) { return n * n; }
function min(a, b) { return arguments.length === 2 ? Math.min(a, b) : Math.min(...arguments); }
function max(a, b) { return arguments.length === 2 ? Math.max(a, b) : Math.max(...arguments); }
function sin(x) { return Math.sin(x); }
/* ========== OVERWORLD (from wizrad) ========== */
const WORLD_W = 3600;
const WORLD_H = 2400;
const TERRAIN_SCALE = 0.0044;
const DANGER_SCALE = 0.0045;
const SEA_LEVEL = 0.3;
const BEACH_BAND = 0.03;
const GRID_CELL = 140;
const GRID_JITTER = 0.28;
const ROAD_NEIGHBORS = 3;
const PLAYER_SPEED = 160;
const CAMERA_ZOOM = 3.45;
const MAP_CENTER_X = WORLD_W * 0.5;
const MAP_CENTER_Y = WORLD_H * 0.5;
const MAX_DIST_FROM_CENTER = Math.hypot(MAP_CENTER_X, MAP_CENTER_Y);
const DANGER_NOISE_WEIGHT = 0.2;
const DANGER_DISTANCE_WEIGHT = 0.8;
const SITE_EMOJI_BY_DANGER = [
{ maxDanger: 0.2, emoji: "๐ฐ", name: "Castle" },
{ maxDanger: 0.4, emoji: "๐๏ธ", name: "Town" },
{ maxDanger: 0.6, emoji: "๐ ", name: "Village" },
{ maxDanger: 0.8, emoji: "๐๏ธ", name: "Shack" },
{ maxDanger: 1.0, emoji: "๐", name: "Hovel" }
];
const EVIL_SITE_MIN_DIST = GRID_CELL * 5;
const EVIL_SITE_PROB_POW = 1.5;
const EVIL_SITE_BASE_PROB = 0.3;
const FOREST_NOISE_SCALE = 0.003;
const FOREST_DENSITY_THRESHOLD = 0.38;
const FOREST_GRID_CELL = 24;
const FOREST_GRID_JITTER = 0.35;
const ROAD_CLEARANCE = 12;
const SETTLEMENT_CLEARANCE = 55;
const TREE_EMOJIS = ["๐ฒ", "๐ณ", "๐ฒ", "๐ณ", "๐ฒ"];
const EVIL_SITE_TYPES = [
{ name: "Cave", emoji: "๐ณ๏ธ" },
{ name: "Haunted House", emoji: "๐๏ธ" },
{ name: "Crypt", emoji: "๐ชฆ" },
{ name: "Cult Shrine", emoji: "๐ฏ๏ธ" },
{ name: "Blighted Tower", emoji: "๐ผ" }
];
const NAME_A = ["Bel", "Dor", "Fen", "Kor", "Tallow", "Mor", "Ash", "Rin", "Var", "Lum", "Grace", "Shorn", "Mort", "Glream"];
const NAME_B = ["ford", "haven", "watch", "crest", "gard", "mere", "hold", "spire", "port", "vale", "bridge", "wood", "gate", "hollow", "vale"];
const TRAVEL_ENCOUNTER_CHANCE = 0.2;
/* ========== CARD GAME (from cardrl5) ========== */
const CARD_UI_HEIGHT = 250;
const CARD_GRID_SIZE = 15;
const CARD_GRID_PADDING = 72;
const TREE_EMOJI_CULL_MARGIN = 128;
const CARD_STATS_WIDTH = 180;
const CARD_ENEMY_WIDTH = 180;
const CARD_HAND_PADDING = 10;
const CARD_DIST_FUDGE = 0.1;
const CARD_TYPES = {
FIREBALL: { name: "Fireball", emoji: "๐ฅ", manaCost: 1, quick: false, range: 5, damage: 2, aoe: 3, selfDamage: true, description: "2 dmg, 5 range, 3 AOE (incl. self)" },
MAGIC_MISSILE: { name: "Magic Missile", emoji: "โจ", manaCost: 1, quick: true, range: 4, damage: 1, description: "1 dmg, 4 range (quick)" },
FROST_BOLT: { name: "Frost Bolt", emoji: "โ๏ธ", manaCost: 1, quick: false, range: 4, damage: 1, freeze: 3, description: "1 dmg, freeze 3 turns" },
FROST_NOVA: { name: "Frost Nova", emoji: "๐ฅถ", manaCost: 2, quick: false, range: 0, damage: 1, freeze: 3, aoe: 3, allInRange: true, description: "1 dmg, freeze 3, 3 AOE" },
WATER_WAVE: { name: "Squirt", emoji: "๐ง", manaCost: 0, quick: true, range: 4, knockback: 3, description: "Knock back 3 (quick)" },
HELLFIRE: { name: "Hellfire", emoji: "๐", manaCost: 3, quick: false, range: 0, damage: 2, aoe: 5, allInRange: true, description: "2 dmg to all in 5 range" },
TSUNAMI: { name: "Tsunami", emoji: "๐", manaCost: 2, quick: false, range: 0, knockback: 3, aoe: 5, allInRange: true, description: "Knock back all in 5" },
THINK: { name: "Think", emoji: "๐ญ", manaCost: 1, quick: true, range: 0, draw: 2, description: "Draw 2 cards (quick)" },
RUMINATE: { name: "Ruminate", emoji: "๐ง ", manaCost: 2, quick: true, range: 0, draw: 3, description: "Draw 3 cards (quick)" },
IDEATE: { name: "Ideate", emoji: "๐ก", manaCost: 3, quick: true, range: 0, draw: 5, description: "Draw 5 cards (quick)" },
RITUAL: { name: "Ritual", emoji: "โญ", manaCost: 0, quick: false, range: 0, manaGain: 3, description: "+3 mana" },
FLASH: { name: "Flash", emoji: "โก", manaCost: 0, quick: true, range: 0, manaGain: 1, description: "+1 mana (quick)" },
BRAIN_SURGE: { name: "Brain Surge", emoji: "๐คฏ", manaCost: 2, quick: false, range: 0, drawPerCard: 1, description: "+1 card per card played" },
BARRAGE: { name: "Barrage", emoji: "๐ฏ", manaCost: 2, quick: true, range: 4, damagePerCard: 1, description: "1 dmg/card played (quick)" },
HASTE: { name: "Haste", emoji: "๐", manaCost: 1, quick: true, range: 0, nextCardQuick: true, description: "Next card gains quick" },
NOVICE_BOLT: { name: "Novice Bolt", emoji: "๐ชต", manaCost: 1, quick: false, range: 4, damage: 1, description: "1 dmg, 4 range" },
FRANTIC_PLANNING: { name: "Frantic Planning", emoji: "๐", manaCost: 2, quick: true, range: 0, draw: 2, description: "Draw 2 cards (quick)" },
FAINT_GLIMMER: { name: "Faint Glimmer", emoji: "โจ", manaCost: 0, quick: false, range: 0, manaGain: 1, description: "+1 mana" }
};
const STARTING_CARD_KEYS = ["NOVICE_BOLT", "FRANTIC_PLANNING", "FAINT_GLIMMER"];
const DECK_REMOVE_COST = 100;
const CARD_ENEMY_TYPES = [
{ emoji: "๐", name: "Skeleton", hp: 3, damage: 1, dangerTier: 0 },
{ emoji: "๐ง", name: "Zombie", hp: 4, damage: 2, dangerTier: 0.15 },
{ emoji: "๐ป", name: "Ghost", hp: 2, damage: 1, dangerTier: 0.1 },
{ emoji: "๐ฆ", name: "Giant Bat", hp: 3, damage: 2, dangerTier: 0.25 },
{ emoji: "๐บ", name: "Dire Wolf", hp: 5, damage: 2, dangerTier: 0.35 },
{ emoji: "๐ท๏ธ", name: "Giant Spider", hp: 4, damage: 2, dangerTier: 0.4 },
{ emoji: "๐น", name: "Goblin Brute", hp: 6, damage: 3, dangerTier: 0.5 },
{ emoji: "๐งโโ๏ธ", name: "Dark Mage", hp: 4, damage: 3, dangerTier: 0.6 },
{ emoji: "๐บ", name: "Oni", hp: 8, damage: 4, dangerTier: 0.75 },
{ emoji: "๐ฒ", name: "Young Dragon", hp: 10, damage: 4, dangerTier: 0.9 },
{ emoji: "๐๏ธ", name: "Beholder", hp: 7, damage: 5, dangerTier: 0.95 }
];
let width = 1, height = 1, frameCount = 0;
const state = {
terrainSeed: 0,
dangerSeed: 0,
forestSeed: 0,
forestTrees: [],
towns: [],
edges: [],
edgeSet: new Set(),
player: null,
uiButtons: [],
message: "",
messageTimer: 0,
encountersEnabled: true,
mode: "overworld",
encounter: null,
worldTime: 0,
nextEncounterAt: 0,
terrainRenderer: null,
gameCanvas: null,
gameCtx: null,
camera: { x: null, y: null, lastFrame: -1 },
cameraZoomMul: 1,
battleZoomMul: 1,
transition: null,
postEncounterReport: null,
pendingEncounterReport: null,
lastReportRendered: null,
cardGameMouseX: -1,
cardGameMouseY: -1,
hoveredCard: null,
lastHandRenderKey: "",
input: { heldKeys: new Set(), keyDownEvents: new Set(), heldLMB: false, lmbDown: false, lmbUp: false },
mouseX: 0, mouseY: 0,
deckViewOpen: false
};
function easeInOutCubic(t) {
const x = constrain(t, 0, 1);
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
}
function u32(n) { return n >>> 0; }
function hash2u32(ix, iy, seed) {
let h = u32(Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1442695041));
h = u32(h ^ (h >>> 13));
h = u32(Math.imul(h, 1274126177));
return u32(h ^ (h >>> 16));
}
function smooth01(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y, scale, seed) {
const px = x * scale, py = y * scale;
const x0 = Math.floor(px), y0 = Math.floor(py), x1 = x0 + 1, y1 = y0 + 1;
const tx = px - x0, ty = py - y0;
const sx = smooth01(tx), sy = smooth01(ty);
const v00 = hash2u32(x0, y0, seed) / 4294967295;
const v10 = hash2u32(x1, y0, seed) / 4294967295;
const v01 = hash2u32(x0, y1, seed) / 4294967295;
const v11 = hash2u32(x1, y1, seed) / 4294967295;
const nx0 = lerp(v00, v10, sx);
const nx1 = lerp(v01, v11, sx);
return Math.round(lerp(nx0, nx1, sy) * 65535) / 65535;
}
function makeTownName() { return random(NAME_A) + random(NAME_B); }
const FBM_OCTAVES = 6;
const FBM_LACUNARITY = 2.0;
const FBM_PERSISTENCE = 0.5;
function fbm2D(x, y, scale, seed) {
let value = 0;
let amplitude = 1;
let frequency = 1;
let maxValue = 0;
for (let i = 0; i < FBM_OCTAVES; i++) {
value += amplitude * valueNoise2D(x * frequency + 300.123 + i * 17.7, y * frequency + 700.51 + i * 31.3, scale, seed);
maxValue += amplitude;
amplitude *= FBM_PERSISTENCE;
frequency *= FBM_LACUNARITY;
}
return value / maxValue;
}
function terrainHeightAt(x, y) {
const h = fbm2D(x, y, TERRAIN_SCALE, state.terrainSeed);
return constrain(h, 0, 1);
}
function dangerNoiseAt(x, y) {
const d = valueNoise2D(x + 90.37, y + 190.13, DANGER_SCALE, state.dangerSeed);
return d * d;
}
function dangerAt(x, y) {
const noiseD = dangerNoiseAt(x, y);
const distFromCenter = dist(x, y, MAP_CENTER_X, MAP_CENTER_Y);
const distFactor = constrain(distFromCenter / MAX_DIST_FROM_CENTER, 0, 1);
return constrain(noiseD * DANGER_NOISE_WEIGHT + distFactor * DANGER_DISTANCE_WEIGHT, 0, 1);
}
function siteEmojiForDanger(dangerVal) {
for (const tier of SITE_EMOJI_BY_DANGER) {
if (dangerVal <= tier.maxDanger) return { emoji: tier.emoji, name: tier.name };
}
return SITE_EMOJI_BY_DANGER[SITE_EMOJI_BY_DANGER.length - 1];
}
function isLand(x, y) { return terrainHeightAt(x, y) >= SEA_LEVEL; }
function forestDensityAt(x, y) {
return valueNoise2D(x * 1.3 + 555.7, y * 1.3 + 333.1, FOREST_NOISE_SCALE, state.forestSeed);
}
function distToNearestRoad(x, y) {
let best = Infinity;
for (const e of state.edges) {
const a = state.towns[e.a], b = state.towns[e.b];
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const t = constrain(((x - a.x) * dx + (y - a.y) * dy) / (len * len), 0, 1);
const px = a.x + t * dx, py = a.y + t * dy;
const d = dist(x, y, px, py);
if (d < best) best = d;
}
return best;
}
function distToNearestSettlement(x, y) {
let best = Infinity;
for (const t of state.towns) {
const d = dist(x, y, t.x, t.y);
if (d < best) best = d;
}
return best;
}
function generateForestTrees() {
state.forestTrees = [];
const seed = state.forestSeed;
for (let gy = 0; gy * FOREST_GRID_CELL < WORLD_H; gy++) {
for (let gx = 0; gx * FOREST_GRID_CELL < WORLD_W; gx++) {
const h = hash2u32(gx, gy, seed);
const jx = (h / 4294967295 - 0.5) * FOREST_GRID_CELL * FOREST_GRID_JITTER * 2;
const jy = (hash2u32(gx, gy + 777, seed) / 4294967295 - 0.5) * FOREST_GRID_CELL * FOREST_GRID_JITTER * 2;
const x = gx * FOREST_GRID_CELL + FOREST_GRID_CELL * 0.5 + jx;
const y = gy * FOREST_GRID_CELL + FOREST_GRID_CELL * 0.5 + jy;
if (x < 4 || y < 4 || x > WORLD_W - 4 || y > WORLD_H - 4) continue;
if (!isLand(x, y)) continue;
if (forestDensityAt(x, y) < FOREST_DENSITY_THRESHOLD) continue;
if (distToNearestRoad(x, y) < ROAD_CLEARANCE) continue;
if (distToNearestSettlement(x, y) < SETTLEMENT_CLEARANCE) continue;
const emojiIdx = (h >>> 16) % TREE_EMOJIS.length;
state.forestTrees.push({ x, y, emoji: TREE_EMOJIS[emojiIdx] });
}
}
}
function getCamera() {
const base = min(width / WORLD_W, height / WORLD_H) * 0.95;
const scale = base * CAMERA_ZOOM * state.cameraZoomMul;
const targetX = state.player ? state.player.x : WORLD_W * 0.5;
const targetY = state.player ? state.player.y : WORLD_H * 0.5;
if (state.camera.lastFrame !== frameCount) {
if (state.camera.x == null || state.camera.y == null) {
state.camera.x = targetX;
state.camera.y = targetY;
} else {
state.camera.x = lerp(state.camera.x, targetX, 0.14);
state.camera.y = lerp(state.camera.y, targetY, 0.14);
}
state.camera.lastFrame = frameCount;
}
const px = state.camera.x, py = state.camera.y;
const halfViewW = width * 0.5 / scale;
const halfViewH = height * 0.5 / scale;
const cx = (halfViewW * 2 >= WORLD_W) ? WORLD_W * 0.5 : constrain(px, halfViewW, WORLD_W - halfViewW);
const cy = (halfViewH * 2 >= WORLD_H) ? WORLD_H * 0.5 : constrain(py, halfViewH, WORLD_H - halfViewH);
return { scale, cx, cy };
}
function worldToScreen(x, y) {
const cam = getCamera();
return { x: width * 0.5 + (x - cam.cx) * cam.scale, y: height * 0.5 + (y - cam.cy) * cam.scale };
}
function screenToWorld(sx, sy) {
const cam = getCamera();
return { x: cam.cx + (sx - width * 0.5) / cam.scale, y: cam.cy + (sy - height * 0.5) / cam.scale };
}
function createTerrainRenderer() {
const canvas = document.createElement("canvas");
canvas.style.position = "fixed";
canvas.style.left = "0";
canvas.style.top = "0";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.zIndex = "0";
canvas.style.pointerEvents = "none";
const first = document.body.firstElementChild;
document.body.insertBefore(canvas, first || null);
const gl = canvas.getContext("webgl2", { alpha: false, antialias: false, depth: false, stencil: false });
if (!gl) throw new Error("WebGL2 is required.");
const vertSrc = `#version 300 es
precision highp float;
const vec2 POS[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
void main() { gl_Position = vec4(POS[gl_VertexID], 0.0, 1.0); }`;
const fragSrc = `#version 300 es
precision highp float;
precision highp int;
out vec4 outColor;
uniform vec2 uResolution;
uniform vec2 uWorldSize;
uniform vec2 uCamCenter;
uniform float uCamScale;
uniform uint uTerrainSeed;
uniform uint uDangerSeed;
uniform float uSeaLevel;
uniform float uBeachBand;
uint hash2u(uvec2 p, uint seed) {
uint h = p.x * 374761393u + p.y * 668265263u + seed * 1442695041u;
h ^= (h >> 13); h *= 1274126177u; h ^= (h >> 16);
return h;
}
float smooth01(float t) { return t * t * (3.0 - 2.0 * t); }
float valueNoise(vec2 p, float scale, uint seed) {
vec2 q = p * scale;
ivec2 i0 = ivec2(floor(q)), i1 = i0 + ivec2(1);
vec2 f = fract(q);
float sx = smooth01(f.x), sy = smooth01(f.y);
float v00 = float(hash2u(uvec2(i0), seed)) * (1.0 / 4294967295.0);
float v10 = float(hash2u(uvec2(ivec2(i1.x, i0.y)), seed)) * (1.0 / 4294967295.0);
float v01 = float(hash2u(uvec2(ivec2(i0.x, i1.y)), seed)) * (1.0 / 4294967295.0);
float v11 = float(hash2u(uvec2(i1), seed)) * (1.0 / 4294967295.0);
return floor((mix(mix(v00, v10, sx), mix(v01, v11, sx), sy) * 65535.0 + 0.5)) / 65535.0;
}
const float FBM_LACUNARITY = 2.0;
const float FBM_PERSISTENCE = 0.5;
const int FBM_OCTAVES = 6;
float fbm(vec2 world, float scale, uint seed) {
float value = 0.0;
float amplitude = 1.0;
float frequency = 1.0;
float maxValue = 0.0;
for (int i = 0; i < FBM_OCTAVES; i++) {
vec2 p = world * frequency + vec2(300.123 + float(i) * 17.7, 700.51 + float(i) * 31.3);
value += amplitude * valueNoise(p, scale, seed);
maxValue += amplitude;
amplitude *= FBM_PERSISTENCE;
frequency *= FBM_LACUNARITY;
}
return value / maxValue;
}
float terrainHeightAt(vec2 world) {
return clamp(fbm(world, ${TERRAIN_SCALE.toFixed(10)}, uTerrainSeed), 0.0, 1.0);
}
void main() {
vec2 screen = vec2(gl_FragCoord.x, uResolution.y - gl_FragCoord.y);
vec2 world = uCamCenter + (screen - 0.5 * uResolution) / uCamScale;
world = clamp(world, vec2(0.0), uWorldSize);
float h = terrainHeightAt(world);
vec3 col;
if (h < uSeaLevel) col = vec3(20.0, 35.0 + h * 20.0, 110.0 + h * 70.0) / 255.0;
else if (h < uSeaLevel + uBeachBand) col = vec3(214.0, 194.0, 132.0) / 255.0;
else if (h < 0.8) {
float t = clamp((h - (uSeaLevel + uBeachBand)) / (0.8 - (uSeaLevel + uBeachBand)), 0.0, 1.0);
col = vec3(48.0 + t * 26.0, 124.0 + t * 46.0, 56.0 + t * 20.0) / 255.0;
} else if (h < 0.92) {
float t = clamp((h - 0.8) / (0.92 - 0.8), 0.0, 1.0);
col = vec3(112.0 + t * 36.0) / 255.0;
} else {
float t = clamp((h - 0.92) / 0.08, 0.0, 1.0);
col = vec3(230.0 + t * 22.0, 230.0 + t * 22.0, 236.0 + t * 19.0) / 255.0;
}
outColor = vec4(col, 1.0);
}`;
function compile(type, src) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader) || "shader compile error");
}
return shader;
}
const vs = compile(gl.VERTEX_SHADER, vertSrc);
const fs = compile(gl.FRAGMENT_SHADER, fragSrc);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || "program link error");
}
gl.deleteShader(vs);
gl.deleteShader(fs);
return {
canvas, gl, program,
uResolution: gl.getUniformLocation(program, "uResolution"),
uWorldSize: gl.getUniformLocation(program, "uWorldSize"),
uCamCenter: gl.getUniformLocation(program, "uCamCenter"),
uCamScale: gl.getUniformLocation(program, "uCamScale"),
uTerrainSeed: gl.getUniformLocation(program, "uTerrainSeed"),
uDangerSeed: gl.getUniformLocation(program, "uDangerSeed"),
uSeaLevel: gl.getUniformLocation(program, "uSeaLevel"),
uBeachBand: gl.getUniformLocation(program, "uBeachBand")
};
}
function resizeTerrainRenderer() {
const tr = state.terrainRenderer;
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
if (tr.canvas.width !== w || tr.canvas.height !== h) {
tr.canvas.width = w;
tr.canvas.height = h;
}
}
function renderTerrainRenderer(camCenterX, camCenterY, camScaleCss) {
const tr = state.terrainRenderer;
resizeTerrainRenderer();
const gl = tr.gl;
gl.viewport(0, 0, tr.canvas.width, tr.canvas.height);
gl.useProgram(tr.program);
gl.uniform2f(tr.uResolution, tr.canvas.width, tr.canvas.height);
gl.uniform2f(tr.uWorldSize, WORLD_W, WORLD_H);
gl.uniform2f(tr.uCamCenter, camCenterX, camCenterY);
gl.uniform1f(tr.uCamScale, camScaleCss * (tr.canvas.width / Math.max(window.innerWidth, 1)));
gl.uniform1ui(tr.uTerrainSeed, state.terrainSeed >>> 0);
gl.uniform1ui(tr.uDangerSeed, state.dangerSeed >>> 0);
gl.uniform1f(tr.uSeaLevel, SEA_LEVEL);
gl.uniform1f(tr.uBeachBand, BEACH_BAND);
gl.disable(gl.BLEND);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function lineLandFraction(a, b) {
const samples = 18;
let landCount = 0;
for (let i = 0; i <= samples; i++) {
const t = i / samples;
const x = lerp(a.x, b.x, t);
const y = lerp(a.y, b.y, t);
if (isLand(x, y)) landCount++;
}
return landCount / (samples + 1);
}
function addEdge(i, j) {
if (i === j) return;
const a = min(i, j), b = max(i, j);
const key = `${a}|${b}`;
if (state.edgeSet.has(key)) return;
state.edgeSet.add(key);
state.edges.push({ a, b });
}
function generateMap() {
randomSeed(floor(random(1e9)));
state.terrainSeed = floor(random(1, 0xffffffff)) >>> 0;
state.dangerSeed = floor(random(1, 0xffffffff)) >>> 0;
state.forestSeed = floor(random(1, 0xffffffff)) >>> 0;
state.towns = [];
state.edges = [];
state.edgeSet.clear();
state.mode = "overworld";
state.encounter = null;
state.nextEncounterAt = 0;
state.transition = null;
state.cameraZoomMul = 1;
state.battleZoomMul = 1;
state.postEncounterReport = null;
state.pendingEncounterReport = null;
state.camera.x = null;
state.camera.y = null;
document.getElementById("cardUiRoot").classList.remove("visible");
hudCacheKey = "";
for (let gy = 0; gy * GRID_CELL < WORLD_H; gy++) {
for (let gx = 0; gx * GRID_CELL < WORLD_W; gx++) {
const jx = (random() - 0.5) * GRID_CELL * GRID_JITTER * 2;
const jy = (random() - 0.5) * GRID_CELL * GRID_JITTER * 2;
const x = gx * GRID_CELL + GRID_CELL * 0.5 + jx;
const y = gy * GRID_CELL + GRID_CELL * 0.5 + jy;
if (x < 8 || y < 8 || x > WORLD_W - 8 || y > WORLD_H - 8) continue;
if (!isLand(x, y)) continue;
const siteDanger = dangerAt(x, y);
const tier = siteEmojiForDanger(siteDanger);
const shopCardPool = Object.keys(CARD_TYPES).filter((k) => !STARTING_CARD_KEYS.includes(k));
const shopCards = [];
for (let s = 0; s < 3; s++) {
shopCards.push(shopCardPool[floor(random(shopCardPool.length))]);
}
state.towns.push({
id: state.towns.length, x, y, gx, gy,
name: makeTownName(),
sizeTier: tier.name, emoji: tier.emoji,
isEvilSite: false, siteType: null, siteEmoji: null, siteDanger,
siteCleared: false, visited: false,
shopCards,
removalsUsed: 0
});
}
}
for (const t of state.towns) {
const evilProb = min(1, EVIL_SITE_BASE_PROB + (1 - EVIL_SITE_BASE_PROB) * Math.pow(t.siteDanger, EVIL_SITE_PROB_POW));
if (random() >= evilProb) continue;
let tooClose = false;
for (const s of state.towns) {
if (s === t || !s.isEvilSite) continue;
if (dist(t.x, t.y, s.x, s.y) < EVIL_SITE_MIN_DIST) { tooClose = true; break; }
}
if (tooClose) continue;
const evilType = random(EVIL_SITE_TYPES);
t.isEvilSite = true;
t.siteType = evilType.name;
t.siteEmoji = evilType.emoji;
t.siteCleared = false;
}
function isGabrielEdge(i, j) {
const a = state.towns[i], b = state.towns[j];
const mx = (a.x + b.x) * 0.5, my = (a.y + b.y) * 0.5;
const r2 = sq(a.x - b.x) * 0.25 + sq(a.y - b.y) * 0.25;
for (let k = 0; k < state.towns.length; k++) {
if (k === i || k === j) continue;
const t = state.towns[k];
if (sq(t.x - mx) + sq(t.y - my) < r2 - 1e-4) return false;
}
return true;
}
const nearest = new Array(state.towns.length).fill(-1);
for (let i = 0; i < state.towns.length; i++) {
const a = state.towns[i];
let best = -1, bestD = Infinity;
for (let j = 0; j < state.towns.length; j++) {
if (i === j) continue;
const b = state.towns[j];
if (abs(a.gx - b.gx) > 3 || abs(a.gy - b.gy) > 3) continue;
const d = dist(a.x, a.y, b.x, b.y);
if (d < bestD && lineLandFraction(a, b) >= 0.65) { bestD = d; best = j; }
}
nearest[i] = best;
}
for (let i = 0; i < state.towns.length; i++) {
const j = nearest[i];
if (j < 0) continue;
if (nearest[j] === i && isGabrielEdge(i, j)) addEdge(i, j);
}
for (let i = 0; i < state.towns.length; i++) {
const a = state.towns[i];
const local = [];
for (let j = 0; j < state.towns.length; j++) {
if (i === j) continue;
const b = state.towns[j];
if (abs(a.gx - b.gx) > 3 || abs(a.gy - b.gy) > 3) continue;
local.push({ j, d: dist(a.x, a.y, b.x, b.y) });
}
local.sort((u, v) => u.d - v.d);
let added = 0;
for (const c of local) {
if (added >= 4) break;
if (!isGabrielEdge(i, c.j)) continue;
if (lineLandFraction(a, state.towns[c.j]) < 0.65) continue;
addEdge(i, c.j);
added++;
}
}
for (let i = 0; i < state.towns.length; i++) {
if (state.edges.some((e) => e.a === i || e.b === i)) continue;
const a = state.towns[i];
let best = -1, bestD = Infinity;
for (let j = 0; j < state.towns.length; j++) {
if (i === j) continue;
const b = state.towns[j];
if (abs(a.gx - b.gx) > 3 || abs(a.gy - b.gy) > 3) continue;
const d = dist(a.x, a.y, b.x, b.y);
if (d < bestD && lineLandFraction(a, b) >= 0.55 && isGabrielEdge(i, j)) { best = j; bestD = d; }
}
if (best < 0) {
for (let j = 0; j < state.towns.length; j++) {
if (i === j) continue;
const b = state.towns[j];
if (abs(a.gx - b.gx) > 3 || abs(a.gy - b.gy) > 3) continue;
const d = dist(a.x, a.y, b.x, b.y);
if (d < bestD && lineLandFraction(a, b) >= 0.55) { best = j; bestD = d; }
}
}
if (best >= 0) addEdge(i, best);
}
generateForestTrees();
initPlayer();
}
function initPlayer() {
if (state.towns.length < 2) { state.player = null; return; }
let startIdx = -1;
let bestScore = Infinity;
for (let i = 0; i < state.towns.length; i++) {
const t = state.towns[i];
const d = dist(t.x, t.y, MAP_CENTER_X, MAP_CENTER_Y);
const evilPenalty = t.isEvilSite ? 3000 : 0;
const score = d + t.siteDanger * 800 + evilPenalty;
if (score < bestScore) { bestScore = score; startIdx = i; }
}
if (startIdx < 0) startIdx = 0;
state.towns[startIdx].visited = true;
const startingCards = [];
for (const k of STARTING_CARD_KEYS) for (let i = 0; i < 4; i++) startingCards.push(k);
state.player = {
atTown: startIdx, targetTown: null, progress: 0,
x: state.towns[startIdx].x, y: state.towns[startIdx].y,
gold: 20, hp: 20, hpMax: 20, mana: 70, manaMax: 100,
startingCards,
ownedCards: []
};
setMessage(`Start at ${state.towns[startIdx].name}.`);
}
function setMessage(msg) {
state.message = msg;
state.messageTimer = 3.2;
}
function neighborsOf(townId) {
const out = [];
for (const e of state.edges) {
if (e.a === townId) out.push(e.b);
else if (e.b === townId) out.push(e.a);
}
return out;
}
function startTravel(targetTownId) {
if (!state.player || state.player.targetTown != null || state.mode !== "overworld") return;
const nbs = neighborsOf(state.player.atTown);
if (!nbs.includes(targetTownId)) return;
state.player.targetTown = targetTownId;
state.player.progress = 0;
state.player.travelEncounterChecked = false;
}
function arriveTown() {
const p = state.player;
const town = state.towns[p.atTown];
town.visited = true;
if (town.isEvilSite && !town.siteCleared) {
beginEncounterTransition(town.siteDanger, {
label: `${town.siteType} encounter`,
bonusGold: floor(60 + town.siteDanger * 120),
bonusMana: floor(25 + town.siteDanger * 25),
siteTownId: town.id
});
setMessage(`You entered a ${town.siteType} near ${town.name}.`);
return;
}
setMessage(`Arrived at ${town.name}.`);
}
function maybeStartTravelEncounter(danger) {
if (!state.encountersEnabled) return false;
if (state.worldTime < state.nextEncounterAt) return false;
if (random() >= TRAVEL_ENCOUNTER_CHANCE) return false;
beginEncounterTransition(danger);
return true;
}
function updateTravel(dt) {
const p = state.player;
if (!p || p.targetTown == null || state.mode !== "overworld") return;
const a = state.towns[p.atTown];
const b = state.towns[p.targetTown];
const d = max(1, dist(a.x, a.y, b.x, b.y));
p.progress += (PLAYER_SPEED * dt) / d;
const rawT = constrain(p.progress, 0, 1);
const t = rawT * rawT * (3 - 2 * rawT);
p.x = lerp(a.x, b.x, t);
p.y = lerp(a.y, b.y, t);
if (!p.travelEncounterChecked && p.progress >= 0.5) {
p.travelEncounterChecked = true;
const routeDanger = max(a.siteDanger, b.siteDanger, dangerAt(p.x, p.y));
if (maybeStartTravelEncounter(routeDanger)) return;
}
if (p.progress >= 1) {
p.atTown = p.targetTown;
p.targetTown = null;
p.progress = 0;
p.x = b.x;
p.y = b.y;
arriveTown();
}
}
function drawWorld() {
const ctx = state.gameCtx;
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
const cam = getCamera();
renderTerrainRenderer(cam.cx, cam.cy, cam.scale);
ctx.save();
ctx.translate(width * 0.5, height * 0.5);
ctx.scale(cam.scale, cam.scale);
ctx.translate(-cam.cx, -cam.cy);
const halfViewW = width * 0.5 / cam.scale;
const halfViewH = height * 0.5 / cam.scale;
const margin = 40;
ctx.font = "24px 'Segoe UI Emoji', sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const t of state.forestTrees) {
if (t.x < cam.cx - halfViewW - margin || t.x > cam.cx + halfViewW + margin) continue;
if (t.y < cam.cy - halfViewH - margin || t.y > cam.cy + halfViewH + margin) continue;
ctx.fillText(t.emoji, t.x, t.y);
}
const playerTown = state.player ? state.player.atTown : -1;
for (const e of state.edges) {
const a = state.towns[e.a];
const b = state.towns[e.b];
const connectedToCurrent = e.a === playerTown || e.b === playerTown;
ctx.strokeStyle = connectedToCurrent ? "rgba(180,140,90,0.95)" : "rgba(120,95,65,0.55)";
ctx.lineWidth = (connectedToCurrent ? 4 : 3) / cam.scale;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const town of state.towns) {
const isAtTown = state.player && town.id === state.player.atTown;
ctx.fillStyle = town.visited ? "rgba(255,236,186,0.71)" : "rgba(220,205,185,0.53)";
ctx.beginPath();
ctx.arc(town.x, town.y, 8 / cam.scale, 0, Math.PI * 2);
ctx.fill();
if (isAtTown) {
ctx.strokeStyle = "rgb(255,228,100)";
ctx.lineWidth = 3 / cam.scale;
ctx.beginPath();
ctx.arc(town.x, town.y, (26 + sin(frameCount * 0.12) * 2.5) / cam.scale / 2, 0, Math.PI * 2);
ctx.stroke();
}
}
ctx.restore();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const town of state.towns) {
const s = worldToScreen(town.x, town.y);
const icon = town.isEvilSite ? town.siteEmoji : town.emoji;
ctx.font = "72px monospace";
ctx.fillStyle = "#e6e6e6";
ctx.fillText(icon, s.x, s.y - 6);
ctx.fillStyle = "rgb(15,15,15)";
ctx.font = "13px monospace";
ctx.fillText(town.name, s.x, s.y + 18);
if (town.isEvilSite) {
ctx.fillStyle = town.siteCleared ? "rgb(160,240,170)" : "rgb(255,120,120)";
ctx.font = "11px monospace";
ctx.fillText(`${town.siteType}${town.siteCleared ? " (cleared)" : ""}`, s.x, s.y + 33);
}
if (state.player && town.id === state.player.atTown && state.player.targetTown == null) {
ctx.fillStyle = "rgb(255,245,145)";
ctx.font = "12px monospace";
ctx.fillText("YOU ARE HERE", s.x, s.y - 32);
}
}
if (state.player) {
const ps = worldToScreen(state.player.x, state.player.y);
ctx.font = "56px monospace";
ctx.fillStyle = "#e6e6e6";
ctx.fillText("๐ง", ps.x, ps.y - 34);
}
}
let hudCacheKey = "";
function drawHud() {
const hud = document.getElementById("hudPanel");
if (!state.player) { hud.innerHTML = ""; hudCacheKey = ""; drawMessageToast(); return; }
const p = state.player;
const at = state.towns[p.atTown];
const d = dangerAt(p.x, p.y);
const shopCards = (p.targetTown == null && state.mode === "overworld") ? (at.shopCards || []) : [];
const key = `${at.id}-${p.atTown}-${p.targetTown}-${p.hp}-${p.gold}-${d.toFixed(1)}-${state.encountersEnabled}-${shopCards.join(",")}`;
if (key === hudCacheKey) { drawMessageToast(); return; }
hudCacheKey = key;
let shopHtml = "";
if (shopCards.length > 0) {
shopHtml = `<div style="margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.2);">Shop:`;
for (const ckey of shopCards) {
const c = CARD_TYPES[ckey];
if (!c) continue;
const price = cardPrice(ckey);
shopHtml += `<div style="margin-top:4px;display:flex;align-items:center;gap:8px;"><button class="shop-btn" data-card="${ckey}" data-price="${price}">Buy</button><span>${c.emoji} ${c.name} ${price}g</span></div>`;
}
shopHtml += `</div>`;
} else if (p.targetTown == null && state.mode === "overworld") {
shopHtml = `<div style="margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.2);color:rgb(160,160,160);">Shop: No cards for sale</div>`;
}
hud.innerHTML = `
<div>${at.emoji} ${at.name} (${at.sizeTier})${p.targetTown != null ? " โ traveling" : ""}</div>
<div style="margin-top:6px;">HP ${p.hp.toFixed(0)}/${p.hpMax} ยท Gold ${p.gold}</div>
<div style="margin-top:4px;font-size:12px;color:rgb(180,180,180);">Danger ${(d * 100).toFixed(0)}% ยท Encounters ${state.encountersEnabled ? "ON" : "OFF"}</div>
${shopHtml}
<div style="margin-top:10px;font-size:11px;color:rgb(160,160,160);">Click connected town to travel ยท E toggle encounters ยท R regenerate ยท WASD+cards in combat</div>
<div style="margin-top:6px;"><button class="shop-btn deck-view-btn">View Deck</button></div>
`;
drawMessageToast();
}
function cardPrice(cardKey) {
const c = CARD_TYPES[cardKey];
return c ? floor(10 + (c.manaCost || 0) * 6) : 15;
}
function getFullDeck() {
const p = state.player;
if (!p) return [];
const startingCounts = {};
for (const k of (p.startingCards || [])) {
startingCounts[k] = (startingCounts[k] || 0) + 1;
}
const ownedCounts = {};
for (const k of (p.ownedCards || [])) {
ownedCounts[k] = (ownedCounts[k] || 0) + 1;
}
const keys = new Set([...Object.keys(startingCounts), ...Object.keys(ownedCounts)]);
return Array.from(keys).map((key) => {
const card = CARD_TYPES[key];
if (!card) return null;
const starting = startingCounts[key] || 0;
const owned = ownedCounts[key] || 0;
const count = starting + owned;
return { key, card, count, removableCount: count };
}).filter(Boolean);
}
function tryBuyCard(cardKey, price) {
const p = state.player;
if (!p || !cardKey) return;
if (p.gold < price) return setMessage("Not enough gold.");
const c = CARD_TYPES[cardKey];
if (!c) return;
const town = state.towns[p.atTown];
const idx = (town.shopCards || []).indexOf(cardKey);
if (idx < 0) return setMessage("That card is not for sale here.");
p.gold -= price;
p.ownedCards.push(cardKey);
town.shopCards.splice(idx, 1);
setMessage(`Bought ${c.name}.`);
}
function openDeckView() {
state.deckViewOpen = true;
document.getElementById("deckViewOverlay").classList.remove("hidden");
drawDeckView();
}
function closeDeckView() {
state.deckViewOpen = false;
document.getElementById("deckViewOverlay").classList.add("hidden");
}
function tryRemoveCard(cardKey) {
const p = state.player;
if (!p || !cardKey) return;
const town = state.towns[p.atTown];
if ((town.removalsUsed || 0) >= 1) return setMessage("This town allows only 1 removal per visit.");
if (p.gold < DECK_REMOVE_COST) return setMessage(`Need ${DECK_REMOVE_COST}g to remove a card.`);
let idx = (p.startingCards || []).indexOf(cardKey);
if (idx >= 0) {
p.startingCards.splice(idx, 1);
} else {
idx = (p.ownedCards || []).indexOf(cardKey);
if (idx < 0) return setMessage("That card cannot be removed.");
p.ownedCards.splice(idx, 1);
}
const c = CARD_TYPES[cardKey];
p.gold -= DECK_REMOVE_COST;
town.removalsUsed = (town.removalsUsed || 0) + 1;
setMessage(`Removed ${c ? c.name : cardKey} from deck. (-${DECK_REMOVE_COST}g)`);
drawDeckView();
}
function drawDeckView() {
const overlay = document.getElementById("deckViewOverlay");
const panel = document.getElementById("deckViewPanel");
if (!state.deckViewOpen || !state.player) {
overlay.classList.add("hidden");
return;
}
const p = state.player;
const town = state.towns[p.atTown];
const totalDeckSize = (p.startingCards || []).length + (p.ownedCards || []).length;
const canRemove = state.mode === "overworld" && p.targetTown == null &&
(!town.isEvilSite || town.siteCleared) && (town.removalsUsed || 0) < 1 &&
totalDeckSize > 0;
const deck = getFullDeck();
const total = deck.reduce((sum, e) => sum + e.count, 0);
let cardsHtml = "";
for (const { key, card, count, removableCount } of deck) {
const removable = canRemove && count > 0 && p.gold >= DECK_REMOVE_COST;
cardsHtml += `<div class="deck-card${removable ? " removable" : ""}" data-key="${key}" data-removable="${!!removable}">
<div class="cardHeader">${card.quick ? "โก" : "โฑ๏ธ"} ${card.name} <span>โก${card.manaCost || 0}</span></div>
<div class="deck-card-emoji">${card.emoji}</div>
<div class="cardDesc">${card.description}</div>
<div class="deck-card-count">ร${count}</div>
</div>`;
}
const removeHint = canRemove
? `<div class="deck-remove-hint">Click a removable card to remove it for ${DECK_REMOVE_COST}g</div>`
: "";
panel.innerHTML = `
<h2>Your Deck (${total} cards)</h2>
<div class="deck-grid">${cardsHtml}</div>
${removeHint}
<button class="report-btn" id="deckViewClose">Close</button>
`;
panel.querySelector("#deckViewClose").addEventListener("click", closeDeckView);
panel.querySelectorAll(".deck-card.removable").forEach((el) => {
el.addEventListener("click", () => {
if (el.dataset.removable === "true") tryRemoveCard(el.dataset.key);
});
});
}
function onWorldClick(mx, my) {
const p = state.player;
if (!p || p.targetTown != null || state.mode !== "overworld") return;
const w = screenToWorld(mx, my);
const nbs = neighborsOf(p.atTown);
let best = null, bestD = 99999;
for (const id of nbs) {
const t = state.towns[id];
const d = dist(w.x, w.y, t.x, t.y);
if (d < bestD) { bestD = d; best = id; }
}
if (best != null && bestD <= 85) startTravel(best);
}
/* ========== CARD GAME ENCOUNTER ========== */
function createCardGameEncounter(danger, options) {
const p = state.player;
const card = {
danger,
special: options || null,
originWorldX: p.x,
originWorldY: p.y,
startGold: p.gold,
startHp: p.hp,
startMana: p.mana,
player: { x: 4, y: 4, hp: p.hp, maxHp: p.hpMax, mana: 3, maxMana: 3 },
enemies: [],
deck: [],
hand: [],
discardPile: [],
selectedCard: null,
selectedEnemy: null,
targeting: false,
turnInProgress: false,
enemyRoster: {},
cardsPlayedThisTurn: 0,
nextCardQuick: false
};
const starting = (p.startingCards || []).map((k) => ({ ...CARD_TYPES[k], effect: getCardEffect(CARD_TYPES[k]) })).filter((c) => c && c.name);
const owned = (p.ownedCards || []).map((k) => ({ ...CARD_TYPES[k], effect: getCardEffect(CARD_TYPES[k]) })).filter((c) => c && c.name);
card.deck = [...starting.map((c) => ({ ...c, effect: getCardEffect(c) })), ...owned];
shuffleCardDeck(card.deck);
for (let i = 0; i < 3; i++) {
if (card.deck.length > 0) card.hand.push(card.deck.pop());
}
card.player.mana += 1;
const count = max(1, min(5, floor(1 + danger * 4)));
const center = floor(CARD_GRID_SIZE / 2);
const eligible = CARD_ENEMY_TYPES.filter((e) => (e.dangerTier || 0) <= danger + 0.05);
const pool = eligible.length > 0 ? eligible : CARD_ENEMY_TYPES.slice(0, 3);
for (let i = 0; i < count; i++) {
let x, y;
do {
x = floor(random(CARD_GRID_SIZE));
y = floor(random(CARD_GRID_SIZE));
} while ((x === center && y === center) || card.enemies.some((e) => e.x === x && e.y === y) || (abs(x - center) < 2 && abs(y - center) < 2));
const tiered = pool.filter((e) => (e.dangerTier || 0) <= danger + 0.08);
const pickPool = tiered.length > 0 ? tiered : pool;
const et = random(pickPool);
const scale = 0.7 + danger * 0.6;
const hp = max(1, floor(et.hp * scale));
const dmg = max(1, floor(et.damage * scale));
const enemy = { x, y, type: "enemy", emoji: et.emoji, name: et.name, hp, damage: dmg, maxHp: hp, frozen: 0 };
card.enemies.push(enemy);
card.enemyRoster[et.name] = (card.enemyRoster[et.name] || 0) + 1;
}
state.encounter = card;
document.getElementById("cardUiRoot").classList.add("visible");
document.documentElement.style.setProperty("--card-ui-height", `${CARD_UI_HEIGHT}px`);
document.documentElement.style.setProperty("--card-stats-width", `${CARD_STATS_WIDTH}px`);
document.documentElement.style.setProperty("--card-enemy-width", `${CARD_ENEMY_WIDTH}px`);
state.lastHandRenderKey = "";
setMessage(`Encounter! Danger ${(danger * 100).toFixed(0)}%`);
}
function cardDist(x1, y1, x2, y2) {
return dist(x1, y1, x2, y2);
}
function cardEnemiesInRange(enc, cx, cy, radius) {
return enc.enemies.filter((e) => cardDist(cx, cy, e.x, e.y) <= radius + CARD_DIST_FUDGE);
}
function cardKnockback(enc, enemy, squares) {
const dx = enemy.x - enc.player.x;
const dy = enemy.y - enc.player.y;
if (dx === 0 && dy === 0) return;
const nx = enemy.x + Math.sign(dx) * squares;
const ny = enemy.y + Math.sign(dy) * squares;
const clampedX = constrain(nx, 0, CARD_GRID_SIZE - 1);
const clampedY = constrain(ny, 0, CARD_GRID_SIZE - 1);
if (!cardIsPositionOccupied(enc, clampedX, clampedY) || (clampedX === enemy.x && clampedY === enemy.y)) {
enemy.x = clampedX;
enemy.y = clampedY;
} else {
for (let s = squares - 1; s >= 1; s--) {
const tx = enemy.x + Math.sign(dx) * s;
const ty = enemy.y + Math.sign(dy) * s;
const cx = constrain(tx, 0, CARD_GRID_SIZE - 1);
const cy = constrain(ty, 0, CARD_GRID_SIZE - 1);
if (!cardIsPositionOccupied(enc, cx, cy) || (cx === enemy.x && cy === enemy.y)) {
enemy.x = cx;
enemy.y = cy;
return;
}
}
}
}
function getCardEffect(card) {
if (card.name === "Fireball") return (enc, target, tx, ty) => {
const cx = tx != null ? tx : (target ? target.x : enc.player.x);
const cy = ty != null ? ty : (target ? target.y : enc.player.y);
const targets = card.aoe ? cardEnemiesInRange(enc, cx, cy, card.aoe) : (target ? [target] : []);
for (const e of targets) cardDamageEnemy(enc, e, card.damage || 0);
if (card.selfDamage && cardDist(enc.player.x, enc.player.y, cx, cy) <= (card.aoe || 0) + CARD_DIST_FUDGE) {
enc.player.hp -= card.damage || 0;
}
};
if (card.name === "Magic Missile") return (enc, target) => target && cardDamageEnemy(enc, target, card.damage || 0);
if (card.name === "Frost Bolt") return (enc, target) => {
if (target) { cardDamageEnemy(enc, target, card.damage || 0); target.frozen = max(target.frozen || 0, card.freeze || 0); }
};
if (card.name === "Frost Nova") return (enc) => {
const inAoe = cardEnemiesInRange(enc, enc.player.x, enc.player.y, card.aoe || 0);
for (const e of inAoe) { cardDamageEnemy(enc, e, card.damage || 0); e.frozen = max(e.frozen || 0, card.freeze || 0); }
};
if (card.name === "Squirt") return (enc, target) => target && cardKnockback(enc, target, card.knockback || 0);
if (card.name === "Hellfire") return (enc) => {
const inRange = cardEnemiesInRange(enc, enc.player.x, enc.player.y, card.aoe || 0);
for (const e of inRange) cardDamageEnemy(enc, e, card.damage || 0);
};
if (card.name === "Tsunami") return (enc) => {
const inRange = cardEnemiesInRange(enc, enc.player.x, enc.player.y, card.aoe || 0);
for (const e of inRange) cardKnockback(enc, e, card.knockback || 0);
};
if (card.name === "Think") return (enc) => cardDrawCards(enc, card.draw || 0);
if (card.name === "Ruminate") return (enc) => cardDrawCards(enc, card.draw || 0);
if (card.name === "Ideate") return (enc) => cardDrawCards(enc, card.draw || 0);
if (card.name === "Ritual") return (enc) => { enc.player.mana += card.manaGain || 0; };
if (card.name === "Flash") return (enc) => { enc.player.mana += card.manaGain || 0; };
if (card.name === "Brain Surge") return (enc) => cardDrawCards(enc, (card.drawPerCard || 0) * enc.cardsPlayedThisTurn);
if (card.name === "Barrage") return (enc, target) => {
if (target) cardDamageEnemy(enc, target, (card.damagePerCard || 0) * enc.cardsPlayedThisTurn);
};
if (card.name === "Haste") return (enc) => { enc.nextCardQuick = true; };
if (card.name === "Novice Bolt") return (enc, target) => target && cardDamageEnemy(enc, target, card.damage || 0);
if (card.name === "Frantic Planning") return (enc) => cardDrawCards(enc, card.draw || 0);
if (card.name === "Faint Glimmer") return (enc) => { enc.player.mana += card.manaGain || 0; };
return (enc) => {};
}
function cardDamageEnemy(enc, enemy, amount) {
enemy.hp -= amount;
if (enemy.hp <= 0) {
enc.enemies = enc.enemies.filter((e) => e !== enemy);
if (enc.selectedEnemy === enemy) enc.selectedEnemy = null;
}
}
function cardDrawCards(enc, count) {
for (let i = 0; i < count; i++) {
if (enc.deck.length === 0) {
enc.deck = [...enc.discardPile];
enc.discardPile = [];
shuffleCardDeck(enc.deck);
}
if (enc.deck.length > 0) enc.hand.push(enc.deck.pop());
}
}
function shuffleCardDeck(deck) {
for (let i = deck.length - 1; i > 0; i--) {
const j = floor(random(i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
}
function cardIsPositionOccupied(enc, x, y) {
if (enc.player.x === x && enc.player.y === y) return true;
return enc.enemies.some((e) => e.x === x && e.y === y);
}
function cardApplyAttacksOfOpportunity(enc) {
const px = enc.player.x;
const py = enc.player.y;
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue;
const ex = px + dx;
const ey = py + dy;
const enemy = enc.enemies.find((e) => e.x === ex && e.y === ey);
if (enemy) enc.player.hp -= enemy.damage;
}
}
}
function cardMoveEnemyTowardPlayer(enc, enemy) {
const dx = enc.player.x - enemy.x;
const dy = enc.player.y - enemy.y;
if (Math.abs(dx) + Math.abs(dy) === 1) {
enc.player.hp -= enemy.damage;
return;
}
if (Math.abs(dx) > Math.abs(dy)) {
const nx = enemy.x + Math.sign(dx);
if (!cardIsPositionOccupied(enc, nx, enemy.y)) enemy.x = nx;
} else if (dy !== 0) {
const ny = enemy.y + Math.sign(dy);
if (!cardIsPositionOccupied(enc, enemy.x, ny)) enemy.y = ny;
}
}
function cardEndTurn(enc, drawCard = false) {
enc.turnInProgress = true;
for (const enemy of enc.enemies) {
if (enemy.frozen > 0) { enemy.frozen--; continue; }
cardMoveEnemyTowardPlayer(enc, enemy);
}
if (drawCard) cardDrawCards(enc, 1);
enc.cardsPlayedThisTurn = 0;
enc.turnInProgress = false;
}
function cardPlayCard(enc, card, targetX, targetY) {
let target = null;
const needsTargetCell = (card.range > 0 || card.aoe) && !card.allInRange;
if (needsTargetCell) {
target = enc.enemies.find((e) => e.x === targetX && e.y === targetY);
const d = cardDist(enc.player.x, enc.player.y, targetX, targetY);
if (d > (card.range || 0) + CARD_DIST_FUDGE) return;
if (!target && (card.damage || card.knockback || card.damagePerCard) && !card.aoe) return;
}
enc.player.mana -= card.manaCost;
enc.hand = enc.hand.filter((c) => c !== card);
enc.discardPile.push(card);
enc.cardsPlayedThisTurn++;
const effectiveQuick = card.quick || enc.nextCardQuick;
if (enc.nextCardQuick) enc.nextCardQuick = false;
card.effect(enc, target, targetX, targetY);
enc.selectedCard = null;
enc.targeting = false;
if (!effectiveQuick) cardEndTurn(enc);
}
function cardCheckEncounterEnd() {
const enc = state.encounter;
if (!enc) return null;
if (enc.player.hp <= 0) return false;
if (enc.enemies.length === 0) return true;
return null;
}
function beginEncounterTransition(danger, options) {
if (state.mode !== "overworld") return;
state.mode = "transition_to_encounter";
state.transition = { phase: "out", t: 0, outDuration: 0.8, inDuration: 0.9, danger, options };
}
function drawBlackOverlay(alpha) {
if (alpha <= 0) return;
const ctx = state.gameCtx;
if (!ctx) return;
ctx.fillStyle = `rgba(0,0,0,${constrain(alpha, 0, 255) / 255})`;
ctx.fillRect(0, 0, width, height);
}
function updateEncounterTransition(dt) {
const tr = state.transition;
if (!tr) return;
if (tr.phase === "out") {
tr.t += dt;
const p = constrain(tr.t / tr.outDuration, 0, 1);
state.cameraZoomMul = lerp(1, 1.34, easeInOutCubic(p));
drawWorld();
drawHud();
drawBlackOverlay(Math.pow(p, 1.05) * 255);
if (p >= 1) {
createCardGameEncounter(tr.danger, tr.options || null);
tr.phase = "in";
tr.t = 0;
state.cameraZoomMul = 1;
}
return;
}
tr.t += dt;
const p = constrain(tr.t / tr.inDuration, 0, 1);
state.battleZoomMul = lerp(1.22, 1, easeInOutCubic(p));
drawEncounterWorld();
drawEncounterCardUi();
drawBlackOverlay((1 - p) * 255);
if (p >= 1) {
state.battleZoomMul = 1;
state.mode = "encounter";
state.transition = null;
}
}
function endCardEncounter(victory) {
const enc = state.encounter;
if (!enc || !state.player) return;
const special = enc.special;
if (victory) {
const reward = floor(6 + enc.danger * 24 + random(0, 8));
const bonusGold = special?.bonusGold || 0;
const bonusMana = special?.bonusMana || 0;
state.player.gold += reward + bonusGold;
state.player.mana = min(state.player.manaMax, state.player.mana + 10 + enc.danger * 15 + bonusMana);
state.player.hp = enc.player.hp;
if (special?.siteTownId != null) {
const t = state.towns[special.siteTownId];
if (t) t.siteCleared = true;
}
setMessage(`Encounter won! +${reward + bonusGold}g`);
} else {
const loss = floor(8 + enc.danger * 20);
state.player.gold = max(0, state.player.gold - loss);
state.player.hp = max(1, floor(state.player.hpMax * 0.45));
state.player.mana = min(state.player.mana, 40);
state.player.targetTown = null;
state.player.progress = 0;
const at = state.towns[state.player.atTown];
state.player.x = at.x;
state.player.y = at.y;
setMessage(`Defeated in encounter. Lost ${loss}g and retreated.`);
}
const enemyLines = Object.entries(enc.enemyRoster)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => `${name} x${count}`);
state.pendingEncounterReport = {
outcome: victory ? "Victory" : "Defeat",
type: special?.label || "Roaming encounter",
dangerPct: (enc.danger * 100).toFixed(0),
enemies: enemyLines,
goldDelta: state.player.gold - enc.startGold,
hpDelta: state.player.hp - enc.startHp
};
state.nextEncounterAt = state.worldTime + 3.0;
document.getElementById("cardUiRoot").classList.remove("visible");
state.hoveredCard = null;
state.mode = "transition_to_overworld";
state.transition = { phase: "out", t: 0, outDuration: 0.55, inDuration: 0.72 };
}
function getCardGameArea() {
const gameW = width;
const gameH = max(1, height - CARD_UI_HEIGHT);
const gridAreaW = gameW - CARD_GRID_PADDING * 2;
const gridAreaH = gameH - CARD_GRID_PADDING * 2;
const cellSize = min(gridAreaW, gridAreaH) / CARD_GRID_SIZE;
const gridPxW = CARD_GRID_SIZE * cellSize;
const gridPxH = CARD_GRID_SIZE * cellSize;
const offsetX = (gameW - gridPxW) / 2;
const offsetY = (gameH - gridPxH) / 2;
return { gameW, gameH, vw: CARD_GRID_SIZE, vh: CARD_GRID_SIZE, cellSize, offsetX, offsetY };
}
function cardWorldToScreen(wx, wy, area) {
return {
x: area.offsetX + wx * area.cellSize + area.cellSize / 2,
y: area.offsetY + wy * area.cellSize + area.cellSize / 2
};
}
function cardScreenToWorld(sx, sy, area) {
const gx = floor((sx - area.offsetX) / area.cellSize);
const gy = floor((sy - area.offsetY) / area.cellSize);
return { gx, gy, x: gx, y: gy };
}
function drawEncounterWorld() {
const enc = state.encounter;
const ctx = state.gameCtx;
if (!enc || !ctx) return;
const area = getCardGameArea();
ctx.clearRect(0, 0, width, height);
const encTerrainScale = min(area.gameW, area.gameH) / 75 * state.battleZoomMul;
renderTerrainRenderer(enc.originWorldX, enc.originWorldY, encTerrainScale);
const encWorldScale = encTerrainScale * (width / Math.max(window.innerWidth, 1));
const halfViewW = width * 0.5 / encWorldScale;
const halfViewH = height * 0.5 / encWorldScale;
const encCenterX = width * 0.5;
const encCenterY = height * 0.5;
ctx.save();
ctx.translate(encCenterX, encCenterY);
ctx.scale(encWorldScale, encWorldScale);
ctx.translate(-enc.originWorldX, -enc.originWorldY);
const playerTown = state.player ? state.player.atTown : -1;
for (const e of state.edges) {
const a = state.towns[e.a], b = state.towns[e.b];
const connectedToCurrent = e.a === playerTown || e.b === playerTown;
ctx.strokeStyle = connectedToCurrent ? "rgba(180,140,90,0.9)" : "rgba(120,95,65,0.6)";
ctx.lineWidth = (connectedToCurrent ? 3 : 2) / encWorldScale;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
ctx.restore();
ctx.save();
ctx.translate(area.offsetX, area.offsetY);
for (let gx = 0; gx < area.vw; gx++) {
for (let gy = 0; gy < area.vh; gy++) {
ctx.strokeStyle = "rgba(50,50,70,0.7)";
ctx.lineWidth = 1;
ctx.strokeRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
}
}
const drawEntity = (wx, wy, emoji, opts) => {
if (wx < 0 || wx >= area.vw || wy < 0 || wy >= area.vh) return;
if (opts && opts.frozen > 0) {
ctx.save();
ctx.globalAlpha = 0.7;
ctx.fillStyle = "rgba(150,200,255,0.5)";
ctx.fillRect(wx * area.cellSize, wy * area.cellSize, area.cellSize, area.cellSize);
ctx.restore();
}
const lx = wx * area.cellSize + area.cellSize / 2;
const ly = wy * area.cellSize + area.cellSize / 2;
if (opts && opts.maxHp != null && opts.hp < opts.maxHp) {
const barW = area.cellSize * 0.8;
const barH = 4;
const left = lx - barW / 2;
const top = ly - area.cellSize * 0.5;
ctx.fillStyle = "rgba(40,40,50,0.9)";
ctx.fillRect(left, top, barW, barH);
ctx.fillStyle = opts.hp <= 0 ? "rgb(80,80,80)" : "rgb(220,60,60)";
ctx.fillRect(left, top, barW * (opts.hp / opts.maxHp), barH);
ctx.strokeStyle = "rgba(255,255,255,0.4)";
ctx.lineWidth = 1;
ctx.strokeRect(left, top, barW, barH);
}
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `${max(28, area.cellSize * 0.7)}px monospace`;
ctx.fillStyle = "#e6e6e6";
ctx.fillText(emoji, lx, ly);
};
drawEntity(enc.player.x, enc.player.y, "๐ง", { hp: enc.player.hp, maxHp: enc.player.maxHp });
for (const enemy of enc.enemies) drawEntity(enemy.x, enemy.y, enemy.emoji, { hp: enemy.hp, maxHp: enemy.maxHp, frozen: enemy.frozen || 0 });
const hovered = state.hoveredCard;
if (!enc.targeting && hovered && enc.hand.includes(hovered)) {
const px = enc.player.x, py = enc.player.y;
if (hovered.aoe && hovered.allInRange) {
ctx.fillStyle = "rgba(255,100,80,0.35)";
for (let gx = 0; gx < area.vw; gx++) {
for (let gy = 0; gy < area.vh; gy++) {
if (cardDist(gx, gy, px, py) <= hovered.aoe + CARD_DIST_FUDGE) {
ctx.fillRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
}
}
}
}
if (hovered.range > 0) {
ctx.fillStyle = "rgba(255,255,255,0.3)";
for (let gx = 0; gx < area.vw; gx++) {
for (let gy = 0; gy < area.vh; gy++) {
if (cardDist(gx, gy, px, py) <= hovered.range + CARD_DIST_FUDGE) {
ctx.fillRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
}
}
}
}
}
if (enc.targeting && enc.selectedCard) {
const card = enc.selectedCard;
const px = enc.player.x, py = enc.player.y;
ctx.fillStyle = "rgba(255,255,255,0.3)";
for (let gx = 0; gx < area.vw; gx++) {
for (let gy = 0; gy < area.vh; gy++) {
if (cardDist(gx, gy, px, py) <= card.range + CARD_DIST_FUDGE) {
ctx.fillRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
}
}
}
const mx = state.cardGameMouseX;
const my = state.cardGameMouseY;
if (mx >= area.offsetX && my >= area.offsetY && mx < area.offsetX + area.vw * area.cellSize && my < area.offsetY + area.vh * area.cellSize) {
const pt = cardScreenToWorld(mx, my, area);
if (pt.gx >= 0 && pt.gx < area.vw && pt.gy >= 0 && pt.gy < area.vh) {
if (cardDist(pt.x, pt.y, px, py) <= card.range + CARD_DIST_FUDGE) {
ctx.fillStyle = "rgba(255,255,100,0.4)";
ctx.fillRect(pt.gx * area.cellSize, pt.gy * area.cellSize, area.cellSize, area.cellSize);
if (card.aoe && !card.allInRange) {
ctx.fillStyle = "rgba(255,100,80,0.35)";
for (let gx = 0; gx < area.vw; gx++) {
for (let gy = 0; gy < area.vh; gy++) {
if (cardDist(gx, gy, pt.x, pt.y) <= card.aoe + CARD_DIST_FUDGE) {
ctx.fillRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
}
}
}
}
}
}
}
}
ctx.restore();
ctx.save();
ctx.translate(encCenterX, encCenterY);
ctx.scale(encWorldScale, encWorldScale);
ctx.translate(-enc.originWorldX, -enc.originWorldY);
const m = TREE_EMOJI_CULL_MARGIN;
const gridLeft = area.offsetX - m;
const gridRight = area.offsetX + area.vw * area.cellSize + m;
const gridTop = area.offsetY - m;
const gridBottom = area.offsetY + area.vh * area.cellSize + m;
ctx.font = "20px 'Segoe UI Emoji', sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#e6e6e6";
for (const t of state.forestTrees) {
if (t.x < enc.originWorldX - halfViewW - 60 || t.x > enc.originWorldX + halfViewW + 60) continue;
if (t.y < enc.originWorldY - halfViewH - 60 || t.y > enc.originWorldY + halfViewH + 60) continue;
const sx = encCenterX + (t.x - enc.originWorldX) * encWorldScale;
const sy = encCenterY + (t.y - enc.originWorldY) * encWorldScale;
if (sx >= gridLeft && sx <= gridRight && sy >= gridTop && sy <= gridBottom) continue;
ctx.fillText(t.emoji, t.x, t.y);
}
ctx.restore();
}
function cardHtml(card) {
const actionTag = card.quick ? "quick" : "1 action";
return `<div class="cardHeader"><span>${card.quick ? "โก" : "โฑ๏ธ"} ${card.name}</span><span>โก${card.manaCost}</span></div>
<div class="cardEmoji">${card.emoji}</div><div class="cardDesc">${card.description}</div><div class="cardDesc" style="font-size:10px;margin-top:4px;">${actionTag}</div>`;
}
function handRenderKey(enc) {
const selectedIndex = enc.hand.indexOf(enc.selectedCard);
const handSignature = enc.hand.map((c) => `${c.name}|${c.manaCost}|${c.quick}`).join(";");
const cardW = Math.min(140, (width - CARD_STATS_WIDTH - CARD_ENEMY_WIDTH - 60 - (9 * CARD_HAND_PADDING)) / 10);
const cardH = cardW * 1.4;
return [cardW, cardH, enc.player.mana, selectedIndex, handSignature].join("::");
}
function drawEncounterCardUi() {
const enc = state.encounter;
if (!enc) return;
const statsPanel = document.getElementById("cardStatsPanel");
const enemyPanel = document.getElementById("cardEnemyPanel");
const handPanel = document.getElementById("cardHandPanel");
statsPanel.innerHTML = `
<div>HP: ${enc.player.hp}/${enc.player.maxHp}</div>
<div style="margin-top:4px;">โก Mana: ${enc.player.mana}</div>
<div style="margin-top:4px;">Spells played: ${enc.cardsPlayedThisTurn}</div>
<div style="margin-top:4px;">๐ Deck: ${enc.deck.length}</div>
<div style="margin-top:4px;">๐๏ธ Discard: ${enc.discardPile.length}</div>
<div class="card-help">
<div>WASD/Arrows: Move</div>
<div>.: Wait</div>
<div>Hover enemy: Info</div>
</div>
`;
if (!enc.selectedEnemy) {
enemyPanel.innerHTML = `<div style="margin-top:72px;color:rgb(180,180,180);font-size:11px;">Hover an enemy</div><div style="color:rgb(180,180,180);font-size:11px;">to view info</div>`;
} else {
const e = enc.selectedEnemy;
enemyPanel.innerHTML = `<div>${e.name}</div><div class="enemyEmoji">${e.emoji}</div><div>HP: ${e.hp}/${e.maxHp}</div><div style="margin-top:6px;">Damage: ${e.damage}</div>${(e.frozen || 0) > 0 ? `<div style="margin-top:4px;color:rgb(150,200,255);">Frozen: ${e.frozen}</div>` : ""}`;
}
const key = handRenderKey(enc);
if (key !== state.lastHandRenderKey) {
state.lastHandRenderKey = key;
state.hoveredCard = null;
const cardW = Math.min(140, (width - CARD_STATS_WIDTH - CARD_ENEMY_WIDTH - 60 - 9 * CARD_HAND_PADDING) / 10);
const cardH = cardW * 1.4;
handPanel.innerHTML = "";
handPanel.style.gap = `${CARD_HAND_PADDING}px`;
for (const card of enc.hand) {
const el = document.createElement("div");
el.className = "card";
el.style.width = `${cardW}px`;
el.style.height = `${cardH}px`;
el.innerHTML = cardHtml(card);
const selected = enc.selectedCard === card;
const disabled = card.manaCost > enc.player.mana;
if (selected) el.classList.add("selected");
if (disabled) el.classList.add("disabled");
el.addEventListener("mouseenter", () => { state.hoveredCard = card; });
el.addEventListener("mouseleave", () => { state.hoveredCard = null; });
el.addEventListener("click", () => {
if (disabled) return;
if (card.range === 0) {
cardPlayCard(enc, card, enc.player.x, enc.player.y);
return;
}
const wasSelected = enc.selectedCard === card;
enc.selectedCard = wasSelected ? null : card;
enc.targeting = !wasSelected;
});
handPanel.appendChild(el);
}
}
}
function updateReturnToOverworldTransition(dt) {
const tr = state.transition;
if (!tr) return;
if (tr.phase === "out") {
tr.t += dt;
const p = constrain(tr.t / tr.outDuration, 0, 1);
state.battleZoomMul = lerp(1, 1.2, easeInOutCubic(p));
drawEncounterWorld();
drawEncounterCardUi();
drawMessageToast();
drawBlackOverlay(Math.pow(p, 1.05) * 255);
if (p >= 1) {
state.encounter = null;
tr.phase = "in";
tr.t = 0;
state.cameraZoomMul = 1.2;
state.battleZoomMul = 1;
}
return;
}
tr.t += dt;
const p = constrain(tr.t / tr.inDuration, 0, 1);
state.cameraZoomMul = lerp(1.2, 1, easeInOutCubic(p));
drawWorld();
drawHud();
drawBlackOverlay((1 - p) * 255);
if (p >= 1) {
state.mode = "overworld";
state.postEncounterReport = state.pendingEncounterReport;
state.pendingEncounterReport = null;
state.transition = null;
state.cameraZoomMul = 1;
state.battleZoomMul = 1;
}
}
function formatSigned(n) {
const rounded = Math.round(n);
return rounded > 0 ? `+${rounded}` : `${rounded}`;
}
function drawEncounterReportModal() {
const overlay = document.getElementById("encounterReportOverlay");
const panel = document.getElementById("encounterReportPanel");
const rep = state.postEncounterReport;
if (!rep || state.mode !== "overworld") {
overlay.classList.add("hidden");
state.lastReportRendered = null;
return;
}
overlay.classList.remove("hidden");
if (state.lastReportRendered === rep) return;
state.lastReportRendered = rep;
const enemyLines = rep.enemies.length === 0 ? "<div>- None</div>" : rep.enemies.map((line) => `<div>- ${line}</div>`).join("");
panel.innerHTML = `
<h2>Encounter Report</h2>
<div style="font-size:15px;margin-bottom:8px;">Outcome: ${rep.outcome} Type: ${rep.type} Danger: ${rep.dangerPct}%</div>
<div style="font-size:15px;margin-bottom:8px;">Gold: ${formatSigned(rep.goldDelta)} HP: ${formatSigned(rep.hpDelta)}</div>
<div style="font-size:16px;margin:12px 0 8px 0;">Enemies Encountered:</div>
<div style="font-size:14px;line-height:1.5;">${enemyLines}</div>
<button class="report-btn">Continue</button>
`;
}
function drawMessageToast() {
const toast = document.getElementById("messageToast");
if (state.messageTimer <= 0) {
toast.classList.add("hidden");
return;
}
const isEncounter = state.mode === "encounter" || state.mode === "transition_to_encounter" || (state.transition && state.transition.phase === "in");
toast.style.bottom = isEncounter ? `${CARD_UI_HEIGHT + 12}px` : "12px";
toast.classList.remove("hidden");
toast.textContent = state.message;
toast.style.maxWidth = `${min(680, width - 24)}px`;
}
function resizeGameCanvas() {
const canvas = document.getElementById("gameCanvas");
width = Math.max(1, window.innerWidth);
height = Math.max(1, window.innerHeight);
canvas.width = width;
canvas.height = height;
state.gameCanvas = canvas;
state.gameCtx = canvas.getContext("2d");
}
function init() {
resizeGameCanvas();
state.terrainRenderer = createTerrainRenderer();
resizeTerrainRenderer();
generateMap();
const reportOverlay = document.getElementById("encounterReportOverlay");
reportOverlay.addEventListener("click", () => {
if (state.postEncounterReport) {
state.postEncounterReport = null;
reportOverlay.classList.add("hidden");
}
});
document.getElementById("hudPanel").addEventListener("click", (e) => {
const buyBtn = e.target.closest(".shop-btn[data-card]");
if (buyBtn) {
if (state.mode === "overworld") tryBuyCard(buyBtn.dataset.card, parseInt(buyBtn.dataset.price, 10));
return;
}
if (e.target.closest(".deck-view-btn")) openDeckView();
});
document.getElementById("deckViewOverlay").addEventListener("click", (e) => {
if (e.target === document.getElementById("deckViewOverlay")) closeDeckView();
});
window.addEventListener("mousedown", onMouseDown);
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("resize", () => {
resizeGameCanvas();
resizeTerrainRenderer();
});
let lastTime = performance.now();
function loop(t) {
const dt = Math.min((t - lastTime) / 1000, 0.05);
lastTime = t;
tick(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
}
document.addEventListener("DOMContentLoaded", init);
function tick(dt) {
frameCount++;
state.worldTime += dt;
if (state.messageTimer > 0) state.messageTimer -= dt;
if (state.mode === "overworld") {
if (state.player) updateTravel(dt);
drawWorld();
drawHud();
drawEncounterReportModal();
} else if (state.mode === "transition_to_encounter") {
updateEncounterTransition(dt);
} else if (state.mode === "transition_to_overworld") {
updateReturnToOverworldTransition(dt);
} else if (state.mode === "encounter") {
const enc = state.encounter;
const result = cardCheckEncounterEnd();
if (result === true) endCardEncounter(true);
else if (result === false) endCardEncounter(false);
else {
drawEncounterWorld();
drawEncounterCardUi();
drawMessageToast();
}
}
state.input.keyDownEvents.clear();
state.input.lmbDown = false;
state.input.lmbUp = false;
}
function onMouseDown(e) {
state.input.heldLMB = true;
state.input.lmbDown = true;
state.mouseX = e.clientX;
state.mouseY = e.clientY;
if (state.mode === "overworld") {
if (state.postEncounterReport) return; // handled by overlay click
if (e.target.closest("#hudPanel, #cardUiRoot, #encounterReportOverlay, #deckViewOverlay")) return;
onWorldClick(state.mouseX, state.mouseY);
return;
}
if (state.mode === "encounter") {
const enc = state.encounter;
if (!enc) return;
const area = getCardGameArea();
if (state.mouseY >= area.gameH) return;
if (state.mouseX < area.offsetX || state.mouseX >= area.offsetX + area.vw * area.cellSize ||
state.mouseY < area.offsetY || state.mouseY >= area.offsetY + area.vh * area.cellSize) return;
const pt = cardScreenToWorld(state.mouseX, state.mouseY, area);
if (enc.targeting && enc.selectedCard) {
if (pt.gx >= 0 && pt.gx < area.vw && pt.gy >= 0 && pt.gy < area.vh) {
const d = cardDist(pt.x, pt.y, enc.player.x, enc.player.y);
if (d <= enc.selectedCard.range + CARD_DIST_FUDGE) {
cardPlayCard(enc, enc.selectedCard, pt.x, pt.y);
}
}
return;
}
}
}
function onMouseUp() {
state.input.heldLMB = false;
state.input.lmbUp = true;
}
function onMouseMove(e) {
state.mouseX = e.clientX;
state.mouseY = e.clientY;
const area = getCardGameArea();
if (state.mouseY < area.gameH) {
state.cardGameMouseX = state.mouseX;
state.cardGameMouseY = state.mouseY;
const enc = state.encounter;
if (enc && (state.mode === "encounter" || state.mode === "transition_to_encounter" || state.mode === "transition_to_overworld")) {
if (state.mouseX >= area.offsetX && state.mouseX < area.offsetX + area.vw * area.cellSize &&
state.mouseY >= area.offsetY && state.mouseY < area.offsetY + area.vh * area.cellSize) {
const pt = cardScreenToWorld(state.mouseX, state.mouseY, area);
enc.selectedEnemy = enc.enemies.find((e) => e.x === pt.x && e.y === pt.y) || null;
} else {
enc.selectedEnemy = null;
}
}
} else {
state.cardGameMouseX = -1;
state.cardGameMouseY = -1;
if (state.encounter) state.encounter.selectedEnemy = null;
}
}
function onKeyDown(e) {
const k = (e.key || "").toLowerCase();
state.input.heldKeys.add(k);
state.input.keyDownEvents.add(k);
if (state.mode === "overworld") {
if (state.deckViewOpen && k === "escape") {
closeDeckView();
e.preventDefault();
return;
}
if (state.postEncounterReport && (k === " " || k === "enter" || k === "escape")) {
state.postEncounterReport = null;
document.getElementById("encounterReportOverlay").classList.add("hidden");
e.preventDefault();
return;
}
if (k === "r") { generateMap(); e.preventDefault(); return; }
if (k === "e") {
state.encountersEnabled = !state.encountersEnabled;
setMessage(`Encounters ${state.encountersEnabled ? "enabled" : "disabled"}.`);
e.preventDefault();
return;
}
}
if (state.mode === "encounter") {
const enc = state.encounter;
if (!enc || enc.turnInProgress) return;
if (k === ".") {
cardEndTurn(enc, true);
e.preventDefault();
return;
}
const deltas = {
w: [0, -1], a: [-1, 0], s: [0, 1], d: [1, 0],
arrowup: [0, -1], arrowleft: [-1, 0], arrowdown: [0, 1], arrowright: [1, 0]
};
const delta = deltas[k];
if (delta) {
const nx = enc.player.x + delta[0];
const ny = enc.player.y + delta[1];
if (nx >= 0 && nx < CARD_GRID_SIZE && ny >= 0 && ny < CARD_GRID_SIZE) {
const enemy = enc.enemies.find((e) => e.x === nx && e.y === ny);
if (enemy) cardDamageEnemy(enc, enemy, 1);
else {
enc.player.x = nx;
enc.player.y = ny;
cardApplyAttacksOfOpportunity(enc);
}
cardEndTurn(enc, true);
}
e.preventDefault();
}
}
}
function onKeyUp(e) {
state.input.heldKeys.delete((e.key || "").toLowerCase());
}
</script>
</body>
</html>
remixes
no remixes yet... email to remix@gameslop.net
