first commit
This commit is contained in:
@@ -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" />
|
||||
Reference in New Issue
Block a user