first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
client/dist/
|
||||||
|
server/dist/
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Soulless Necromancer
|
||||||
|
|
||||||
|
Web multiplayer necromancer game: build a zombie or shadow army, roam a shared map, and delve into click-combat crypts.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Client:** React + Vite + TypeScript
|
||||||
|
- **Server:** Node.js + Express + Socket.io + TypeScript
|
||||||
|
- **Database:** PostgreSQL + Prisma
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- Docker (for PostgreSQL)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repo root
|
||||||
|
npm install
|
||||||
|
npm run db:setup # starts Postgres on :5433, pushes schema, seeds items
|
||||||
|
npm run dev # API :3001 + Vite :5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:5173](http://localhost:5173).
|
||||||
|
|
||||||
|
Postgres runs on **port 5433** by default (see `server/.env`) to avoid clashing with a local Postgres on 5432.
|
||||||
|
|
||||||
|
## Play loop
|
||||||
|
|
||||||
|
1. **Register** with email, password, character name, and specialty (`ZOMBIE` or `SHADOW`).
|
||||||
|
2. **World map** — click tiles to move (max 3 Manhattan steps). Other online players appear live.
|
||||||
|
3. **Portal** — stand next to the pulsing portal tile and click it to enter your personal dungeon instance.
|
||||||
|
4. **Dungeon** — click empty tiles to **move** (Move mode) or switch to **Summon** to place units. Summons walk toward enemies when nothing is in range; combat is automatic in range. Clear the floor for XP, loot, and floor progression.
|
||||||
|
5. **Progression** — spend stat points on level-up; equip relics from the Relics panel. Outside dungeons, HP and energy regenerate slowly while you are online on the world map.
|
||||||
|
|
||||||
|
## Useful scripts
|
||||||
|
|
||||||
|
| Script | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `npm run db:up` | Start Postgres container |
|
||||||
|
| `npm run db:migrate` | Apply Prisma migrations |
|
||||||
|
| `npm run db:seed` | Seed item catalogue |
|
||||||
|
| `npm run dev` | Run client + server |
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
Server env lives in `server/.env` (see defaults for local Docker Postgres).
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Soulless Necromancer</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=Source+Sans+3:wght@400;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.4.0",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { useAuth } from "./state/AuthContext";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Register from "./pages/Register";
|
||||||
|
import Game from "./pages/Game";
|
||||||
|
|
||||||
|
function Protected({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <div className="boot">Awakening the void...</div>;
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route
|
||||||
|
path="/game"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<Game />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/game" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Character } from "../types";
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error ?? "Request failed");
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () =>
|
||||||
|
request<{ user: { id: string; email: string }; character: Character }>("/api/auth/me"),
|
||||||
|
login: (body: { email: string; password: string }) =>
|
||||||
|
request<{ user: { id: string; email: string }; character: Character }>("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
register: (body: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
characterName: string;
|
||||||
|
specialty: "ZOMBIE" | "SHADOW";
|
||||||
|
}) =>
|
||||||
|
request<{ user: { id: string; email: string }; character: Character }>("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }),
|
||||||
|
allocateStats: (body: { str?: number; dex?: number; int?: number }) =>
|
||||||
|
request<{ character: Character }>("/api/character/stats", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
equip: (inventoryItemId: string, equipped: boolean) =>
|
||||||
|
request<{ character: Character }>("/api/inventory/equip", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ inventoryItemId, equipped }),
|
||||||
|
}),
|
||||||
|
useItem: (inventoryItemId: string) =>
|
||||||
|
request<{ character: Character }>("/api/inventory/use", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ inventoryItemId }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Character } from "../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
character: Character;
|
||||||
|
onUpdate: (c: Character) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAT_INFO = {
|
||||||
|
str: {
|
||||||
|
label: "STR",
|
||||||
|
description:
|
||||||
|
"Strength. Raises melee strike power in the crypt. Each point spent also increases max HP.",
|
||||||
|
},
|
||||||
|
dex: {
|
||||||
|
label: "DEX",
|
||||||
|
description:
|
||||||
|
"Dexterity. Favors Shadow vessels — improves agility and finesse of your legion.",
|
||||||
|
},
|
||||||
|
int: {
|
||||||
|
label: "INT",
|
||||||
|
description:
|
||||||
|
"Intellect. Empowers dark magic and summons. Each point spent also raises max energy.",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function CharacterPanel({ character, onUpdate }: Props) {
|
||||||
|
const [pending, setPending] = useState({ str: 0, dex: 0, int: 0 });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const spent = pending.str + pending.dex + pending.int;
|
||||||
|
const remaining = character.unspentStatPoints - spent;
|
||||||
|
|
||||||
|
function bump(stat: "str" | "dex" | "int", delta: number) {
|
||||||
|
setPending((p) => {
|
||||||
|
const next = Math.max(0, p[stat] + delta);
|
||||||
|
const total = Object.values({ ...p, [stat]: next }).reduce((a, b) => a + b, 0);
|
||||||
|
if (total > character.unspentStatPoints) return p;
|
||||||
|
return { ...p, [stat]: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (spent <= 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { character: updated } = await api.allocateStats(pending);
|
||||||
|
onUpdate(updated);
|
||||||
|
setPending({ str: 0, dex: 0, int: 0 });
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const armyTotal = character.army.reduce((s, u) => s + u.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel character-panel">
|
||||||
|
<header>
|
||||||
|
<h2>{character.name}</h2>
|
||||||
|
<p>
|
||||||
|
Lv {character.level} · {character.specialty} Legion
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bars">
|
||||||
|
<div className="bar">
|
||||||
|
<span>HP</span>
|
||||||
|
<div>
|
||||||
|
<i style={{ width: `${(character.hp / character.maxHp) * 100}%` }} className="hp" />
|
||||||
|
</div>
|
||||||
|
<em>
|
||||||
|
{character.hp}/{character.maxHp}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
<div className="bar">
|
||||||
|
<span>Energy</span>
|
||||||
|
<div>
|
||||||
|
<i
|
||||||
|
style={{ width: `${(character.energy / character.maxEnergy) * 100}%` }}
|
||||||
|
className="en"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<em>
|
||||||
|
{character.energy}/{character.maxEnergy}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
<div className="bar">
|
||||||
|
<span>XP</span>
|
||||||
|
<div>
|
||||||
|
<i style={{ width: `${(character.xp / character.xpToNext) * 100}%` }} className="xp" />
|
||||||
|
</div>
|
||||||
|
<em>
|
||||||
|
{character.xp}/{character.xpToNext}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats">
|
||||||
|
{(["str", "dex", "int"] as const).map((stat) => (
|
||||||
|
<div key={stat} className="stat-row">
|
||||||
|
<span className="stat-label" tabIndex={0}>
|
||||||
|
{STAT_INFO[stat].label}
|
||||||
|
<span className="stat-tooltip" role="tooltip">
|
||||||
|
{STAT_INFO[stat].description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
{character[stat]}
|
||||||
|
{pending[stat] > 0 ? ` +${pending[stat]}` : ""}
|
||||||
|
<small> → {character.effective[stat] + pending[stat]}</small>
|
||||||
|
</strong>
|
||||||
|
<div className="stat-btns">
|
||||||
|
<button type="button" onClick={() => bump(stat, -1)} disabled={pending[stat] <= 0}>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => bump(stat, 1)} disabled={remaining <= 0}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="points">
|
||||||
|
Unspent points: <strong>{remaining}</strong>
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={apply} disabled={busy || spent === 0}>
|
||||||
|
Apply points
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="army-summary">
|
||||||
|
<h3>Army ({armyTotal})</h3>
|
||||||
|
{character.army.length === 0 ? (
|
||||||
|
<p className="muted">Summon units in the dungeon to grow your legion.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{character.army.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
{u.type} T{u.tier} × {u.count}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { Character, DungeonState } from "../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
state: DungeonState;
|
||||||
|
character: Character;
|
||||||
|
onMove: (x: number, y: number) => void;
|
||||||
|
onSummon: (x: number, y: number) => void;
|
||||||
|
onExit: () => void;
|
||||||
|
flash?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DungeonView({
|
||||||
|
state,
|
||||||
|
character,
|
||||||
|
onMove,
|
||||||
|
onSummon,
|
||||||
|
onExit,
|
||||||
|
flash,
|
||||||
|
}: Props) {
|
||||||
|
const [mode, setMode] = useState<"move" | "summon">("move");
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
for (let y = 0; y < state.height; y++) {
|
||||||
|
for (let x = 0; x < state.width; x++) {
|
||||||
|
const enemy = state.enemies.find((e) => e.x === x && e.y === y && e.hp > 0);
|
||||||
|
const summon = state.summons.find((s) => s.x === x && s.y === y);
|
||||||
|
const isPlayer = state.playerX === x && state.playerY === y;
|
||||||
|
|
||||||
|
cells.push(
|
||||||
|
<button
|
||||||
|
key={`${x}-${y}`}
|
||||||
|
type="button"
|
||||||
|
className={`dtile ${enemy ? "enemy" : ""} ${summon ? "summon" : ""} ${
|
||||||
|
isPlayer ? "player" : ""
|
||||||
|
} ${flash === `${x},${y}` ? "hit" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (state.over) return;
|
||||||
|
if (enemy || isPlayer || summon) return;
|
||||||
|
if (mode === "summon") onSummon(x, y);
|
||||||
|
else onMove(x, y);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
enemy
|
||||||
|
? `${enemy.name} (auto-attack in range)`
|
||||||
|
: isPlayer
|
||||||
|
? character.name
|
||||||
|
: summon
|
||||||
|
? summon.type
|
||||||
|
: mode === "summon"
|
||||||
|
? "Summon here"
|
||||||
|
: "Move here"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPlayer && <span className="d-avatar self">{character.name[0]}</span>}
|
||||||
|
{enemy && (
|
||||||
|
<span className="d-enemy">
|
||||||
|
<span>{enemy.name.split(" ")[0][0]}</span>
|
||||||
|
<i style={{ width: `${(enemy.hp / enemy.maxHp) * 100}%` }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summon && <span className={`d-summon ${summon.type.toLowerCase()}`}>†</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dungeon-shell">
|
||||||
|
<header className="dungeon-bar">
|
||||||
|
<div>
|
||||||
|
<strong>Crypt Floor {state.floor}</strong>
|
||||||
|
<span> — move freely; summons hunt enemies; attacks auto in range</span>
|
||||||
|
</div>
|
||||||
|
<div className="dungeon-modes">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "move" ? "active" : ""}
|
||||||
|
onClick={() => setMode("move")}
|
||||||
|
>
|
||||||
|
Move
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "summon" ? "active" : ""}
|
||||||
|
onClick={() => setMode("summon")}
|
||||||
|
>
|
||||||
|
Summon {character.specialty === "ZOMBIE" ? "zombie" : "shadow"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost" onClick={onExit}>
|
||||||
|
Leave portal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
className="dungeon-grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${state.width}, var(--dtile))`,
|
||||||
|
gridTemplateRows: `repeat(${state.height}, var(--dtile))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells}
|
||||||
|
</div>
|
||||||
|
<aside className="combat-log">
|
||||||
|
{state.log.slice(-8).map((line, i) => (
|
||||||
|
<p key={`${i}-${line}`}>{line}</p>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Character } from "../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
character: Character;
|
||||||
|
onUpdate: (c: Character) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InventoryPanel({ character, onUpdate }: Props) {
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function toggleEquip(id: string, equipped: boolean) {
|
||||||
|
setBusy(id);
|
||||||
|
try {
|
||||||
|
const { character: updated } = await api.equip(id, !equipped);
|
||||||
|
onUpdate(updated);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useItem(id: string) {
|
||||||
|
setBusy(id);
|
||||||
|
try {
|
||||||
|
const { character: updated } = await api.useItem(id);
|
||||||
|
onUpdate(updated);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel inventory-panel">
|
||||||
|
<header>
|
||||||
|
<h2>Relics</h2>
|
||||||
|
<p>{character.inventory.length} carried</p>
|
||||||
|
</header>
|
||||||
|
{character.inventory.length === 0 ? (
|
||||||
|
<p className="muted">Clear dungeon floors to loot items.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="inv-list">
|
||||||
|
{character.inventory.map((inv) => (
|
||||||
|
<li key={inv.id} className={`rarity-${inv.item.rarity}`}>
|
||||||
|
<div>
|
||||||
|
<strong>{inv.item.name}</strong>
|
||||||
|
<span>
|
||||||
|
{inv.item.slot}
|
||||||
|
{inv.quantity > 1 ? ` ×${inv.quantity}` : ""}
|
||||||
|
{inv.equipped ? " · equipped" : ""}
|
||||||
|
</span>
|
||||||
|
<p>{inv.item.description}</p>
|
||||||
|
<small>
|
||||||
|
+{inv.item.strBonus} STR · +{inv.item.dexBonus} DEX · +{inv.item.intBonus} INT · +
|
||||||
|
{inv.item.hpBonus} HP
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="inv-actions">
|
||||||
|
{inv.item.slot === "CONSUMABLE" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === inv.id}
|
||||||
|
onClick={() => useItem(inv.id)}
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === inv.id}
|
||||||
|
onClick={() => toggleEquip(inv.id, inv.equipped)}
|
||||||
|
>
|
||||||
|
{inv.equipped ? "Unequip" : "Equip"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { WorldPlayer } from "../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
players: WorldPlayer[];
|
||||||
|
portal: { x: number; y: number };
|
||||||
|
selfId: string;
|
||||||
|
onTileClick: (x: number, y: number) => void;
|
||||||
|
onPortalClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MapView({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
players,
|
||||||
|
portal,
|
||||||
|
selfId,
|
||||||
|
onTileClick,
|
||||||
|
onPortalClick,
|
||||||
|
}: Props) {
|
||||||
|
const cells = [];
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const isPortal = x === portal.x && y === portal.y;
|
||||||
|
const here = players.filter((p) => p.x === x && p.y === y);
|
||||||
|
const terrain =
|
||||||
|
(x + y) % 7 === 0 ? "mire" : (x * 3 + y) % 11 === 0 ? "bone" : "soil";
|
||||||
|
|
||||||
|
cells.push(
|
||||||
|
<button
|
||||||
|
key={`${x}-${y}`}
|
||||||
|
type="button"
|
||||||
|
className={`tile ${terrain} ${isPortal ? "portal" : ""}`}
|
||||||
|
style={{ gridColumn: x + 1, gridRow: y + 1 }}
|
||||||
|
onClick={() => {
|
||||||
|
if (isPortal) onPortalClick();
|
||||||
|
else onTileClick(x, y);
|
||||||
|
}}
|
||||||
|
title={isPortal ? "Dungeon Portal" : `(${x}, ${y})`}
|
||||||
|
>
|
||||||
|
{isPortal && <span className="portal-glyph" />}
|
||||||
|
{here.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.characterId}
|
||||||
|
className={`avatar ${p.specialty.toLowerCase()} ${
|
||||||
|
p.characterId === selfId ? "self" : ""
|
||||||
|
}`}
|
||||||
|
title={p.name}
|
||||||
|
>
|
||||||
|
{p.name.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="map-shell">
|
||||||
|
<div
|
||||||
|
className="map-grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${width}, var(--tile))`,
|
||||||
|
gridTemplateRows: `repeat(${height}, var(--tile))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import { AuthProvider } from "./state/AuthContext";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
import { useAuth } from "../state/AuthContext";
|
||||||
|
import { connectGameSocket } from "../sockets/gameSocket";
|
||||||
|
import MapView from "../components/MapView";
|
||||||
|
import DungeonView from "../components/DungeonView";
|
||||||
|
import CharacterPanel from "../components/CharacterPanel";
|
||||||
|
import InventoryPanel from "../components/InventoryPanel";
|
||||||
|
import type { DungeonState, WorldPlayer } from "../types";
|
||||||
|
|
||||||
|
export default function Game() {
|
||||||
|
const { character, setCharacter, logout, user } = useAuth();
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const [players, setPlayers] = useState<WorldPlayer[]>([]);
|
||||||
|
const [portal, setPortal] = useState({ x: 20, y: 15 });
|
||||||
|
const [mapSize, setMapSize] = useState({ width: 40, height: 30 });
|
||||||
|
const [dungeon, setDungeon] = useState<DungeonState | null>(null);
|
||||||
|
const [zone, setZone] = useState<"WORLD" | "DUNGEON">(
|
||||||
|
character?.currentZone ?? "WORLD"
|
||||||
|
);
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
const [sidebar, setSidebar] = useState<"char" | "inv">("char");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!character) return;
|
||||||
|
|
||||||
|
const socket = connectGameSocket({
|
||||||
|
onPlayers: (data) => {
|
||||||
|
setPlayers(data.players);
|
||||||
|
setPortal(data.portal);
|
||||||
|
setMapSize(data.map);
|
||||||
|
},
|
||||||
|
onCharacter: (c) => setCharacter(c),
|
||||||
|
onDungeon: (s) => {
|
||||||
|
setDungeon(s);
|
||||||
|
setZone("DUNGEON");
|
||||||
|
},
|
||||||
|
onZone: (z) => {
|
||||||
|
setZone(z.zone as "WORLD" | "DUNGEON");
|
||||||
|
if (z.zone === "WORLD") setDungeon(null);
|
||||||
|
},
|
||||||
|
onReward: (r) => {
|
||||||
|
setToast(
|
||||||
|
`Victory! +${r.xp} XP` +
|
||||||
|
(r.leveled ? ` · leveled ${r.leveled}×` : "") +
|
||||||
|
(r.loot ? ` · loot: ${r.loot}` : "")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (msg) => setToast(msg),
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
setZone(character.currentZone);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const t = setTimeout(() => setToast(null), 4000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
// Flash enemy tiles when HP drops (auto combat feedback)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dungeon || zone !== "DUNGEON") return;
|
||||||
|
const wounded = dungeon.enemies.find((e) => e.hp < e.maxHp && e.hp > 0);
|
||||||
|
if (!wounded) return;
|
||||||
|
setFlash(`${wounded.x},${wounded.y}`);
|
||||||
|
const t = setTimeout(() => setFlash(null), 280);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [dungeon?.log.length, zone]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
return <div className="boot">Loading vessel...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(x: number, y: number) {
|
||||||
|
socketRef.current?.emit("player:move", { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterPortal() {
|
||||||
|
socketRef.current?.emit("player:enter_dungeon");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dungeonMove(x: number, y: number) {
|
||||||
|
socketRef.current?.emit("dungeon:move", { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
function summon(x: number, y: number) {
|
||||||
|
socketRef.current?.emit("dungeon:summon", { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitDungeon() {
|
||||||
|
socketRef.current?.emit("player:exit_dungeon");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="game-root">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand-inline">
|
||||||
|
<span>Soulless Necromancer</span>
|
||||||
|
<small>
|
||||||
|
{character.name} · {zone === "WORLD" ? "Waking Map" : `Crypt ${dungeon?.floor ?? ""}`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="top-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={sidebar === "char" ? "active" : ""}
|
||||||
|
onClick={() => setSidebar("char")}
|
||||||
|
>
|
||||||
|
Vessel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={sidebar === "inv" ? "active" : ""}
|
||||||
|
onClick={() => setSidebar("inv")}
|
||||||
|
>
|
||||||
|
Relics
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost" onClick={() => logout()}>
|
||||||
|
Sever
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="game-layout">
|
||||||
|
<main className="stage">
|
||||||
|
{zone === "DUNGEON" && dungeon ? (
|
||||||
|
<DungeonView
|
||||||
|
state={dungeon}
|
||||||
|
character={character}
|
||||||
|
onMove={dungeonMove}
|
||||||
|
onSummon={summon}
|
||||||
|
onExit={exitDungeon}
|
||||||
|
flash={flash}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="map-hint">
|
||||||
|
Click any tile to move. Reach the pulsing portal (stand next to it) to enter the
|
||||||
|
crypt.
|
||||||
|
</p>
|
||||||
|
<MapView
|
||||||
|
width={mapSize.width}
|
||||||
|
height={mapSize.height}
|
||||||
|
players={players}
|
||||||
|
portal={portal}
|
||||||
|
selfId={character.id}
|
||||||
|
onTileClick={move}
|
||||||
|
onPortalClick={enterPortal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside className="side">
|
||||||
|
{sidebar === "char" ? (
|
||||||
|
<CharacterPanel character={character} onUpdate={setCharacter} />
|
||||||
|
) : (
|
||||||
|
<InventoryPanel character={character} onUpdate={setCharacter} />
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast && <div className="toast">{toast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Link, Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../state/AuthContext";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login, user, loading } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
if (!loading && user) return <Navigate to="/game" replace />;
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Login failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-mist" aria-hidden />
|
||||||
|
<form className="auth-panel" onSubmit={onSubmit}>
|
||||||
|
<p className="brand">Soulless Necromancer</p>
|
||||||
|
<h1>Return to the wake</h1>
|
||||||
|
<p className="lede">Sign in to reclaim your army.</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Binding..." : "Enter"}
|
||||||
|
</button>
|
||||||
|
<p className="auth-switch">
|
||||||
|
No vessel yet? <Link to="/register">Register</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Link, Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../state/AuthContext";
|
||||||
|
import type { Specialty } from "../types";
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const { register, user, loading } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [characterName, setCharacterName] = useState("");
|
||||||
|
const [specialty, setSpecialty] = useState<Specialty>("ZOMBIE");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
if (!loading && user) return <Navigate to="/game" replace />;
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await register({ email, password, characterName, specialty });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Register failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-mist" aria-hidden />
|
||||||
|
<form className="auth-panel" onSubmit={onSubmit}>
|
||||||
|
<p className="brand">Soulless Necromancer</p>
|
||||||
|
<h1>Forge a vessel</h1>
|
||||||
|
<p className="lede">Choose your legion and step onto the waking map.</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
minLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Character name
|
||||||
|
<input
|
||||||
|
value={characterName}
|
||||||
|
onChange={(e) => setCharacterName(e.target.value)}
|
||||||
|
minLength={2}
|
||||||
|
maxLength={20}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<fieldset className="specialty">
|
||||||
|
<legend>Specialty</legend>
|
||||||
|
<label className={specialty === "ZOMBIE" ? "selected" : ""}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="specialty"
|
||||||
|
checked={specialty === "ZOMBIE"}
|
||||||
|
onChange={() => setSpecialty("ZOMBIE")}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Zombie Legion</strong>
|
||||||
|
Brute strength and bone-crushing ranks.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className={specialty === "SHADOW" ? "selected" : ""}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="specialty"
|
||||||
|
checked={specialty === "SHADOW"}
|
||||||
|
onChange={() => setSpecialty("SHADOW")}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Shadow Legion</strong>
|
||||||
|
Swift shades fueled by dark intellect.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Binding soul..." : "Awaken"}
|
||||||
|
</button>
|
||||||
|
<p className="auth-switch">
|
||||||
|
Already bound? <Link to="/login">Login</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { io, type Socket } from "socket.io-client";
|
||||||
|
import type { Character, DungeonState, WorldPlayer } from "../types";
|
||||||
|
|
||||||
|
export type GameHandlers = {
|
||||||
|
onPlayers: (data: {
|
||||||
|
players: WorldPlayer[];
|
||||||
|
portal: { x: number; y: number };
|
||||||
|
map: { width: number; height: number };
|
||||||
|
}) => void;
|
||||||
|
onCharacter: (c: Character) => void;
|
||||||
|
onDungeon: (s: DungeonState) => void;
|
||||||
|
onZone: (z: { zone: string; won?: boolean }) => void;
|
||||||
|
onReward: (r: { xp: number; leveled: number; loot: string | null; nextFloor: number }) => void;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function connectGameSocket(handlers: GameHandlers): Socket {
|
||||||
|
const socket = io("/", {
|
||||||
|
withCredentials: true,
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("world:players", handlers.onPlayers);
|
||||||
|
socket.on("character:update", handlers.onCharacter);
|
||||||
|
socket.on("dungeon:state", handlers.onDungeon);
|
||||||
|
socket.on("zone:change", handlers.onZone);
|
||||||
|
socket.on("dungeon:reward", handlers.onReward);
|
||||||
|
socket.on("error:message", handlers.onError);
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Character } from "../types";
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
user: { id: string; email: string } | null;
|
||||||
|
character: Character | null;
|
||||||
|
loading: boolean;
|
||||||
|
setCharacter: (c: Character | null) => void;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (data: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
characterName: string;
|
||||||
|
specialty: "ZOMBIE" | "SHADOW";
|
||||||
|
}) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<{ id: string; email: string } | null>(null);
|
||||||
|
const [character, setCharacter] = useState<Character | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.me();
|
||||||
|
setUser(data.user);
|
||||||
|
setCharacter(data.character);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
setCharacter(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const data = await api.login({ email, password });
|
||||||
|
setUser(data.user);
|
||||||
|
setCharacter(data.character);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (payload: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
characterName: string;
|
||||||
|
specialty: "ZOMBIE" | "SHADOW";
|
||||||
|
}) => {
|
||||||
|
const data = await api.register(payload);
|
||||||
|
setUser(data.user);
|
||||||
|
setCharacter(data.character);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await api.logout();
|
||||||
|
setUser(null);
|
||||||
|
setCharacter(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
character,
|
||||||
|
loading,
|
||||||
|
setCharacter,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
[user, character, loading, login, register, logout, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth outside provider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
:root {
|
||||||
|
--bg-deep: #0b100e;
|
||||||
|
--bg-mid: #121a16;
|
||||||
|
--fog: #1a2820;
|
||||||
|
--bone: #c9b896;
|
||||||
|
--bone-dim: #8a7a5c;
|
||||||
|
--mire: #24352c;
|
||||||
|
--accent: #6b8f4e;
|
||||||
|
--accent-bright: #9fc47a;
|
||||||
|
--blood: #8b3a3a;
|
||||||
|
--energy: #4a7a6a;
|
||||||
|
--danger: #c45a4a;
|
||||||
|
--text: #e6e0d4;
|
||||||
|
--muted: #9a9284;
|
||||||
|
--panel: rgba(14, 20, 17, 0.92);
|
||||||
|
--line: rgba(201, 184, 150, 0.18);
|
||||||
|
--tile: 18px;
|
||||||
|
--dtile: 42px;
|
||||||
|
--font-display: "Cinzel Decorative", serif;
|
||||||
|
--font-body: "Source Sans 3", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: relative;
|
||||||
|
padding: 2rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 20%, rgba(107, 143, 78, 0.12), transparent 55%),
|
||||||
|
linear-gradient(180deg, #0e1612 0%, #0b100e 55%, #080c0a 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-mist {
|
||||||
|
position: absolute;
|
||||||
|
inset: -20%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 40%, rgba(74, 122, 106, 0.15), transparent 40%),
|
||||||
|
radial-gradient(circle at 80% 60%, rgba(139, 58, 58, 0.1), transparent 35%);
|
||||||
|
animation: drift 18s ease-in-out infinite alternate;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift {
|
||||||
|
from {
|
||||||
|
transform: translate(-2%, -1%) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(2%, 2%) scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
position: relative;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
padding: 2rem 1.75rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
animation: rise 0.7s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
color: var(--bone);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede,
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel input[type="email"],
|
||||||
|
.auth-panel input[type="password"],
|
||||||
|
.auth-panel input[type="text"],
|
||||||
|
.auth-panel input:not([type]) {
|
||||||
|
background: #0a0f0c;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel button[type="submit"],
|
||||||
|
.panel > button,
|
||||||
|
.inv-actions button,
|
||||||
|
.stat-btns button,
|
||||||
|
.top-actions button,
|
||||||
|
.dungeon-bar button,
|
||||||
|
.ghost {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, #1c2a22, #141c18);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel button[type="submit"]:hover,
|
||||||
|
.panel > button:hover,
|
||||||
|
.inv-actions button:hover,
|
||||||
|
.top-actions button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel button:disabled,
|
||||||
|
.panel > button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-switch {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty legend {
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty > label {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.55rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty > label.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(107, 143, 78, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty input {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game shell */
|
||||||
|
.game-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 70% at 50% -10%, rgba(36, 53, 44, 0.55), transparent 50%),
|
||||||
|
var(--bg-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(8, 12, 10, 0.85);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-inline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-inline span {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-inline small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions button.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr min(320px, 34vw);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(11, 16, 14, 0.2), rgba(11, 16, 14, 0.6)),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 3px,
|
||||||
|
rgba(255, 255, 255, 0.01) 3px,
|
||||||
|
rgba(255, 255, 255, 0.01) 4px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-hint {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-shell {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: inset 0 0 80px rgba(0, 0, 0, 0.45);
|
||||||
|
animation: fade-in 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-grid {
|
||||||
|
display: grid;
|
||||||
|
background: #0a0f0c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
width: var(--tile);
|
||||||
|
height: var(--tile);
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #162018;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.soil {
|
||||||
|
background: #15201a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.mire {
|
||||||
|
background: #1a2a22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.bone {
|
||||||
|
background: #1e2620;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:hover {
|
||||||
|
outline: 1px solid rgba(159, 196, 122, 0.35);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.portal {
|
||||||
|
background: radial-gradient(circle at center, #2a3f30 0%, #0f1612 70%);
|
||||||
|
animation: pulse-portal 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-portal {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: inset 0 0 6px rgba(107, 143, 78, 0.35);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: inset 0 0 14px rgba(159, 196, 122, 0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-glyph {
|
||||||
|
position: absolute;
|
||||||
|
inset: 2px;
|
||||||
|
border: 1px solid rgba(159, 196, 122, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
position: absolute;
|
||||||
|
inset: 1px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #3a2e24;
|
||||||
|
color: var(--bone);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar.zombie {
|
||||||
|
background: #2f4a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar.shadow {
|
||||||
|
background: #1e2c36;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar.self {
|
||||||
|
outline: 1px solid var(--accent-bright);
|
||||||
|
animation: bob 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bob {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel header p {
|
||||||
|
margin: 0.2rem 0 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 54px 1fr 70px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar div {
|
||||||
|
height: 8px;
|
||||||
|
background: #0a0f0c;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar i.hp {
|
||||||
|
background: var(--blood);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar i.en {
|
||||||
|
background: var(--energy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar i.xp {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar em {
|
||||||
|
font-style: normal;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
position: relative;
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted var(--bone-dim);
|
||||||
|
color: var(--bone);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
z-index: 10;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: #0f1612;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(4px);
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label:hover .stat-tooltip,
|
||||||
|
.stat-label:focus-visible .stat-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-btns button {
|
||||||
|
width: 28px;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.army-summary {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.army-summary h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.army-summary ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(10, 15, 12, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-list strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-list span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-list p {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-list small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rarity-uncommon strong {
|
||||||
|
color: var(--accent-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rarity-rare strong {
|
||||||
|
color: #d4a574;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dungeon */
|
||||||
|
.dungeon-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
animation: fade-in 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-modes {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-modes button.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-bar strong {
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-grid {
|
||||||
|
display: grid;
|
||||||
|
width: max-content;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #0c1210;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtile {
|
||||||
|
width: var(--dtile);
|
||||||
|
height: var(--dtile);
|
||||||
|
border: 1px solid rgba(201, 184, 150, 0.08);
|
||||||
|
background: #152019;
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtile:hover {
|
||||||
|
background: #1a2820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtile.enemy:hover {
|
||||||
|
background: #2a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtile.hit {
|
||||||
|
animation: hitflash 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hitflash {
|
||||||
|
from {
|
||||||
|
background: #6b3a3a;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background: #152019;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-avatar,
|
||||||
|
.d-enemy,
|
||||||
|
.d-summon {
|
||||||
|
position: absolute;
|
||||||
|
inset: 4px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-avatar.self {
|
||||||
|
background: #2f4a2a;
|
||||||
|
color: var(--bone);
|
||||||
|
outline: 1px solid var(--accent-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-enemy {
|
||||||
|
background: #4a2424;
|
||||||
|
color: #f0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-enemy i {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-summon.zombie {
|
||||||
|
background: #355233;
|
||||||
|
color: #cfe0c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-summon.shadow {
|
||||||
|
background: #243640;
|
||||||
|
color: #c5d6e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-log {
|
||||||
|
max-width: 560px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(10, 15, 12, 0.8);
|
||||||
|
max-height: 160px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-log p {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #1a2820;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.75rem 1.2rem;
|
||||||
|
z-index: 20;
|
||||||
|
animation: rise 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.game-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(50vh, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
max-height: 42vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--tile: 14px;
|
||||||
|
--dtile: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export type Specialty = "ZOMBIE" | "SHADOW";
|
||||||
|
export type Zone = "WORLD" | "DUNGEON";
|
||||||
|
|
||||||
|
export type ItemData = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
slot: string;
|
||||||
|
strBonus: number;
|
||||||
|
dexBonus: number;
|
||||||
|
intBonus: number;
|
||||||
|
hpBonus: number;
|
||||||
|
rarity: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InventoryEntry = {
|
||||||
|
id: string;
|
||||||
|
equipped: boolean;
|
||||||
|
quantity: number;
|
||||||
|
item: ItemData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArmyUnit = {
|
||||||
|
id: string;
|
||||||
|
type: Specialty;
|
||||||
|
tier: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Character = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
specialty: Specialty;
|
||||||
|
level: number;
|
||||||
|
xp: number;
|
||||||
|
xpToNext: number;
|
||||||
|
unspentStatPoints: number;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
energy: number;
|
||||||
|
maxEnergy: number;
|
||||||
|
str: number;
|
||||||
|
dex: number;
|
||||||
|
int: number;
|
||||||
|
effective: { str: number; dex: number; int: number; maxHp: number; maxEnergy: number };
|
||||||
|
mapX: number;
|
||||||
|
mapY: number;
|
||||||
|
currentZone: Zone;
|
||||||
|
dungeonFloor: number;
|
||||||
|
inventory: InventoryEntry[];
|
||||||
|
army: ArmyUnit[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorldPlayer = {
|
||||||
|
characterId: string;
|
||||||
|
name: string;
|
||||||
|
specialty: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Enemy = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
atk: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DungeonState = {
|
||||||
|
characterId: string;
|
||||||
|
floor: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
playerX: number;
|
||||||
|
playerY: number;
|
||||||
|
energy: number;
|
||||||
|
enemies: Enemy[];
|
||||||
|
summons: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
atk: number;
|
||||||
|
}>;
|
||||||
|
log: string[];
|
||||||
|
over: boolean;
|
||||||
|
won: boolean;
|
||||||
|
};
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3001",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/socket.io": {
|
||||||
|
target: "http://localhost:3001",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: soulless
|
||||||
|
POSTGRES_PASSWORD: soulless
|
||||||
|
POSTGRES_DB: soulless_necromancer
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
Generated
+4470
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "soulless-necromancer",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"client",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,client -c green,cyan \"npm run dev -w server\" \"npm run dev -w client\"",
|
||||||
|
"build": "npm run build -w server && npm run build -w client",
|
||||||
|
"db:up": "docker start soulless-postgres 2>/dev/null || docker run -d --name soulless-postgres -e POSTGRES_USER=soulless -e POSTGRES_PASSWORD=soulless -e POSTGRES_DB=soulless_necromancer -p 5433:5432 -v soulless_pgdata:/var/lib/postgresql/data postgres:16-alpine",
|
||||||
|
"db:down": "docker stop soulless-postgres",
|
||||||
|
"db:migrate": "npm run db:push -w server",
|
||||||
|
"db:seed": "npm run db:seed -w server",
|
||||||
|
"db:setup": "npm run db:up && sleep 4 && npm run db:migrate && npm run db:seed"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL="postgresql://soulless:soulless@localhost:5433/soulless_necromancer?schema=public"
|
||||||
|
JWT_SECRET="soulless-necromancer-dev-secret-change-in-prod"
|
||||||
|
PORT=3001
|
||||||
|
CLIENT_ORIGIN="http://localhost:5173"
|
||||||
|
COOKIE_NAME="sn_token"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:migrate": "prisma db push && prisma generate",
|
||||||
|
"db:push": "prisma db push && prisma generate",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.5.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cookie-parser": "^1.4.8",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"prisma": "^6.5.0",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Specialty {
|
||||||
|
ZOMBIE
|
||||||
|
SHADOW
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Zone {
|
||||||
|
WORLD
|
||||||
|
DUNGEON
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ItemSlot {
|
||||||
|
WEAPON
|
||||||
|
ARMOR
|
||||||
|
ACCESSORY
|
||||||
|
CONSUMABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ArmyUnitType {
|
||||||
|
ZOMBIE
|
||||||
|
SHADOW
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
character Character?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Character {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
specialty Specialty
|
||||||
|
level Int @default(1)
|
||||||
|
xp Int @default(0)
|
||||||
|
unspentStatPoints Int @default(0)
|
||||||
|
hp Int @default(100)
|
||||||
|
maxHp Int @default(100)
|
||||||
|
energy Int @default(50)
|
||||||
|
maxEnergy Int @default(50)
|
||||||
|
str Int @default(5)
|
||||||
|
dex Int @default(5)
|
||||||
|
int Int @default(5)
|
||||||
|
mapX Int @default(5)
|
||||||
|
mapY Int @default(5)
|
||||||
|
currentZone Zone @default(WORLD)
|
||||||
|
dungeonFloor Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
inventory InventoryItem[]
|
||||||
|
army ArmyUnit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Item {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
name String
|
||||||
|
description String
|
||||||
|
slot ItemSlot
|
||||||
|
strBonus Int @default(0)
|
||||||
|
dexBonus Int @default(0)
|
||||||
|
intBonus Int @default(0)
|
||||||
|
hpBonus Int @default(0)
|
||||||
|
rarity String @default("common")
|
||||||
|
inventory InventoryItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model InventoryItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
characterId String
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
itemId String
|
||||||
|
item Item @relation(fields: [itemId], references: [id])
|
||||||
|
equipped Boolean @default(false)
|
||||||
|
quantity Int @default(1)
|
||||||
|
|
||||||
|
@@index([characterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ArmyUnit {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
characterId String
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
type ArmyUnitType
|
||||||
|
tier Int @default(1)
|
||||||
|
count Int @default(1)
|
||||||
|
|
||||||
|
@@unique([characterId, type, tier])
|
||||||
|
@@index([characterId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { PrismaClient, ItemSlot } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: "bone_dagger",
|
||||||
|
name: "Bone Dagger",
|
||||||
|
description: "A crude blade carved from a fallen warrior.",
|
||||||
|
slot: ItemSlot.WEAPON,
|
||||||
|
strBonus: 2,
|
||||||
|
dexBonus: 1,
|
||||||
|
intBonus: 0,
|
||||||
|
hpBonus: 0,
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "shadow_veil",
|
||||||
|
name: "Shadow Veil",
|
||||||
|
description: "Cloth woven from twilight itself.",
|
||||||
|
slot: ItemSlot.ARMOR,
|
||||||
|
strBonus: 0,
|
||||||
|
dexBonus: 2,
|
||||||
|
intBonus: 1,
|
||||||
|
hpBonus: 10,
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "grimoire_fragment",
|
||||||
|
name: "Grimoire Fragment",
|
||||||
|
description: "Pages torn from a forbidden tome.",
|
||||||
|
slot: ItemSlot.ACCESSORY,
|
||||||
|
strBonus: 0,
|
||||||
|
dexBonus: 0,
|
||||||
|
intBonus: 3,
|
||||||
|
hpBonus: 0,
|
||||||
|
rarity: "uncommon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "necrotic_blade",
|
||||||
|
name: "Necrotic Blade",
|
||||||
|
description: "Steel that drinks life force.",
|
||||||
|
slot: ItemSlot.WEAPON,
|
||||||
|
strBonus: 4,
|
||||||
|
dexBonus: 0,
|
||||||
|
intBonus: 2,
|
||||||
|
hpBonus: 0,
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ossuary_plate",
|
||||||
|
name: "Ossuary Plate",
|
||||||
|
description: "Armor fashioned from countless bones.",
|
||||||
|
slot: ItemSlot.ARMOR,
|
||||||
|
strBonus: 2,
|
||||||
|
dexBonus: 0,
|
||||||
|
intBonus: 0,
|
||||||
|
hpBonus: 25,
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "soul_phial",
|
||||||
|
name: "Soul Phial",
|
||||||
|
description: "A vial of restless essence. Restores energy.",
|
||||||
|
slot: ItemSlot.CONSUMABLE,
|
||||||
|
strBonus: 0,
|
||||||
|
dexBonus: 0,
|
||||||
|
intBonus: 0,
|
||||||
|
hpBonus: 0,
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const item of items) {
|
||||||
|
await prisma.item.upsert({
|
||||||
|
where: { key: item.key },
|
||||||
|
update: item,
|
||||||
|
create: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Seeded ${items.length} items.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import http from "http";
|
||||||
|
import { Server } from "socket.io";
|
||||||
|
import { config } from "./lib/config.js";
|
||||||
|
import { authRouter } from "./routes/auth.js";
|
||||||
|
import { characterRouter } from "./routes/character.js";
|
||||||
|
import { inventoryRouter } from "./routes/inventory.js";
|
||||||
|
import { registerSockets } from "./sockets/game.js";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
const io = new Server(server, {
|
||||||
|
cors: {
|
||||||
|
origin: config.clientOrigin,
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: config.clientOrigin,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
app.get("/api/health", (_req, res) => {
|
||||||
|
res.json({ ok: true, name: "Soulless Necromancer" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api/character", characterRouter);
|
||||||
|
app.use("/api/inventory", inventoryRouter);
|
||||||
|
|
||||||
|
registerSockets(io);
|
||||||
|
|
||||||
|
server.listen(config.port, () => {
|
||||||
|
console.log(`Soulless Necromancer server on http://localhost:${config.port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: Number(process.env.PORT ?? 3001),
|
||||||
|
jwtSecret: process.env.JWT_SECRET ?? "dev-secret",
|
||||||
|
clientOrigin: process.env.CLIENT_ORIGIN ?? "http://localhost:5173",
|
||||||
|
cookieName: process.env.COOKIE_NAME ?? "sn_token",
|
||||||
|
mapWidth: 40,
|
||||||
|
mapHeight: 30,
|
||||||
|
portalX: 20,
|
||||||
|
portalY: 15,
|
||||||
|
spawnX: 5,
|
||||||
|
spawnY: 5,
|
||||||
|
dungeonWidth: 12,
|
||||||
|
dungeonHeight: 10,
|
||||||
|
/** World-map regen tick interval (ms) */
|
||||||
|
regenIntervalMs: 4000,
|
||||||
|
regenHp: 4,
|
||||||
|
regenEnergy: 2,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { config } from "../lib/config.js";
|
||||||
|
|
||||||
|
export type AuthPayload = {
|
||||||
|
userId: string;
|
||||||
|
characterId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
auth?: AuthPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signToken(payload: AuthPayload): string {
|
||||||
|
return jwt.sign(payload, config.jwtSecret, { expiresIn: "7d" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): AuthPayload | null {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, config.jwtSecret) as AuthPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const token =
|
||||||
|
req.cookies?.[config.cookieName] ??
|
||||||
|
(req.headers.authorization?.startsWith("Bearer ")
|
||||||
|
? req.headers.authorization.slice(7)
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
res.status(401).json({ error: "Invalid token" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.auth = payload;
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { config } from "../lib/config.js";
|
||||||
|
import { requireAuth, signToken } from "../middleware/auth.js";
|
||||||
|
import { baseStatsForSpecialty, serializeCharacter } from "../services/progression.js";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
characterName: z.string().min(2).max(20),
|
||||||
|
specialty: z.enum(["ZOMBIE", "SHADOW"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
function setAuthCookie(res: import("express").Response, token: string) {
|
||||||
|
res.cookie(config.cookieName, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
authRouter.post("/register", async (req, res) => {
|
||||||
|
const parsed = registerSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid input", details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password, characterName, specialty } = parsed.data;
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: "Email already registered" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameTaken = await prisma.character.findFirst({ where: { name: characterName } });
|
||||||
|
if (nameTaken) {
|
||||||
|
res.status(409).json({ error: "Character name already taken" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const stats = baseStatsForSpecialty(specialty);
|
||||||
|
|
||||||
|
const starter = await prisma.item.findUnique({ where: { key: "soul_phial" } });
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
character: {
|
||||||
|
create: {
|
||||||
|
name: characterName,
|
||||||
|
specialty,
|
||||||
|
str: stats.str,
|
||||||
|
dex: stats.dex,
|
||||||
|
int: stats.int,
|
||||||
|
maxHp: stats.maxHp,
|
||||||
|
hp: stats.maxHp,
|
||||||
|
maxEnergy: stats.maxEnergy,
|
||||||
|
energy: stats.maxEnergy,
|
||||||
|
mapX: config.spawnX,
|
||||||
|
mapY: config.spawnY,
|
||||||
|
inventory: starter
|
||||||
|
? {
|
||||||
|
create: {
|
||||||
|
itemId: starter.id,
|
||||||
|
quantity: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
character: {
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const character = user.character!;
|
||||||
|
const token = signToken({ userId: user.id, characterId: character.id });
|
||||||
|
setAuthCookie(res, token);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
user: { id: user.id, email: user.email },
|
||||||
|
character: serializeCharacter(character),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/login", async (req, res) => {
|
||||||
|
const parsed = loginSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: parsed.data.email },
|
||||||
|
include: {
|
||||||
|
character: {
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !(await bcrypt.compare(parsed.data.password, user.passwordHash))) {
|
||||||
|
res.status(401).json({ error: "Invalid email or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.character) {
|
||||||
|
res.status(500).json({ error: "Character missing" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = signToken({ userId: user.id, characterId: user.character.id });
|
||||||
|
setAuthCookie(res, token);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: { id: user.id, email: user.email },
|
||||||
|
character: serializeCharacter(user.character),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/logout", (_req, res) => {
|
||||||
|
res.clearCookie(config.cookieName);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/me", requireAuth, async (req, res) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.auth!.userId },
|
||||||
|
include: {
|
||||||
|
character: {
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.character) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: { id: user.id, email: user.email },
|
||||||
|
character: serializeCharacter(user.character),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
import { serializeCharacter } from "../services/progression.js";
|
||||||
|
|
||||||
|
export const characterRouter = Router();
|
||||||
|
|
||||||
|
characterRouter.use(requireAuth);
|
||||||
|
|
||||||
|
async function loadCharacter(characterId: string) {
|
||||||
|
return prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
characterRouter.get("/", async (req, res) => {
|
||||||
|
const character = await loadCharacter(req.auth!.characterId);
|
||||||
|
if (!character) {
|
||||||
|
res.status(404).json({ error: "Character not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ character: serializeCharacter(character) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const statsSchema = z.object({
|
||||||
|
str: z.number().int().min(0).max(50).optional(),
|
||||||
|
dex: z.number().int().min(0).max(50).optional(),
|
||||||
|
int: z.number().int().min(0).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
characterRouter.post("/stats", async (req, res) => {
|
||||||
|
const parsed = statsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await loadCharacter(req.auth!.characterId);
|
||||||
|
if (!character) {
|
||||||
|
res.status(404).json({ error: "Character not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addStr = parsed.data.str ?? 0;
|
||||||
|
const addDex = parsed.data.dex ?? 0;
|
||||||
|
const addInt = parsed.data.int ?? 0;
|
||||||
|
const total = addStr + addDex + addInt;
|
||||||
|
|
||||||
|
if (total <= 0) {
|
||||||
|
res.status(400).json({ error: "No points allocated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (total > character.unspentStatPoints) {
|
||||||
|
res.status(400).json({ error: "Not enough stat points" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.character.update({
|
||||||
|
where: { id: character.id },
|
||||||
|
data: {
|
||||||
|
str: character.str + addStr,
|
||||||
|
dex: character.dex + addDex,
|
||||||
|
int: character.int + addInt,
|
||||||
|
unspentStatPoints: character.unspentStatPoints - total,
|
||||||
|
maxHp: character.maxHp + addStr * 2,
|
||||||
|
maxEnergy: character.maxEnergy + addInt,
|
||||||
|
},
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ character: serializeCharacter(updated) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
import { serializeCharacter } from "../services/progression.js";
|
||||||
|
|
||||||
|
export const inventoryRouter = Router();
|
||||||
|
|
||||||
|
inventoryRouter.use(requireAuth);
|
||||||
|
|
||||||
|
async function loadCharacter(characterId: string) {
|
||||||
|
return prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inventoryRouter.get("/", async (req, res) => {
|
||||||
|
const character = await loadCharacter(req.auth!.characterId);
|
||||||
|
if (!character) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ character: serializeCharacter(character) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const equipSchema = z.object({
|
||||||
|
inventoryItemId: z.string(),
|
||||||
|
equipped: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
inventoryRouter.post("/equip", async (req, res) => {
|
||||||
|
const parsed = equipSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inv = await prisma.inventoryItem.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parsed.data.inventoryItemId,
|
||||||
|
characterId: req.auth!.characterId,
|
||||||
|
},
|
||||||
|
include: { item: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inv) {
|
||||||
|
res.status(404).json({ error: "Item not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inv.item.slot === "CONSUMABLE") {
|
||||||
|
res.status(400).json({ error: "Cannot equip consumable" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.equipped) {
|
||||||
|
// Unequip same slot
|
||||||
|
const sameSlot = await prisma.inventoryItem.findMany({
|
||||||
|
where: {
|
||||||
|
characterId: req.auth!.characterId,
|
||||||
|
equipped: true,
|
||||||
|
item: { slot: inv.item.slot },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const other of sameSlot) {
|
||||||
|
await prisma.inventoryItem.update({
|
||||||
|
where: { id: other.id },
|
||||||
|
data: { equipped: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.inventoryItem.update({
|
||||||
|
where: { id: inv.id },
|
||||||
|
data: { equipped: parsed.data.equipped },
|
||||||
|
});
|
||||||
|
|
||||||
|
const character = await loadCharacter(req.auth!.characterId);
|
||||||
|
res.json({ character: serializeCharacter(character!) });
|
||||||
|
});
|
||||||
|
|
||||||
|
inventoryRouter.post("/use", async (req, res) => {
|
||||||
|
const schema = z.object({ inventoryItemId: z.string() });
|
||||||
|
const parsed = schema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inv = await prisma.inventoryItem.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parsed.data.inventoryItemId,
|
||||||
|
characterId: req.auth!.characterId,
|
||||||
|
},
|
||||||
|
include: { item: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inv || inv.item.slot !== "CONSUMABLE") {
|
||||||
|
res.status(400).json({ error: "Not a consumable" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await prisma.character.findUnique({ where: { id: req.auth!.characterId } });
|
||||||
|
if (!character) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.character.update({
|
||||||
|
where: { id: character.id },
|
||||||
|
data: {
|
||||||
|
energy: Math.min(character.maxEnergy, character.energy + 25),
|
||||||
|
hp: Math.min(character.maxHp, character.hp + 15),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inv.quantity <= 1) {
|
||||||
|
await prisma.inventoryItem.delete({ where: { id: inv.id } });
|
||||||
|
} else {
|
||||||
|
await prisma.inventoryItem.update({
|
||||||
|
where: { id: inv.id },
|
||||||
|
data: { quantity: inv.quantity - 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await loadCharacter(req.auth!.characterId);
|
||||||
|
res.json({ character: serializeCharacter(updated!) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ArmyUnitType, Specialty } from "@prisma/client";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
|
||||||
|
export function armyTypeForSpecialty(specialty: Specialty): ArmyUnitType {
|
||||||
|
return specialty === "ZOMBIE" ? "ZOMBIE" : "SHADOW";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summonEnergyCost(tier: number): number {
|
||||||
|
return 8 + tier * 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unitPower(type: ArmyUnitType, tier: number, count: number, casterInt: number) {
|
||||||
|
const base = type === "ZOMBIE" ? 6 : 5;
|
||||||
|
return (base + tier * 3 + Math.floor(casterInt / 2)) * count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addArmyUnit(
|
||||||
|
characterId: string,
|
||||||
|
type: ArmyUnitType,
|
||||||
|
tier = 1,
|
||||||
|
count = 1
|
||||||
|
) {
|
||||||
|
return prisma.armyUnit.upsert({
|
||||||
|
where: {
|
||||||
|
characterId_type_tier: { characterId, type, tier },
|
||||||
|
},
|
||||||
|
update: { count: { increment: count } },
|
||||||
|
create: { characterId, type, tier, count },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArmyPower(characterId: string, casterInt: number) {
|
||||||
|
const units = await prisma.armyUnit.findMany({ where: { characterId } });
|
||||||
|
return units.reduce((sum, u) => sum + unitPower(u.type, u.tier, u.count, casterInt), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { config } from "../lib/config.js";
|
||||||
|
import { effectiveStats, type CharacterWithRelations } from "./progression.js";
|
||||||
|
import { getArmyPower, unitPower } from "./army.js";
|
||||||
|
import type { ArmyUnitType } from "@prisma/client";
|
||||||
|
|
||||||
|
export const PLAYER_ATTACK_RANGE = 2;
|
||||||
|
export const UNIT_ATTACK_RANGE = 2;
|
||||||
|
export const ENEMY_ATTACK_RANGE = 2;
|
||||||
|
|
||||||
|
export type Enemy = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
atk: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Summon = {
|
||||||
|
id: string;
|
||||||
|
type: ArmyUnitType;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
atk: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DungeonState = {
|
||||||
|
characterId: string;
|
||||||
|
floor: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
playerX: number;
|
||||||
|
playerY: number;
|
||||||
|
energy: number;
|
||||||
|
enemies: Enemy[];
|
||||||
|
summons: Summon[];
|
||||||
|
log: string[];
|
||||||
|
over: boolean;
|
||||||
|
won: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function enemyName(floor: number, i: number): string {
|
||||||
|
const names = ["Bone Wretch", "Hollow Scout", "Grave Shade", "Carrion Knight", "Void Hound"];
|
||||||
|
return `${names[i % names.length]} Lv${floor}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manhattan(ax: number, ay: number, bx: number, by: number) {
|
||||||
|
return Math.abs(ax - bx) + Math.abs(ay - by);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestEnemy(fromX: number, fromY: number, enemies: Enemy[], range: number): Enemy | null {
|
||||||
|
const inRange = enemies
|
||||||
|
.filter((e) => e.hp > 0 && manhattan(fromX, fromY, e.x, e.y) <= range)
|
||||||
|
.sort((a, b) => manhattan(fromX, fromY, a.x, a.y) - manhattan(fromX, fromY, b.x, b.y));
|
||||||
|
return inRange[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestEnemyAny(fromX: number, fromY: number, enemies: Enemy[]): Enemy | null {
|
||||||
|
const living = enemies
|
||||||
|
.filter((e) => e.hp > 0)
|
||||||
|
.sort((a, b) => manhattan(fromX, fromY, a.x, a.y) - manhattan(fromX, fromY, b.x, b.y));
|
||||||
|
return living[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWalkable(state: DungeonState, x: number, y: number, ignoreSummonId?: string): boolean {
|
||||||
|
if (x < 0 || y < 0 || x >= state.width || y >= state.height) return false;
|
||||||
|
if (x === state.playerX && y === state.playerY) return false;
|
||||||
|
if (state.enemies.some((e) => e.hp > 0 && e.x === x && e.y === y)) return false;
|
||||||
|
if (state.summons.some((s) => s.id !== ignoreSummonId && s.hp > 0 && s.x === x && s.y === y)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One orthogonal step closer to target, if possible. */
|
||||||
|
function stepToward(
|
||||||
|
state: DungeonState,
|
||||||
|
fromX: number,
|
||||||
|
fromY: number,
|
||||||
|
toX: number,
|
||||||
|
toY: number,
|
||||||
|
ignoreSummonId?: string
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const candidates: Array<{ x: number; y: number }> = [];
|
||||||
|
if (toX !== fromX) candidates.push({ x: fromX + Math.sign(toX - fromX), y: fromY });
|
||||||
|
if (toY !== fromY) candidates.push({ x: fromX, y: fromY + Math.sign(toY - fromY) });
|
||||||
|
// Prefer the step that reduces Manhattan distance the most
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) => manhattan(a.x, a.y, toX, toY) - manhattan(b.x, b.y, toX, toY)
|
||||||
|
);
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (isWalkable(state, c.x, c.y, ignoreSummonId)) return c;
|
||||||
|
}
|
||||||
|
// Sidestep if direct path blocked
|
||||||
|
const sides = [
|
||||||
|
{ x: fromX + 1, y: fromY },
|
||||||
|
{ x: fromX - 1, y: fromY },
|
||||||
|
{ x: fromX, y: fromY + 1 },
|
||||||
|
{ x: fromX, y: fromY - 1 },
|
||||||
|
].filter((c) => isWalkable(state, c.x, c.y, ignoreSummonId));
|
||||||
|
sides.sort((a, b) => manhattan(a.x, a.y, toX, toY) - manhattan(b.x, b.y, toX, toY));
|
||||||
|
return sides[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveSummonTowardEnemy(state: DungeonState, summon: Summon): boolean {
|
||||||
|
const foe = nearestEnemyAny(summon.x, summon.y, state.enemies);
|
||||||
|
if (!foe) return false;
|
||||||
|
const step = stepToward(state, summon.x, summon.y, foe.x, foe.y, summon.id);
|
||||||
|
if (!step) return false;
|
||||||
|
summon.x = step.x;
|
||||||
|
summon.y = step.y;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDungeon(character: CharacterWithRelations): DungeonState {
|
||||||
|
const floor = Math.max(1, character.dungeonFloor || 1);
|
||||||
|
const enemyCount = 3 + Math.min(4, floor);
|
||||||
|
const enemies: Enemy[] = [];
|
||||||
|
const startY = Math.floor(config.dungeonHeight / 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < enemyCount; i++) {
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let attempts = 0;
|
||||||
|
do {
|
||||||
|
x = 2 + Math.floor(Math.random() * (config.dungeonWidth - 3));
|
||||||
|
y = 1 + Math.floor(Math.random() * (config.dungeonHeight - 2));
|
||||||
|
attempts++;
|
||||||
|
} while (
|
||||||
|
attempts < 40 &&
|
||||||
|
(enemies.some((e) => e.x === x && e.y === y) || (x === 1 && y === startY))
|
||||||
|
);
|
||||||
|
|
||||||
|
const hp = 25 + floor * 12 + i * 5;
|
||||||
|
enemies.push({
|
||||||
|
id: `enemy-${i}-${Date.now()}`,
|
||||||
|
name: enemyName(floor, i),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
hp,
|
||||||
|
maxHp: hp,
|
||||||
|
atk: 4 + floor * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
characterId: character.id,
|
||||||
|
floor,
|
||||||
|
width: config.dungeonWidth,
|
||||||
|
height: config.dungeonHeight,
|
||||||
|
playerX: 1,
|
||||||
|
playerY: startY,
|
||||||
|
energy: character.energy,
|
||||||
|
enemies,
|
||||||
|
summons: [],
|
||||||
|
log: [`Floor ${floor}: the crypt awakens. Combat starts when in range.`],
|
||||||
|
over: false,
|
||||||
|
won: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveInDungeon(
|
||||||
|
state: DungeonState,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): { ok: boolean; error?: string; state: DungeonState } {
|
||||||
|
if (state.over) return { ok: false, error: "Combat over", state };
|
||||||
|
x = Math.floor(x);
|
||||||
|
y = Math.floor(y);
|
||||||
|
if (x < 0 || y < 0 || x >= state.width || y >= state.height) {
|
||||||
|
return { ok: false, error: "Out of bounds", state };
|
||||||
|
}
|
||||||
|
if (x === state.playerX && y === state.playerY) {
|
||||||
|
return { ok: false, error: "Already there", state };
|
||||||
|
}
|
||||||
|
if (state.enemies.some((e) => e.hp > 0 && e.x === x && e.y === y)) {
|
||||||
|
return { ok: false, error: "Tile blocked by enemy", state };
|
||||||
|
}
|
||||||
|
if (state.summons.some((s) => s.x === x && s.y === y)) {
|
||||||
|
return { ok: false, error: "Tile occupied by ally", state };
|
||||||
|
}
|
||||||
|
|
||||||
|
state.playerX = x;
|
||||||
|
state.playerY = y;
|
||||||
|
state.log.push(`You move to (${x}, ${y}).`);
|
||||||
|
return { ok: true, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerAttackDamage(character: CharacterWithRelations, state: DungeonState): number {
|
||||||
|
const stats = effectiveStats(character);
|
||||||
|
const armyBonus = character.army.reduce(
|
||||||
|
(s, u) => s + unitPower(u.type, u.tier, u.count, stats.int),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(stats.str * 1.2 + stats.int * 0.8 + armyBonus * 0.15)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One auto-combat round: summons move or fight, player hits if in range, enemies retaliate. */
|
||||||
|
export function resolveAutoCombatRound(
|
||||||
|
state: DungeonState,
|
||||||
|
character: CharacterWithRelations
|
||||||
|
): { state: DungeonState; damageToPlayer: number; acted: boolean } {
|
||||||
|
if (state.over) return { state, damageToPlayer: 0, acted: false };
|
||||||
|
|
||||||
|
let acted = false;
|
||||||
|
const playerDmg = playerAttackDamage(character, state);
|
||||||
|
|
||||||
|
const playerTarget = nearestEnemy(state.playerX, state.playerY, state.enemies, PLAYER_ATTACK_RANGE);
|
||||||
|
if (playerTarget) {
|
||||||
|
playerTarget.hp = Math.max(0, playerTarget.hp - playerDmg);
|
||||||
|
state.log.push(`You strike ${playerTarget.name} for ${playerDmg}.`);
|
||||||
|
acted = true;
|
||||||
|
if (playerTarget.hp <= 0) {
|
||||||
|
state.log.push(`${playerTarget.name} falls.`);
|
||||||
|
state.enemies = state.enemies.filter((e) => e.id !== playerTarget.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const summon of [...state.summons]) {
|
||||||
|
if (summon.hp <= 0) continue;
|
||||||
|
const target = nearestEnemy(summon.x, summon.y, state.enemies, UNIT_ATTACK_RANGE);
|
||||||
|
if (target) {
|
||||||
|
target.hp = Math.max(0, target.hp - summon.atk);
|
||||||
|
state.log.push(
|
||||||
|
`Your ${summon.type.toLowerCase()} hits ${target.name} for ${summon.atk}.`
|
||||||
|
);
|
||||||
|
acted = true;
|
||||||
|
if (target.hp <= 0) {
|
||||||
|
state.log.push(`${target.name} falls.`);
|
||||||
|
state.enemies = state.enemies.filter((e) => e.id !== target.id);
|
||||||
|
}
|
||||||
|
} else if (moveSummonTowardEnemy(state, summon)) {
|
||||||
|
acted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let damageToPlayer = 0;
|
||||||
|
for (const enemy of [...state.enemies]) {
|
||||||
|
if (enemy.hp <= 0) continue;
|
||||||
|
|
||||||
|
const nearPlayer = manhattan(enemy.x, enemy.y, state.playerX, state.playerY) <= ENEMY_ATTACK_RANGE;
|
||||||
|
const nearSummons = state.summons.filter(
|
||||||
|
(s) => s.hp > 0 && manhattan(enemy.x, enemy.y, s.x, s.y) <= ENEMY_ATTACK_RANGE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nearPlayer && nearSummons.length === 0) continue;
|
||||||
|
|
||||||
|
acted = true;
|
||||||
|
if (nearSummons.length > 0 && (!nearPlayer || Math.random() < 0.55)) {
|
||||||
|
const target = nearSummons[Math.floor(Math.random() * nearSummons.length)];
|
||||||
|
target.hp -= enemy.atk;
|
||||||
|
state.log.push(`${enemy.name} hits your ${target.type.toLowerCase()} for ${enemy.atk}.`);
|
||||||
|
if (target.hp <= 0) {
|
||||||
|
state.summons = state.summons.filter((s) => s.id !== target.id);
|
||||||
|
state.log.push(`Your ${target.type.toLowerCase()} is destroyed.`);
|
||||||
|
}
|
||||||
|
} else if (nearPlayer) {
|
||||||
|
damageToPlayer += enemy.atk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (damageToPlayer > 0) {
|
||||||
|
state.log.push(`You take ${damageToPlayer} damage.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.enemies.filter((e) => e.hp > 0).length === 0) {
|
||||||
|
state.over = true;
|
||||||
|
state.won = true;
|
||||||
|
state.log.push("Floor cleared!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.log.length > 40) {
|
||||||
|
state.log = state.log.slice(-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, damageToPlayer, acted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combat tick needed if fight in range OR summons can still hunt enemies. */
|
||||||
|
export function anyoneInCombatRange(state: DungeonState): boolean {
|
||||||
|
if (state.over) return false;
|
||||||
|
if (nearestEnemy(state.playerX, state.playerY, state.enemies, PLAYER_ATTACK_RANGE)) return true;
|
||||||
|
for (const s of state.summons) {
|
||||||
|
if (s.hp <= 0) continue;
|
||||||
|
if (nearestEnemy(s.x, s.y, state.enemies, UNIT_ATTACK_RANGE)) return true;
|
||||||
|
}
|
||||||
|
for (const e of state.enemies) {
|
||||||
|
if (e.hp <= 0) continue;
|
||||||
|
if (manhattan(e.x, e.y, state.playerX, state.playerY) <= ENEMY_ATTACK_RANGE) return true;
|
||||||
|
if (state.summons.some((s) => s.hp > 0 && manhattan(e.x, e.y, s.x, s.y) <= ENEMY_ATTACK_RANGE)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dungeonNeedsTick(state: DungeonState): boolean {
|
||||||
|
if (state.over) return false;
|
||||||
|
if (state.enemies.every((e) => e.hp <= 0)) return false;
|
||||||
|
if (anyoneInCombatRange(state)) return true;
|
||||||
|
return state.summons.some((s) => s.hp > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeSummon(
|
||||||
|
state: DungeonState,
|
||||||
|
type: ArmyUnitType,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
casterInt: number
|
||||||
|
): { ok: boolean; error?: string; state: DungeonState } {
|
||||||
|
if (state.over) return { ok: false, error: "Combat over", state };
|
||||||
|
if (x < 0 || y < 0 || x >= state.width || y >= state.height) {
|
||||||
|
return { ok: false, error: "Out of bounds", state };
|
||||||
|
}
|
||||||
|
if (state.enemies.some((e) => e.x === x && e.y === y && e.hp > 0)) {
|
||||||
|
return { ok: false, error: "Tile occupied", state };
|
||||||
|
}
|
||||||
|
if (state.summons.some((s) => s.x === x && s.y === y)) {
|
||||||
|
return { ok: false, error: "Tile occupied", state };
|
||||||
|
}
|
||||||
|
if (x === state.playerX && y === state.playerY) {
|
||||||
|
return { ok: false, error: "Cannot summon on self", state };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hp = 18 + casterInt * 2;
|
||||||
|
const atk = 4 + Math.floor(casterInt / 2);
|
||||||
|
state.summons.push({
|
||||||
|
id: `summon-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
type,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
hp,
|
||||||
|
maxHp: hp,
|
||||||
|
atk,
|
||||||
|
});
|
||||||
|
state.log.push(`You summon a ${type.toLowerCase()} at (${x},${y}).`);
|
||||||
|
return { ok: true, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function estimateArmyPower(characterId: string, casterInt: number) {
|
||||||
|
return getArmyPower(characterId, casterInt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { Character, InventoryItem, Item, Specialty, ArmyUnit } from "@prisma/client";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
|
||||||
|
export type CharacterWithRelations = Character & {
|
||||||
|
inventory: (InventoryItem & { item: Item })[];
|
||||||
|
army: ArmyUnit[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function baseStatsForSpecialty(specialty: Specialty) {
|
||||||
|
if (specialty === "ZOMBIE") {
|
||||||
|
return { str: 8, dex: 4, int: 5, maxHp: 120, maxEnergy: 40 };
|
||||||
|
}
|
||||||
|
return { str: 4, dex: 7, int: 8, maxHp: 90, maxEnergy: 60 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xpToNextLevel(level: number): number {
|
||||||
|
return 50 + level * 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function equipmentBonuses(character: CharacterWithRelations) {
|
||||||
|
return character.inventory
|
||||||
|
.filter((i) => i.equipped)
|
||||||
|
.reduce(
|
||||||
|
(acc, inv) => ({
|
||||||
|
str: acc.str + inv.item.strBonus,
|
||||||
|
dex: acc.dex + inv.item.dexBonus,
|
||||||
|
int: acc.int + inv.item.intBonus,
|
||||||
|
hp: acc.hp + inv.item.hpBonus,
|
||||||
|
}),
|
||||||
|
{ str: 0, dex: 0, int: 0, hp: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveStats(character: CharacterWithRelations) {
|
||||||
|
const bonus = equipmentBonuses(character);
|
||||||
|
return {
|
||||||
|
str: character.str + bonus.str,
|
||||||
|
dex: character.dex + bonus.dex,
|
||||||
|
int: character.int + bonus.int,
|
||||||
|
maxHp: character.maxHp + bonus.hp,
|
||||||
|
maxEnergy: character.maxEnergy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCharacter(character: CharacterWithRelations) {
|
||||||
|
const stats = effectiveStats(character);
|
||||||
|
return {
|
||||||
|
id: character.id,
|
||||||
|
name: character.name,
|
||||||
|
specialty: character.specialty,
|
||||||
|
level: character.level,
|
||||||
|
xp: character.xp,
|
||||||
|
xpToNext: xpToNextLevel(character.level),
|
||||||
|
unspentStatPoints: character.unspentStatPoints,
|
||||||
|
hp: character.hp,
|
||||||
|
maxHp: stats.maxHp,
|
||||||
|
energy: character.energy,
|
||||||
|
maxEnergy: stats.maxEnergy,
|
||||||
|
str: character.str,
|
||||||
|
dex: character.dex,
|
||||||
|
int: character.int,
|
||||||
|
effective: stats,
|
||||||
|
mapX: character.mapX,
|
||||||
|
mapY: character.mapY,
|
||||||
|
currentZone: character.currentZone,
|
||||||
|
dungeonFloor: character.dungeonFloor,
|
||||||
|
inventory: character.inventory.map((inv) => ({
|
||||||
|
id: inv.id,
|
||||||
|
equipped: inv.equipped,
|
||||||
|
quantity: inv.quantity,
|
||||||
|
item: {
|
||||||
|
id: inv.item.id,
|
||||||
|
key: inv.item.key,
|
||||||
|
name: inv.item.name,
|
||||||
|
description: inv.item.description,
|
||||||
|
slot: inv.item.slot,
|
||||||
|
strBonus: inv.item.strBonus,
|
||||||
|
dexBonus: inv.item.dexBonus,
|
||||||
|
intBonus: inv.item.intBonus,
|
||||||
|
hpBonus: inv.item.hpBonus,
|
||||||
|
rarity: inv.item.rarity,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
army: character.army.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
type: u.type,
|
||||||
|
tier: u.tier,
|
||||||
|
count: u.count,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyXp(
|
||||||
|
characterId: string,
|
||||||
|
current: Character,
|
||||||
|
amount: number
|
||||||
|
) {
|
||||||
|
let { level, xp, unspentStatPoints } = current;
|
||||||
|
xp += amount;
|
||||||
|
let leveled = 0;
|
||||||
|
while (xp >= xpToNextLevel(level)) {
|
||||||
|
xp -= xpToNextLevel(level);
|
||||||
|
level += 1;
|
||||||
|
unspentStatPoints += 3;
|
||||||
|
leveled += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: {
|
||||||
|
level,
|
||||||
|
xp,
|
||||||
|
unspentStatPoints,
|
||||||
|
maxHp: current.maxHp + leveled * 10,
|
||||||
|
hp: Math.min(current.hp + leveled * 10, current.maxHp + leveled * 10),
|
||||||
|
maxEnergy: current.maxEnergy + leveled * 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { updated, leveled, xpGained: amount };
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import type { Server, Socket } from "socket.io";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { config } from "../lib/config.js";
|
||||||
|
import { verifyToken, type AuthPayload } from "../middleware/auth.js";
|
||||||
|
import {
|
||||||
|
createDungeon,
|
||||||
|
dungeonNeedsTick,
|
||||||
|
moveInDungeon,
|
||||||
|
placeSummon,
|
||||||
|
resolveAutoCombatRound,
|
||||||
|
type DungeonState,
|
||||||
|
} from "../services/combat.js";
|
||||||
|
import {
|
||||||
|
applyXp,
|
||||||
|
effectiveStats,
|
||||||
|
serializeCharacter,
|
||||||
|
type CharacterWithRelations,
|
||||||
|
} from "../services/progression.js";
|
||||||
|
import { addArmyUnit, armyTypeForSpecialty, summonEnergyCost } from "../services/army.js";
|
||||||
|
|
||||||
|
type AuthedSocket = Socket & { data: { auth: AuthPayload } };
|
||||||
|
|
||||||
|
const worldPresence = new Map<
|
||||||
|
string,
|
||||||
|
{ characterId: string; name: string; specialty: string; x: number; y: number; socketId: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const dungeons = new Map<string, DungeonState>();
|
||||||
|
const combatTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
|
const dungeonSockets = new Map<string, string>(); // characterId -> socketId
|
||||||
|
|
||||||
|
const COMBAT_TICK_MS = 900;
|
||||||
|
|
||||||
|
async function loadFullCharacter(characterId: string) {
|
||||||
|
return prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastWorld(io: Server) {
|
||||||
|
const players = [...worldPresence.values()].map((p) => ({
|
||||||
|
characterId: p.characterId,
|
||||||
|
name: p.name,
|
||||||
|
specialty: p.specialty,
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
}));
|
||||||
|
io.to("world").emit("world:players", {
|
||||||
|
players,
|
||||||
|
portal: { x: config.portalX, y: config.portalY },
|
||||||
|
map: { width: config.mapWidth, height: config.mapHeight },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHandshake(socket: Socket): AuthPayload | null {
|
||||||
|
const cookieHeader = socket.handshake.headers.cookie ?? "";
|
||||||
|
const match = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.find((c) => c.startsWith(`${config.cookieName}=`));
|
||||||
|
const fromCookie = match ? decodeURIComponent(match.split("=").slice(1).join("=")) : undefined;
|
||||||
|
const fromAuth = socket.handshake.auth?.token as string | undefined;
|
||||||
|
const token = fromAuth || fromCookie;
|
||||||
|
if (!token) return null;
|
||||||
|
return verifyToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCombatLoop(characterId: string) {
|
||||||
|
const t = combatTimers.get(characterId);
|
||||||
|
if (t) {
|
||||||
|
clearInterval(t);
|
||||||
|
combatTimers.delete(characterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCombatLoop(io: Server, characterId: string) {
|
||||||
|
if (combatTimers.has(characterId)) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void runCombatTick(io, characterId);
|
||||||
|
}, COMBAT_TICK_MS);
|
||||||
|
combatTimers.set(characterId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCombatTick(io: Server, characterId: string) {
|
||||||
|
const state = dungeons.get(characterId);
|
||||||
|
if (!state || state.over) {
|
||||||
|
stopCombatLoop(characterId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dungeonNeedsTick(state)) return;
|
||||||
|
|
||||||
|
const char = await loadFullCharacter(characterId);
|
||||||
|
if (!char || char.currentZone !== "DUNGEON") {
|
||||||
|
stopCombatLoop(characterId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketId = dungeonSockets.get(characterId);
|
||||||
|
const socket = socketId ? io.sockets.sockets.get(socketId) : undefined;
|
||||||
|
|
||||||
|
const { state: next, damageToPlayer, acted } = resolveAutoCombatRound(state, char);
|
||||||
|
if (!acted && damageToPlayer === 0) return;
|
||||||
|
|
||||||
|
let updatedChar: CharacterWithRelations = char;
|
||||||
|
|
||||||
|
if (damageToPlayer > 0) {
|
||||||
|
const newHp = Math.max(0, char.hp - damageToPlayer);
|
||||||
|
updatedChar = await prisma.character.update({
|
||||||
|
where: { id: char.id },
|
||||||
|
data: { hp: newHp },
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newHp <= 0) {
|
||||||
|
next.over = true;
|
||||||
|
next.won = false;
|
||||||
|
next.log.push("You have fallen. Returning to the world...");
|
||||||
|
dungeons.set(characterId, next);
|
||||||
|
stopCombatLoop(characterId);
|
||||||
|
if (socket) {
|
||||||
|
socket.emit("dungeon:state", next);
|
||||||
|
socket.emit("character:update", serializeCharacter(updatedChar));
|
||||||
|
await exitDungeon(io, socket, characterId, false, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dungeons.set(characterId, next);
|
||||||
|
if (socket) {
|
||||||
|
socket.emit("dungeon:state", next);
|
||||||
|
if (damageToPlayer > 0) {
|
||||||
|
socket.emit("character:update", serializeCharacter(updatedChar));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.won) {
|
||||||
|
stopCombatLoop(characterId);
|
||||||
|
if (socket) {
|
||||||
|
await rewardAndExit(io, socket, updatedChar, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorldRegen(io: Server) {
|
||||||
|
for (const presence of worldPresence.values()) {
|
||||||
|
const char = await loadFullCharacter(presence.characterId);
|
||||||
|
if (!char || char.currentZone !== "WORLD") continue;
|
||||||
|
|
||||||
|
const stats = effectiveStats(char);
|
||||||
|
if (char.hp >= stats.maxHp && char.energy >= char.maxEnergy) continue;
|
||||||
|
|
||||||
|
const newHp = Math.min(stats.maxHp, char.hp + config.regenHp);
|
||||||
|
const newEnergy = Math.min(char.maxEnergy, char.energy + config.regenEnergy);
|
||||||
|
if (newHp === char.hp && newEnergy === char.energy) continue;
|
||||||
|
|
||||||
|
const updated = await prisma.character.update({
|
||||||
|
where: { id: char.id },
|
||||||
|
data: { hp: newHp, energy: newEnergy },
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const socket = io.sockets.sockets.get(presence.socketId);
|
||||||
|
socket?.emit("character:update", serializeCharacter(updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSockets(io: Server) {
|
||||||
|
setInterval(() => {
|
||||||
|
void runWorldRegen(io);
|
||||||
|
}, config.regenIntervalMs);
|
||||||
|
|
||||||
|
io.use((socket, next) => {
|
||||||
|
const auth = authHandshake(socket);
|
||||||
|
if (!auth) {
|
||||||
|
next(new Error("Unauthorized"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(socket as AuthedSocket).data.auth = auth;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on("connection", async (socket: Socket) => {
|
||||||
|
const auth = (socket as AuthedSocket).data.auth;
|
||||||
|
const character = await loadFullCharacter(auth.characterId);
|
||||||
|
if (!character) {
|
||||||
|
socket.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.join("world");
|
||||||
|
if (character.currentZone === "WORLD") {
|
||||||
|
worldPresence.set(character.id, {
|
||||||
|
characterId: character.id,
|
||||||
|
name: character.name,
|
||||||
|
specialty: character.specialty,
|
||||||
|
x: character.mapX,
|
||||||
|
y: character.mapY,
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
broadcastWorld(io);
|
||||||
|
} else {
|
||||||
|
let state = dungeons.get(character.id);
|
||||||
|
if (!state || state.over) {
|
||||||
|
state = createDungeon(character);
|
||||||
|
dungeons.set(character.id, state);
|
||||||
|
}
|
||||||
|
dungeonSockets.set(character.id, socket.id);
|
||||||
|
socket.join(`dungeon:${character.id}`);
|
||||||
|
socket.emit("dungeon:state", state);
|
||||||
|
startCombatLoop(io, character.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("character:update", serializeCharacter(character));
|
||||||
|
|
||||||
|
socket.on("player:move", async (payload: { x: number; y: number }) => {
|
||||||
|
const char = await loadFullCharacter(auth.characterId);
|
||||||
|
if (!char || char.currentZone !== "WORLD") return;
|
||||||
|
|
||||||
|
const x = Math.floor(payload.x);
|
||||||
|
const y = Math.floor(payload.y);
|
||||||
|
if (x < 0 || y < 0 || x >= config.mapWidth || y >= config.mapHeight) {
|
||||||
|
socket.emit("error:message", "Out of bounds");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (x === char.mapX && y === char.mapY) return;
|
||||||
|
|
||||||
|
const updated = await prisma.character.update({
|
||||||
|
where: { id: char.id },
|
||||||
|
data: { mapX: x, mapY: y },
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
worldPresence.set(updated.id, {
|
||||||
|
characterId: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
specialty: updated.specialty,
|
||||||
|
x: updated.mapX,
|
||||||
|
y: updated.mapY,
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
broadcastWorld(io);
|
||||||
|
socket.emit("character:update", serializeCharacter(updated));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("player:enter_dungeon", async () => {
|
||||||
|
const char = await loadFullCharacter(auth.characterId);
|
||||||
|
if (!char || char.currentZone !== "WORLD") return;
|
||||||
|
|
||||||
|
const dist =
|
||||||
|
Math.abs(char.mapX - config.portalX) + Math.abs(char.mapY - config.portalY);
|
||||||
|
if (dist > 1) {
|
||||||
|
socket.emit("error:message", "Move closer to the portal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const floor = Math.max(1, char.dungeonFloor || 1);
|
||||||
|
const updated = await prisma.character.update({
|
||||||
|
where: { id: char.id },
|
||||||
|
data: { currentZone: "DUNGEON", dungeonFloor: floor },
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
worldPresence.delete(char.id);
|
||||||
|
broadcastWorld(io);
|
||||||
|
|
||||||
|
const state = createDungeon(updated);
|
||||||
|
dungeons.set(char.id, state);
|
||||||
|
dungeonSockets.set(char.id, socket.id);
|
||||||
|
socket.leave("world");
|
||||||
|
socket.join(`dungeon:${char.id}`);
|
||||||
|
socket.emit("zone:change", { zone: "DUNGEON" });
|
||||||
|
socket.emit("dungeon:state", state);
|
||||||
|
socket.emit("character:update", serializeCharacter(updated));
|
||||||
|
startCombatLoop(io, char.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("player:exit_dungeon", async () => {
|
||||||
|
stopCombatLoop(auth.characterId);
|
||||||
|
await exitDungeon(io, socket, auth.characterId, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("dungeon:move", async (payload: { x: number; y: number }) => {
|
||||||
|
const char = await loadFullCharacter(auth.characterId);
|
||||||
|
const state = dungeons.get(auth.characterId);
|
||||||
|
if (!char || !state || char.currentZone !== "DUNGEON") return;
|
||||||
|
|
||||||
|
const result = moveInDungeon(state, payload.x, payload.y);
|
||||||
|
if (!result.ok) {
|
||||||
|
socket.emit("error:message", result.error ?? "Cannot move");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dungeons.set(auth.characterId, result.state);
|
||||||
|
dungeonSockets.set(auth.characterId, socket.id);
|
||||||
|
socket.emit("dungeon:state", result.state);
|
||||||
|
startCombatLoop(io, auth.characterId);
|
||||||
|
if (dungeonNeedsTick(result.state)) {
|
||||||
|
await runCombatTick(io, auth.characterId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("dungeon:summon", async (payload: { x: number; y: number }) => {
|
||||||
|
await handleSummon(io, socket, auth.characterId, payload.x, payload.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
const presence = [...worldPresence.entries()].find(([, p]) => p.socketId === socket.id);
|
||||||
|
if (presence) {
|
||||||
|
worldPresence.delete(presence[0]);
|
||||||
|
broadcastWorld(io);
|
||||||
|
}
|
||||||
|
if (dungeonSockets.get(auth.characterId) === socket.id) {
|
||||||
|
dungeonSockets.delete(auth.characterId);
|
||||||
|
// Keep dungeon state; pause combat until reconnect
|
||||||
|
stopCombatLoop(auth.characterId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSummon(
|
||||||
|
io: Server,
|
||||||
|
socket: Socket,
|
||||||
|
characterId: string,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
) {
|
||||||
|
const char = await loadFullCharacter(characterId);
|
||||||
|
const state = dungeons.get(characterId);
|
||||||
|
if (!char || !state || char.currentZone !== "DUNGEON") return;
|
||||||
|
|
||||||
|
const cost = summonEnergyCost(1);
|
||||||
|
if (char.energy < cost) {
|
||||||
|
socket.emit("error:message", "Not enough energy");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = effectiveStats(char);
|
||||||
|
const type = armyTypeForSpecialty(char.specialty);
|
||||||
|
const placed = placeSummon(state, type, Math.floor(x), Math.floor(y), stats.int);
|
||||||
|
if (!placed.ok) {
|
||||||
|
socket.emit("error:message", placed.error ?? "Summon failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.character.update({
|
||||||
|
where: { id: char.id },
|
||||||
|
data: { energy: char.energy - cost },
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await addArmyUnit(char.id, type, 1, 1);
|
||||||
|
|
||||||
|
const refreshed = await loadFullCharacter(characterId);
|
||||||
|
state.energy = updated.energy;
|
||||||
|
dungeons.set(characterId, state);
|
||||||
|
dungeonSockets.set(characterId, socket.id);
|
||||||
|
socket.emit("dungeon:state", state);
|
||||||
|
socket.emit("character:update", serializeCharacter(refreshed!));
|
||||||
|
startCombatLoop(io, characterId);
|
||||||
|
if (dungeonNeedsTick(state)) {
|
||||||
|
await runCombatTick(io, characterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rewardAndExit(
|
||||||
|
io: Server,
|
||||||
|
socket: Socket,
|
||||||
|
character: CharacterWithRelations,
|
||||||
|
state: DungeonState
|
||||||
|
) {
|
||||||
|
const xpGain = 30 + state.floor * 20;
|
||||||
|
const { leveled } = await applyXp(character.id, character, xpGain);
|
||||||
|
|
||||||
|
const lootKeys = [
|
||||||
|
"bone_dagger",
|
||||||
|
"shadow_veil",
|
||||||
|
"grimoire_fragment",
|
||||||
|
"soul_phial",
|
||||||
|
"necrotic_blade",
|
||||||
|
"ossuary_plate",
|
||||||
|
];
|
||||||
|
const key = lootKeys[Math.floor(Math.random() * lootKeys.length)];
|
||||||
|
const item = await prisma.item.findUnique({ where: { key } });
|
||||||
|
if (item) {
|
||||||
|
await prisma.inventoryItem.create({
|
||||||
|
data: { characterId: character.id, itemId: item.id, quantity: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.character.update({
|
||||||
|
where: { id: character.id },
|
||||||
|
data: {
|
||||||
|
dungeonFloor: state.floor + 1,
|
||||||
|
energy: Math.min(character.maxEnergy, character.energy + 10),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.emit("dungeon:reward", {
|
||||||
|
xp: xpGain,
|
||||||
|
leveled,
|
||||||
|
loot: item ? item.name : null,
|
||||||
|
nextFloor: state.floor + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await exitDungeon(io, socket, character.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exitDungeon(
|
||||||
|
io: Server,
|
||||||
|
socket: Socket,
|
||||||
|
characterId: string,
|
||||||
|
won: boolean,
|
||||||
|
defeated = false
|
||||||
|
) {
|
||||||
|
stopCombatLoop(characterId);
|
||||||
|
dungeons.delete(characterId);
|
||||||
|
dungeonSockets.delete(characterId);
|
||||||
|
|
||||||
|
const data: {
|
||||||
|
currentZone: "WORLD";
|
||||||
|
mapX?: number;
|
||||||
|
mapY?: number;
|
||||||
|
hp?: number;
|
||||||
|
} = { currentZone: "WORLD" };
|
||||||
|
|
||||||
|
if (defeated) {
|
||||||
|
data.mapX = config.spawnX;
|
||||||
|
data.mapY = config.spawnY;
|
||||||
|
const char = await prisma.character.findUnique({ where: { id: characterId } });
|
||||||
|
if (char) {
|
||||||
|
data.hp = Math.max(1, Math.floor(char.maxHp * 0.4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data,
|
||||||
|
include: { inventory: { include: { item: true } }, army: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
worldPresence.set(updated.id, {
|
||||||
|
characterId: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
specialty: updated.specialty,
|
||||||
|
x: updated.mapX,
|
||||||
|
y: updated.mapY,
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.leave(`dungeon:${characterId}`);
|
||||||
|
socket.join("world");
|
||||||
|
broadcastWorld(io);
|
||||||
|
socket.emit("zone:change", { zone: "WORLD", won });
|
||||||
|
socket.emit("character:update", serializeCharacter(updated));
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user