docker000 / game /webapi /view.py
seachen's picture
Playable frontend: FastAPI + canvas shop UI
9ad9b76 verified
Raw
History Blame Contribute Delete
11 kB
"""将会话序列化为前端视图模型(含简体中文标签)。"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, List, Optional
from game.sim.session import GameSession
from game.webapi.i18n import (
atm_zh,
light_zh,
marker_zh,
pace_zh,
phase_zh,
state_zh,
)
ROOT = Path(__file__).resolve().parents[2]
PHASE_BG = {
"PREP": "assets/shop/interior_morning_01.jpg",
"MORNING": "assets/shop/interior_morning_01.jpg",
"AFTERNOON": "assets/shop/interior_afternoon_02.jpg",
"DUSK": "assets/shop/interior_dusk_01.jpg",
"CLOSING": "assets/shop/interior_golden_hour_honest_01.jpg",
"NIGHT": "assets/shop/interior_night_01.jpg",
}
PHASE_ICON = {
"PREP": "assets/ui/phase_morning_01.jpg",
"MORNING": "assets/ui/phase_morning_01.jpg",
"AFTERNOON": "assets/ui/phase_afternoon_01.jpg",
"DUSK": "assets/ui/phase_dusk_01.jpg",
"CLOSING": "assets/ui/phase_closing_01.jpg",
"NIGHT": "assets/ui/phase_night_01.jpg",
}
KIND_ZH = {"regular": "熟客", "passerby": "路人"}
@lru_cache(maxsize=1)
def _manifest() -> Dict[str, str]:
path = ROOT / "assets/manifest/assets.json"
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return dict(data.get("assets") or {})
def _exists(rel: Optional[str]) -> Optional[str]:
if not rel:
return None
p = ROOT / rel
return rel if p.is_file() else None
def _resolve_product_sprite(product_id: Optional[str], db) -> Optional[str]:
if not product_id:
return None
prod = (db.products or {}).get(product_id) or {}
for key in (
prod.get("asset"),
f"assets/products/{product_id}_01.jpg",
_manifest().get(f"product.{product_id}"),
):
hit = _exists(key)
if hit:
return hit
return None
def _resolve_furniture_sprite(furniture_id: str, db) -> Optional[str]:
fdef = (db.furniture or {}).get(furniture_id) or {}
for key in (
fdef.get("asset"),
f"assets/furniture/{furniture_id}_01.jpg",
_manifest().get(f"furniture.{furniture_id}"),
):
hit = _exists(key)
if hit:
return hit
return None
def _resolve_npc_portrait(npc_id: str, db) -> Optional[str]:
npc = (db.npcs or {}).get(npc_id) or {}
for key in (
npc.get("portrait"),
npc.get("asset"),
f"assets/characters/{npc_id}_portrait_01.jpg",
f"assets/characters/{npc_id}_01.jpg",
_manifest().get(f"character.{npc_id}"),
_manifest().get("character.regular_lan") if "lan" in npc_id else None,
_manifest().get("character.regular_mori") if "mori" in npc_id else None,
):
hit = _exists(key)
if hit:
return hit
for name in (
"assets/characters/passerby_student_01.jpg",
"assets/characters/passerby_commuter_02.jpg",
"assets/characters/passerby_elder_01.jpg",
):
hit = _exists(name)
if hit:
return hit
return None
def _npc_name(npc_id: str, db) -> str:
npc = (db.npcs or {}).get(npc_id) or {}
return str(npc.get("name") or npc.get("display_name") or npc_id)
def _product_name(product_id: str, db) -> str:
prod = (db.products or {}).get(product_id) or {}
return str(prod.get("name") or product_id)
def build_view(session: GameSession, log: Optional[List[str]] = None) -> Dict[str, Any]:
db = session.db
phase = session.day.phase.value
markers = {k: [int(v[0]), int(v[1])] for k, v in session.space.markers.items()}
pace_val = session.day.pace.value if hasattr(session.day.pace, "value") else str(session.day.pace)
light = getattr(session.atmosphere, "light", "warm")
furniture: List[dict] = []
for item in session.state.get("shop", {}).get("layout") or []:
fid = item.get("furniture_id") or ""
fdef = (db.furniture or {}).get(fid) or {}
furniture.append(
{
"id": item.get("instance_id"),
"fid": fid,
"name": fdef.get("name") or fid,
"x": int(item.get("x", 0)),
"y": int(item.get("y", 0)),
"rot90": bool(item.get("rot90", False)),
"sprite": _resolve_furniture_sprite(fid, db),
"slots": list(fdef.get("display_slots") or []),
}
)
displays: List[dict] = []
raw_disp = session.state.get("shop", {}).get("displays") or {}
for slot, info in raw_disp.items():
info = info or {}
pid = info.get("product_id")
displays.append(
{
"slot": slot,
"slot_label": slot.replace("_", " "),
"product_id": pid,
"name": _product_name(pid, db) if pid else "空",
"qty": int(info.get("qty") or 0),
"sprite": _resolve_product_sprite(pid, db) if pid else None,
}
)
inventory: List[dict] = []
inv = session.state.get("shop", {}).get("inventory") or {}
for pid, qty in inv.items():
inventory.append(
{
"product_id": pid,
"name": _product_name(pid, db),
"qty": int(qty),
"price": int(((db.products or {}).get(pid) or {}).get("price") or 0),
"sprite": _resolve_product_sprite(pid, db),
}
)
inventory.sort(key=lambda x: (-x["qty"], x["name"]))
agents: List[dict] = []
for agent in list(session.director.agents):
marker = agent.current_marker()
xy = markers.get(marker) or markers.get("door") or [5, 9]
st = agent.state.value if hasattr(agent.state, "value") else str(agent.state)
agents.append(
{
"agent_id": agent.agent_id,
"npc_id": agent.npc_id,
"name": _npc_name(agent.npc_id, db),
"kind": agent.kind,
"kind_zh": KIND_ZH.get(agent.kind, agent.kind),
"state": st,
"state_zh": state_zh(st),
"marker": marker,
"marker_zh": marker_zh(marker),
"xy": xy,
"path": list(agent.path or []),
"path_index": int(agent.path_index),
"portrait": _resolve_npc_portrait(agent.npc_id, db),
"bark": agent.last_bark,
"target_product": agent.target_product,
"target_name": _product_name(agent.target_product, db) if agent.target_product else None,
}
)
atm = dict(session.atmosphere.snapshot or {})
dims_list = []
for k, v in atm.items():
if isinstance(v, (int, float)):
dims_list.append(
{
"key": k,
"label": atm_zh(k),
"value": round(float(v), 3),
"pct": int(max(0, min(100, round(float(v) * 100)))),
}
)
night = None
if phase == "NIGHT":
try:
raw_night = session.night_summary()
night = {
"mood": raw_night.get("mood"),
"diary": raw_night.get("diary"),
"sales": raw_night.get("sales"),
"revenue": raw_night.get("revenue"),
"enters": raw_night.get("enters"),
"top_dims": [atm_zh(x) if isinstance(x, str) else x for x in (raw_night.get("top_dims") or [])],
}
except Exception:
night = None
catalog: List[dict] = []
for pid, prod in list((db.products or {}).items())[:40]:
catalog.append(
{
"product_id": pid,
"name": prod.get("name") or pid,
"price": int(prod.get("price") or 0),
"sprite": _resolve_product_sprite(pid, db),
}
)
phases = [
{"id": p, "label": phase_zh(p), "active": p == phase}
for p in ("PREP", "MORNING", "AFTERNOON", "DUSK", "CLOSING", "NIGHT")
]
return {
"locale": "zh-CN",
"meta": {
"day_index": int(session.day.day_index),
"phase": phase,
"phase_zh": phase_zh(phase),
"phase_elapsed": round(float(session.day.phase_elapsed), 2),
"paused": bool(getattr(session.day, "paused", False)),
"pace": pace_val,
"pace_zh": pace_zh(pace_val),
"day_label": f"第 {int(session.day.day_index) + 1} 天",
},
"phases": phases,
"wallet": int(session.economy.wallet),
"atmosphere": {
"dims": dims_list,
"mood_line": session.atmosphere.mood_line(),
"light": light,
"light_zh": light_zh(light),
"bgm": getattr(session.atmosphere, "bgm", "quiet"),
},
"grid": {
"w": int(session.space.width),
"h": int(session.space.height),
"markers": markers,
"markers_zh": {k: marker_zh(k) for k in markers},
"cell_tags": {
f"{x},{y}": sorted(tags)
for (x, y), tags in session.space.cell_tags.items()
if tags - {"floor"}
},
},
"furniture": furniture,
"displays": displays,
"inventory": inventory[:24],
"catalog": catalog,
"agents": agents,
"log": list(log or session.phase_log)[-40:],
"stats": {
"enters": int(session.director.stats.get("enters", 0)),
"sales": int(session.sales_count),
"revenue": int(session.revenue),
"barks": int(session.director.stats.get("barks", 0)),
},
"art": {
"shop_bg": _exists(PHASE_BG.get(phase))
or _exists("assets/shop/interior_alley_flower_shop_01.jpg"),
"phase_icon": _exists(PHASE_ICON.get(phase)),
"open_sign": _exists("assets/ui/open_sign_01.jpg"),
},
"night": night,
"ui": {
"can_open": phase == "PREP",
"can_tick": phase not in ("PREP", "NIGHT"),
"can_sleep": phase == "NIGHT",
"can_prep_actions": phase == "PREP",
# 与 Economy 规则一致:进货 PREP/NIGHT,上架仅 PREP
"can_restock": phase in ("PREP", "NIGHT"),
"can_stock_display": phase == "PREP",
"restock_hint": (
"现在可以进货"
if phase in ("PREP", "NIGHT")
else "进货仅限「营业前」与「夜晚」(营业中请专心待客)"
),
"stock_hint": (
"现在可以上架"
if phase == "PREP"
else "上架仅限「营业前」整理货架"
),
},
}