import React, { useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Flame, Map, Package, RotateCcw, Save, ScrollText, Swords } from 'lucide-react';
import './style.css';
const SAVE_KEY = 'qinglan-wuxiang-save-v2';
const PLAYER_KEY = 'qinglan-wuxiang-player-id';
const API_BASE = import.meta.env.VITE_API_BASE || '';
const canUseCloudSave = Boolean(API_BASE);
const GAME = {
player: {
name: '沈砚', hp: 150, maxHp: 150, mp: 70, maxMp: 70, atk: 18, def: 8,
lvl: 1, xp: 0, gold: 120, chapter: 0,
equip: { weapon: '桃木剑', armor: '粗布道袍', trinket: '无' },
inv: { 金疮药: 6, 回气散: 4, 辟谷丹: 3, 清心符: 1 },
},
items: {
金疮药: { kind: '药', desc: '恢复45点气血', hp: 45 },
回气散: { kind: '药', desc: '恢复35点真元', mp: 35 },
辟谷丹: { kind: '药', desc: '恢复25点气血与20点真元', hp: 25, mp: 20 },
凝血丸: { kind: '药', desc: '恢复90点气血', hp: 90 },
醒神露: { kind: '药', desc: '恢复80点真元', mp: 80 },
归元丹: { kind: '药', desc: '气血真元全部回满', full: true },
清心符: { kind: '符', desc: '战斗中恢复35点气血', combat: true, hp: 35 },
火鸦符: { kind: '符', desc: '战斗中造成55点火焰伤害', combat: true, damage: 55 },
玄霜符: { kind: '符', desc: '战斗中造成90点寒霜伤害', combat: true, damage: 90 },
铁鳞丹: { kind: '药', desc: '本场战斗护体+8', combat: true, buffDef: 8 },
龙涎香: { kind: '材', desc: '渡口支线信物,可卖可留' },
残玉: { kind: '证物', desc: '无相教残玉,推动主线的证物' },
赤蛟鳞: { kind: '材', desc: '灼热如炭的蛟鳞' },
星砂: { kind: '材', desc: '天墟集通用的稀有材料' },
},
gear: {
桃木剑: { slot: 'weapon', atk: 3 }, 青钢剑: { slot: 'weapon', atk: 8 },
霜纹剑: { slot: 'weapon', atk: 15, maxMp: 10 }, 玄铁尺: { slot: 'weapon', atk: 24, def: 3 },
粗布道袍: { slot: 'armor', def: 2 }, 云纹袍: { slot: 'armor', def: 8, maxMp: 15 },
鳞甲道衣: { slot: 'armor', def: 14, maxHp: 40 }, 太乙鹤氅: { slot: 'armor', def: 21, maxMp: 35 },
无: { slot: 'trinket' }, 兽牙坠: { slot: 'trinket', atk: 4 },
镇魂铃: { slot: 'trinket', def: 4, maxMp: 20 }, 星河佩: { slot: 'trinket', atk: 8, def: 5, maxHp: 30 },
},
skills: [
{ name: '斩风', mp: 0, power: 1, text: '稳健一剑' },
{ name: '离火诀', mp: 12, power: 1.8, text: '掌心火符炸开' },
{ name: '听雨剑', mp: 18, power: 2.4, text: '剑势如雨连绵', lvl: 3 },
{ name: '雷引', mp: 28, power: 3.2, text: '引雷破邪', lvl: 5 },
{ name: '太虚归元', mp: 38, power: 2.1, healRate: 0.35, text: '伤敌并回气血', lvl: 7 },
],
shops: {
青岚镇: [['金疮药', 20], ['回气散', 22], ['火鸦符', 45], ['青钢剑', 120], ['云纹袍', 110]],
栖霞渡: [['辟谷丹', 28], ['铁鳞丹', 40], ['醒神露', 65], ['霜纹剑', 260], ['镇魂铃', 220]],
沉剑关: [['凝血丸', 70], ['玄霜符', 90], ['玄铁尺', 520], ['鳞甲道衣', 480]],
天墟集: [['归元丹', 180], ['太乙鹤氅', 900], ['星河佩', 760]],
},
mobs: {
wolf: { name: '月牙狼', hp: 75, atk: 13, def: 4, xp: 35, gold: 22, drop: ['金疮药'] },
bandit: { name: '黑风寨哨探', hp: 95, atk: 17, def: 6, xp: 50, gold: 38, drop: ['火鸦符'] },
ghost: { name: '破庙伥鬼', hp: 110, atk: 20, def: 5, xp: 70, gold: 45, drop: ['清心符', '残玉'] },
snake: { name: '毒沼青鳞蛇', hp: 140, atk: 24, def: 8, xp: 95, gold: 62, drop: ['铁鳞丹', '龙涎香'] },
guard: { name: '沉剑关叛军', hp: 165, atk: 30, def: 12, xp: 125, gold: 90, drop: ['凝血丸'] },
demon: { name: '镜湖水魈', hp: 210, atk: 36, def: 13, xp: 170, gold: 120, drop: ['玄霜符'] },
cult: { name: '无相教护法', hp: 270, atk: 45, def: 17, xp: 240, gold: 180, drop: ['星砂'] },
dragon: { name: '赤蛟残魂', hp: 360, atk: 55, def: 21, xp: 380, gold: 260, drop: ['赤蛟鳞'] },
lord: { name: '无相教主·谢玄衣', hp: 520, atk: 66, def: 25, xp: 700, gold: 500, drop: ['星砂', '归元丹'] },
},
chapters: [
{ place: '青岚镇', title: '雨夜青灯', story: '师父失踪三月,镇外古井忽然反涌桃花。你在药铺后院找到半枚残玉,玉上刻着“无相”二字。镇民请你清掉竹林妖狼,换一条进山的路。', foes: ['wolf', 'wolf'], reward: { gold: 80, items: { 青钢剑: 1 } } },
{ place: '黑风岭', title: '山寨旧账', story: '黑风寨不是普通贼窝,账册里记着给栖霞渡送人的日期。二当家认得师父的剑穗,却只说“活人进了渡口,出来的都是影子”。', foes: ['bandit', 'bandit', 'ghost'], reward: { gold: 120, items: { 兽牙坠: 1, 残玉: 1 } } },
{ place: '栖霞渡', title: '渡口灯影', story: '渡船夜夜空来满去。船娘柳七娘愿意带你过江,条件是取回被青鳞蛇吞下的龙涎香。水面映出师父背影,他似乎在阻止你继续查。', foes: ['snake', 'ghost'], reward: { gold: 180, items: { 霜纹剑: 1, 龙涎香: 1 } } },
{ place: '沉剑关', title: '关城雪', story: '沉剑关军械库被无相教接管,守将留下血书:教众在收集修士心镜,欲开天墟。你救出铁匠韩铮,他替你重铸佩剑。', foes: ['guard', 'guard', 'demon'], reward: { gold: 260, items: { 鳞甲道衣: 1 } } },
{ place: '镜湖', title: '一湖两月', story: '镜湖有两个月亮,一个在天上,一个在水底。水魈用熟人的声音诱你回头;你若斩破倒影,便能看见师父被锁在天墟门前。', foes: ['demon', 'cult'], reward: { gold: 320, items: { 镇魂铃: 1, 星砂: 1 } } },
{ place: '天墟集', title: '市中仙魔', story: '天墟集三百年开一次,仙门、妖市、鬼商同席。你用星砂换到入墟令,也听见谢玄衣的真相:他曾是师父师兄,为复活亡妻不惜以一州心魂炼镜。', foes: ['cult', 'cult', 'dragon'], reward: { gold: 420, items: { 玄铁尺: 1, 归元丹: 1 } } },
{ place: '无相宫', title: '镜碎人间', story: '无相宫悬在破碎星河上。师父尚有一息,愿把毕生修为渡给你。谢玄衣并非癫狂,他清醒地选择牺牲别人。此战之后,青岚镇的雨会停。', foes: ['dragon', 'lord'], reward: { gold: 888, items: { 太乙鹤氅: 1, 星河佩: 1 } } },
],
};
const clone = (value) => JSON.parse(JSON.stringify(value));
const addLog = (logs, line) => [line, ...logs].slice(0, 80);
function loadState() {
try {
const raw = window.localStorage.getItem(SAVE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
window.localStorage.removeItem(SAVE_KEY);
return null;
}
}
function baseState() {
return { p: clone(GAME.player), log: ['雨落青岚,故事开始。'], mode: 'town', combat: null };
}
function stats(player) {
const result = { ...player };
Object.values(player.equip).forEach((name) => {
const gear = GAME.gear[name] || {};
Object.entries(gear).forEach(([key, value]) => {
if (key !== 'slot') result[key] = (result[key] || 0) + value;
});
});
result.hp = Math.min(result.hp, result.maxHp);
result.mp = Math.min(result.mp, result.maxMp);
return result;
}
function addItem(player, name, count = 1) {
return { ...player, inv: { ...player.inv, [name]: (player.inv[name] || 0) + count } };
}
function gainXp(player, amount) {
const next = { ...player, xp: player.xp + amount };
while (next.xp >= next.lvl * 100) {
next.xp -= next.lvl * 100;
next.lvl += 1;
next.maxHp += 28;
next.maxMp += 12;
next.atk += 5;
next.def += 3;
next.hp = next.maxHp;
next.mp = next.maxMp;
}
return next;
}
function App() {
const [state, setState] = useState(() => loadState() || baseState());
const [playerId, setPlayerId] = useState(() => window.localStorage.getItem(PLAYER_KEY) || 'player001');
const [syncing, setSyncing] = useState(false);
const player = useMemo(() => stats(state.p), [state.p]);
const chapter = GAME.chapters[state.p.chapter];
const knownSkills = GAME.skills.filter((skill) => (skill.lvl || 1) <= state.p.lvl);
const shop = GAME.shops[chapter?.place] || GAME.shops.青岚镇;
function update(line, patch = {}) {
setState((current) => ({ ...current, ...patch, log: addLog(current.log, line) }));
}
async function save() {
window.localStorage.setItem(SAVE_KEY, JSON.stringify(state));
window.localStorage.setItem(PLAYER_KEY, playerId);
if (!canUseCloudSave) return update('已保存到本地。配置 VITE_API_BASE 后可同步到 MySQL。');
setSyncing(true);
try {
const response = await fetch(`${API_BASE}/api/saves/${encodeURIComponent(playerId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ save: state }),
});
if (!response.ok) throw new Error((await response.json()).error || '云存档失败');
update('已保存到服务器:MySQL 持久化、Redis 缓存、Kafka 已记录事件。');
} catch (error) {
update(`本地已保存,云存档失败:${error.message}`);
} finally {
setSyncing(false);
}
}
async function loadCloudSave() {
if (!canUseCloudSave) return update('还没有配置 VITE_API_BASE,当前只能读取本地存档。');
setSyncing(true);
try {
const response = await fetch(`${API_BASE}/api/saves/${encodeURIComponent(playerId)}`);
if (!response.ok) throw new Error((await response.json()).error || '读取云存档失败');
const data = await response.json();
window.localStorage.setItem(SAVE_KEY, JSON.stringify(data.save));
window.localStorage.setItem(PLAYER_KEY, playerId);
setState({ ...data.save, log: addLog(data.save.log || [], `已从${data.source}读取服务器存档。`) });
} catch (error) {
update(`读取云存档失败:${error.message}`);
} finally {
setSyncing(false);
}
}
function reset() {
window.localStorage.removeItem(SAVE_KEY);
setState(baseState());
}
function startChapter() {
if (!chapter) return;
const foes = chapter.foes.map((id) => ({ ...GAME.mobs[id], id, maxHp: GAME.mobs[id].hp, hp: GAME.mobs[id].hp }));
update(`踏入【${chapter.title}】。${chapter.story}`, { mode: 'combat', combat: { foes } });
}
function damage(attacker, defender, power = 1) {
return Math.max(1, Math.floor(attacker.atk * power + Math.random() * 8 - defender.def * 0.6));
}
function win(combat) {
let next = state.p;
let gold = 0;
const found = [];
combat.foes.forEach((foe) => {
next = gainXp(next, foe.xp);
gold += foe.gold;
foe.drop.forEach((item) => {
if (Math.random() < 0.55) {
next = addItem(next, item);
found.push(item);
}
});
});
if (chapter?.reward) {
gold += chapter.reward.gold;
Object.entries(chapter.reward.items).forEach(([item, count]) => { next = addItem(next, item, count); });
}
next = { ...next, gold: next.gold + gold, chapter: Math.min(next.chapter + 1, GAME.chapters.length) };
update(`章节完成,得灵石${gold}${found.length ? `,额外获得${found.join('、')}` : ''}。`, { p: next, mode: 'town', combat: null });
}
function cast(skill) {
if (state.mode !== 'combat') return;
const combat = clone(state.combat);
let next = { ...state.p };
const me = stats(next);
if (me.mp < skill.mp) return update('真元不足,先用回气散或换低消耗招式。');
next.mp -= skill.mp;
const foe = combat.foes.find((target) => target.hp > 0);
const amount = damage(me, foe, skill.power);
foe.hp -= amount;
if (skill.healRate) next.hp = Math.min(me.maxHp, next.hp + Math.floor(me.maxHp * skill.healRate));
let line = `${skill.name}:${skill.text},对${foe.name}造成${amount}伤害。`;
if (combat.foes.every((target) => target.hp <= 0)) return win(combat);
combat.foes.filter((target) => target.hp > 0).forEach((enemy) => {
const hit = damage(enemy, stats(next));
next.hp -= hit;
line += ` ${enemy.name}反击${hit}。`;
});
if (next.hp <= 0) {
next = { ...next, hp: Math.ceil(next.maxHp * 0.6), mp: Math.ceil(next.maxMp * 0.5), gold: Math.max(0, next.gold - 50) };
return update(`${line} 你力竭昏迷,被柳七娘拖回客栈,损失50灵石。`, { p: next, mode: 'town', combat: null });
}
return update(line, { p: next, combat });
}
function useItem(name) {
const item = GAME.items[name];
if (!state.p.inv[name]) return;
if (GAME.gear[name]) return equip(name);
if (!item) return;
let next = { ...state.p, inv: { ...state.p.inv, [name]: state.p.inv[name] - 1 } };
if (item.combat && state.mode !== 'combat') return update(`${name}需要在战斗中使用。`);
if (item.damage) {
const combat = clone(state.combat);
const foe = combat.foes.find((target) => target.hp > 0);
foe.hp -= item.damage;
update(`${name}击中${foe.name},造成${item.damage}伤害。`, { p: next, combat });
if (combat.foes.every((target) => target.hp <= 0)) win(combat);
return;
}
if (item.buffDef) next.def += item.buffDef;
if (item.full) {
const full = stats(next);
next.hp = full.maxHp;
next.mp = full.maxMp;
} else {
const current = stats(next);
next.hp = Math.min(current.maxHp, next.hp + (item.hp || 0));
next.mp = Math.min(current.maxMp, next.mp + (item.mp || 0));
}
update(`使用${name}。`, { p: next });
}
function buy(name, price) {
if (state.p.gold < price) return update('灵石不足。');
update(`买下${name}。`, { p: addItem({ ...state.p, gold: state.p.gold - price }, name) });
}
function equip(name) {
const gear = GAME.gear[name];
if (!gear) return;
update(`装备${name}。`, { p: { ...state.p, equip: { ...state.p.equip, [gear.slot]: name } } });
}
return (
{state.p.name} Lv.{state.p.lvl}
气血 {player.hp}/{player.maxHp} 真元 {player.mp}/{player.maxMp} 灵石 {state.p.gold} 阅历 {state.p.xp}/{state.p.lvl * 100}
攻 {player.atk} 防 {player.def} 地点:{chapter?.place || '青岚镇'}
兵器 {state.p.equip.weapon}|衣甲 {state.p.equip.armor}|佩饰 {state.p.equip.trinket}
{chapter ? chapter.title : '尾声:雨停青岚'}
{chapter ? chapter.story : '谢玄衣伏诛,师父在晨光中散去。你回到镇上开了一间小小剑馆,偶尔也会在雨夜想起天墟星河。江湖仍有妖邪,但人间不再是别人的祭品。'}
{chapter && state.mode !== 'combat' && }
{state.mode === 'combat' && (
回合战斗
{state.combat.foes.map((foe, index) =>
{foe.name}{Math.max(0, foe.hp)}/{foe.maxHp}
)}
{knownSkills.map((skill) => )}
)}
行囊
{Object.entries(state.p.inv).filter(([, count]) => count > 0).map(([name, count]) => )}
市集
{shop.map(([name, price]) => )}
江湖札记
{state.log.map((line, index) => {line}
)}
);
}
createRoot(document.getElementById('root')).render();