/* 小镇角落小店 — 可玩 Vue 3 前端 */ const { createApp, ref, computed, watch, onMounted, nextTick } = Vue; const STATE_COLOR = { SPAWNING: "#b0a8a0", BROWSE: "#6fa8dc", LINGER: "#8fbf7f", CONSIDER: "#e0b050", QUEUE: "#d49050", PAY: "#c06060", TALK: "#9b7ed9", EXIT: "#888", }; const CELL = 48; createApp({ setup() { const view = ref(null); const sessionId = ref(null); /** 仅用户点击操作时锁定;自动流逝绝不改 busy,避免按钮狂闪 */ const busy = ref(false); const error = ref(""); const speed = ref(180); const showNight = ref(false); const showHelp = ref(true); const nightDay = ref(null); const canvas = ref(null); const restockId = ref(""); const stockPid = ref(""); const stockSlot = ref(""); const toasts = ref([]); const floaters = ref([]); const flavor = ref( localStorage.getItem("town-shop-flavor") === "latte" ? "latte" : "mocha" ); let autoTimer = null; let autoInFlight = false; let toastSeq = 0; let floatSeq = 0; const imgCache = new Map(); function setFlavor(name) { flavor.value = name === "latte" ? "latte" : "mocha"; document.documentElement.setAttribute("data-flavor", flavor.value); localStorage.setItem("town-shop-flavor", flavor.value); const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.setAttribute("content", flavor.value === "latte" ? "#eff1f5" : "#1e1e2e"); nextTick(draw); } function ctp(name, fallback) { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } const reversedLog = computed(() => [...(view.value?.log || [])].reverse()); const catalogOptions = computed(() => view.value?.catalog || []); const stockOptions = computed(() => { const inv = view.value?.inventory || []; return inv.length ? inv : catalogOptions.value; }); const bgStyle = computed(() => { const bg = view.value?.art?.shop_bg; return bg ? { backgroundImage: `url(/${bg})` } : {}; }); function isHere(agentId) { return (view.value?.agents || []).some((a) => a.agent_id === agentId); } function img(src) { if (!src) return null; if (imgCache.has(src)) return imgCache.get(src); const im = new Image(); im.onload = () => draw(); im.src = "/" + String(src).replace(/^\//, ""); imgCache.set(src, im); return im; } function pushToast(text, kind = "info") { const id = ++toastSeq; toasts.value = [...toasts.value, { id, text, kind }].slice(-5); setTimeout(() => { toasts.value = toasts.value.filter((t) => t.id !== id); }, 3200); } function pushFloater(text, kind = "sale") { const id = ++floatSeq; floaters.value = [...floaters.value, { id, text, kind }].slice(-4); setTimeout(() => { floaters.value = floaters.value.filter((f) => f.id !== id); }, 1600); } async function api(path, opts = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, ...opts, }); if (!res.ok) { let detail = res.statusText; try { const j = await res.json(); detail = j.detail || JSON.stringify(j); } catch (_) {} throw new Error(typeof detail === "string" ? detail : "请求失败"); } return res.json(); } function apply(payload, { fromAuto = false } = {}) { view.value = payload.view; sessionId.value = payload.session_id || sessionId.value; syncSelects(); // 自动流逝时只提示「进店/成交」,避免每帧 toast 刷屏 const toastsIn = payload.view?.toasts || []; for (const t of toastsIn) { if (fromAuto && t.kind === "info" && !/进店|成交|收入|营收|夜幕/.test(t.text || "")) { continue; } pushToast(t.text, t.kind || "info"); } for (const ev of payload.view?.events || []) { if (ev.type === "sale") pushFloater(ev.text, "sale"); if (ev.type === "enter") pushFloater(ev.text, "enter"); } const m = payload.view?.meta; if (m?.phase === "NIGHT" && payload.view.night && nightDay.value !== m.day_index) { nightDay.value = m.day_index; showNight.value = true; } // 自动 tick 由 ensureAuto 的串行 loop 自己排程,这里不要每次重建 if (!fromAuto) { ensureAuto(); } else if (m?.phase === "NIGHT" || m?.phase === "PREP") { stopAuto(); } nextTick(draw); } function syncSelects() { const cat = catalogOptions.value; const stock = stockOptions.value; if (!restockId.value && cat[0]) restockId.value = cat[0].product_id; if (!stockPid.value && stock[0]) stockPid.value = stock[0].product_id; const slots = view.value?.displays || []; if (!stockSlot.value && slots[0]) stockSlot.value = slots[0].slot; } function stopAuto() { if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; } } async function autoTickOnce() { if (!sessionId.value || autoInFlight || busy.value) return; if (!view.value?.ui?.can_tick || view.value.meta.paused) return; autoInFlight = true; try { const data = await api(`/api/session/${sessionId.value}/tick`, { method: "POST", body: JSON.stringify({ steps: 1, delta: 0.25, skip_idle: true, max_skip: 60, }), }); apply(data, { fromAuto: true }); } catch (e) { // 自动流逝失败不闪按钮,只记一条 console.warn(e); } finally { autoInFlight = false; } } function ensureAuto() { stopAuto(); const ms = Number(speed.value); if (!ms) return; if (!view.value?.ui?.can_tick) return; if (view.value.meta.paused) return; const loop = async () => { autoTimer = null; if (!view.value?.ui?.can_tick || view.value.meta.paused || !Number(speed.value)) { return; } await autoTickOnce(); // 仍在营业中再排下一次(串行,绝不重叠) if (view.value?.ui?.can_tick && !view.value.meta.paused && Number(speed.value)) { autoTimer = setTimeout(loop, Number(speed.value)); } }; autoTimer = setTimeout(loop, ms); } // 兼容模板里的 @change="setupAuto" function setupAuto() { ensureAuto(); } async function newGame() { if (busy.value) return; busy.value = true; stopAuto(); error.value = ""; try { const seed = 7 + Math.floor(Math.random() * 90); const data = await api("/api/session", { method: "POST", body: JSON.stringify({ seed, pace: "Brisk" }), }); nightDay.value = null; showNight.value = false; apply(data); } catch (e) { error.value = e.message || "开局失败"; view.value = null; } finally { busy.value = false; } } /** 用户主动操作:可短暂 busy;不重建 auto 定时器(由 ensureAuto 按阶段处理) */ async function act(path, body, { lock = true } = {}) { if (!sessionId.value) return; if (lock && busy.value) return; if (lock) busy.value = true; // 用户操作时暂停自动一轮,避免抢请求 const wasAuto = !!Number(speed.value) && view.value?.ui?.can_tick; if (lock) stopAuto(); try { const data = await api(`/api/session/${sessionId.value}${path}`, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined, }); apply(data); } catch (e) { pushToast(e.message, "err"); } finally { if (lock) busy.value = false; if (wasAuto) ensureAuto(); } } const openShop = async () => { await act("/open"); if (speed.value === 0) speed.value = 180; ensureAuto(); }; const sleep = () => act("/sleep"); const tickOnce = () => act("/tick", { steps: 1, delta: 0.25, skip_idle: true, max_skip: 80 }); const fastNight = () => act("/run_to_night"); const setLight = (mode) => act("/set_light", { mode }); const togglePause = async () => { await act("/pause", { paused: !view.value?.meta?.paused }); ensureAuto(); }; const restock = () => act("/restock", { product_id: restockId.value, qty: 1 }); const stock = () => act("/stock_display", { product_id: stockPid.value, slot_id: stockSlot.value, qty: 1, }); async function quickPrep() { if (busy.value) return; busy.value = true; stopAuto(); try { const picks = (catalogOptions.value || []).slice(0, 3); for (const p of picks) { try { const data = await api(`/api/session/${sessionId.value}/restock`, { method: "POST", body: JSON.stringify({ product_id: p.product_id, qty: 2 }), }); apply(data); } catch (_) {} } const empty = (view.value?.displays || []).filter((d) => !d.qty); const inv = stockOptions.value; for (let i = 0; i < Math.min(empty.length, inv.length); i++) { try { const data = await api(`/api/session/${sessionId.value}/stock_display`, { method: "POST", body: JSON.stringify({ product_id: inv[i].product_id, slot_id: empty[i].slot, qty: 1, }), }); apply(data); } catch (_) {} } pushToast("备货完成,可以开始营业了", "info"); } finally { busy.value = false; } } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function drawBubble(ctx, x, y, text) { const t = String(text).slice(0, 18); const peach = ctp("--ctp-peach", "#fab387"); const base = ctp("--ctp-mantle", "#181825"); const ink = ctp("--ctp-text", "#cdd6f4"); ctx.font = "12px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; const tw = ctx.measureText(t).width; const bw = tw + 16; const bh = 24; const bx = x - bw / 2; const by = y - bh - 10; ctx.fillStyle = base; ctx.strokeStyle = peach; roundRect(ctx, bx, by, bw, bh, 10); ctx.fill(); ctx.stroke(); ctx.fillStyle = ink; ctx.textAlign = "center"; ctx.fillText(t, x, by + 16); ctx.textAlign = "left"; } function draw() { const el = canvas.value; if (!el || !view.value) return; const ctx = el.getContext("2d"); const w = el.width; const h = el.height; const base = ctp("--ctp-base", "#1e1e2e"); const surface0 = ctp("--ctp-surface0", "#313244"); const surface1 = ctp("--ctp-surface1", "#45475a"); const text = ctp("--ctp-text", "#cdd6f4"); const subtext = ctp("--ctp-subtext0", "#a6adc8"); const peach = ctp("--ctp-peach", "#fab387"); const sky = ctp("--ctp-sky", "#89dceb"); const crust = ctp("--ctp-crust", "#11111b"); ctx.clearRect(0, 0, w, h); ctx.fillStyle = base; ctx.fillRect(0, 0, w, h); const v = view.value; const gw = v.grid.w; const gh = v.grid.h; const tags = v.grid.cell_tags || {}; const markersZh = v.grid.markers_zh || {}; for (let y = 0; y < gh; y++) { for (let x = 0; x < gw; x++) { const px = x * CELL; const py = y * CELL; const t = tags[`${x},${y}`] || []; if (t.includes("wall") && t.includes("window")) ctx.fillStyle = sky + "66"; else if (t.includes("wall")) ctx.fillStyle = surface1 + "cc"; else if (t.includes("is_entrance")) ctx.fillStyle = peach + "55"; else ctx.fillStyle = (x + y) % 2 === 0 ? surface0 + "aa" : surface1 + "66"; ctx.fillRect(px, py, CELL, CELL); } } for (const f of v.furniture || []) { const px = f.x * CELL; const py = f.y * CELL; const im = img(f.sprite); if (im && im.complete && im.naturalWidth) { ctx.save(); roundRect(ctx, px + 2, py + 2, CELL - 4, CELL - 4, 8); ctx.clip(); ctx.drawImage(im, px + 2, py + 2, CELL - 4, CELL - 4); ctx.restore(); } else { ctx.fillStyle = "rgba(120,90,70,0.5)"; roundRect(ctx, px + 6, py + 6, CELL - 12, CELL - 12, 6); ctx.fill(); } ctx.fillStyle = text; ctx.font = "11px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; ctx.fillText(String(f.name || "").slice(0, 6), px + 4, py + CELL - 5); } const slotPos = {}; for (const f of v.furniture || []) { for (const s of f.slots || []) slotPos[s] = [f.x, f.y]; } let di = 0; for (const d of v.displays || []) { if (!d.product_id || !d.qty) continue; const [sx, sy] = slotPos[d.slot] || [1 + (di % 4), 3]; di++; const px = sx * CELL + 28; const py = sy * CELL + 4; const im = img(d.sprite); ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.beginPath(); ctx.arc(px, py + 14, 13, 0, Math.PI * 2); ctx.fill(); if (im && im.complete && im.naturalWidth) { ctx.save(); ctx.beginPath(); ctx.arc(px, py + 14, 12, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(im, px - 12, py + 2, 24, 24); ctx.restore(); } ctx.fillStyle = text; ctx.font = "bold 11px 'JetBrains Mono',sans-serif"; ctx.fillText("×" + d.qty, px + 12, py + 18); } for (const [name, xy] of Object.entries(v.grid.markers || {})) { ctx.fillStyle = subtext; ctx.font = "10px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; ctx.fillText(markersZh[name] || name, xy[0] * CELL + 3, xy[1] * CELL + 12); } // 客人放大绘制 for (const a of v.agents || []) { const [ax, ay] = a.xy || [5, 9]; const px = ax * CELL + CELL / 2; const py = ay * CELL + CELL / 2; const col = STATE_COLOR[a.state] || "#888"; // 光晕 ctx.beginPath(); ctx.arc(px, py, 22, 0, Math.PI * 2); ctx.fillStyle = col + "55"; ctx.fill(); ctx.beginPath(); ctx.arc(px, py, 18, 0, Math.PI * 2); ctx.fillStyle = col; ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = "#fff"; ctx.stroke(); const portrait = img(a.portrait); if (portrait && portrait.complete && portrait.naturalWidth) { ctx.save(); ctx.beginPath(); ctx.arc(px, py, 15, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(portrait, px - 15, py - 15, 30, 30); ctx.restore(); } ctx.fillStyle = text; ctx.font = "bold 12px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; ctx.textAlign = "center"; ctx.fillText(a.name || "", px, py + 32); ctx.font = "11px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; ctx.fillStyle = col; ctx.fillText(a.state_zh || "", px, py + 46); ctx.textAlign = "left"; if (a.bark) drawBubble(ctx, px, py - 22, a.bark); } } watch(view, () => nextTick(draw), { deep: true }); onMounted(async () => { setFlavor(flavor.value); await newGame(); setInterval(() => { if (view.value) draw(); }, 500); }); return { view, busy, error, speed, showNight, showHelp, canvas, restockId, stockPid, stockSlot, reversedLog, catalogOptions, stockOptions, bgStyle, toasts, floaters, flavor, setFlavor, isHere, newGame, openShop, sleep, tickOnce, fastNight, setLight, togglePause, restock, stock, quickPrep, setupAuto, }; }, }).mount("#app");