docker000 / web /play /app.js
seachen's picture
Playable frontend: FastAPI + canvas shop UI
9fdeb41 verified
Raw
History Blame Contribute Delete
16.7 kB
/* 小镇角落小店 — 可玩 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");