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();