diff --git a/.gitignore b/.gitignore index 9b527e9..8b56c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ cloudflared.out healthcheck.log backend.log frontend/office_bg.png +runtime-config.json +assets/bg-history/ +frontend/*.bak diff --git a/LICENSE b/LICENSE index 4409887..c25c8b8 100644 --- a/LICENSE +++ b/LICENSE @@ -32,26 +32,21 @@ SOFTWARE. ### Important: Art Assets are NOT for commercial use -All art assets (including but not limited to: character sprites, scene backgrounds, posters, furniture, plants, coffee machine, server room, animations, and the full asset pack) are **non-commercial only**. They are for **learning, demonstration, and idea sharing only**. +All art assets (including but not limited to character sprites, scene backgrounds, +posters, furniture, plants, coffee machine, server room, animations, and full asset packs) +are **non-commercial only**. -You may NOT use any art assets from this repository for any commercial purpose. If you want to use this project commercially, you **must create and replace all art assets with your own original work**. +They are for **learning, demonstration, and idea sharing only**. + +You may NOT use any art assets from this repository for commercial purposes. +If you want to use this project commercially, you **must replace all art assets with your own original work**. --- -## 3. Additional Disclaimer (Starmie / Pokémon) - -- The main character "Starmie" is an existing IP from Nintendo/Pokémon and is **not original to this project**. -- This project is a non-commercial fan creation only; the character was chosen for a fun homophone between "Starmie" and the author’s Chinese name "海辛" (Hǎi Xīn). -- This project’s fan content is for learning, demonstration, and idea sharing only, with no commercial use. -- Nintendo, Pokémon, and "Starmie" are trademarks or registered trademarks of Nintendo/The Pokémon Company. -- If you plan to use any content related to this project, please assess compliance risks on your own and prioritize using your own original characters/art assets. - ---- - -## 4. Guest Character Asset Attribution +## 3. Guest Character Asset Attribution Guest character animations use LimeZu’s free assets: - Animated Mini Characters 2 (Platformer) [FREE] - https://limezu.itch.io/animated-mini-characters-2-platform-free -Please keep this source attribution and follow the original author’s license terms when redistributing or demonstrating. +Please keep this attribution and follow the original author’s license terms when redistributing or demonstrating. diff --git a/README.md b/README.md index a48d91a..f3e0200 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,20 @@ Star Office UI 目前实现了: 4. **已适配手机端访问** - 移动端可直接打开与查看状态(适合外出时快速查看)。 -5. **公网访问方式灵活** +5. **支持中英日三语切换** + - 已支持 CN / EN / JP 三语切换。 + - 语言切换会实时作用于界面文案、加载提示与角色气泡。 + +6. **支持自定义美术资产** + - 支持在资产侧边栏替换角色/场景素材。 + - 支持动态素材重切帧与参数同步(frame size / frame range)以避免闪烁。 + +7. **支持接入自己的生图 API(可无限换背景)** + - 支持接入自有生图 API 进行“搬新家/找中介”式背景更新。 + - 推荐模型:`nanobanana-pro` 或 `nanobanana-2`(结构保持更稳定)。 + - 基础看板功能不依赖 API;不接入 API 也可正常使用核心状态看板与资产管理。 + +8. **公网访问方式灵活** - Skill 默认建议使用 Cloudflare Tunnel 快速公网化。 - 也可以使用你自己的公网域名 / 反向代理方案。 @@ -141,17 +154,6 @@ python3 set_state.py idle "待命中" 请在二次发布/演示时保留来源说明,并遵守原作者许可条款。 -### 其他资产说明与免责(重要) - -- **主角色(宝石海星)与谐音说明**: - - “宝石海星”是任天堂《宝可梦》(Pokémon)系列中已有的角色 IP,**不是本项目原创 IP**。 - - 本项目仅为**非商用二创/粉丝创作**:选择这个角色,是因为“宝石海星”与作者名字“海辛”在中文发音上有谐音趣味。 - - 本项目的二创内容仅供学习、演示、交流使用,**无任何商业用途**。 - - 任天堂、宝可梦、“宝石海星”均为任天堂/宝可梦公司的商标或注册商标。 - - 若你计划使用本项目相关内容,请使用你自己的原创角色/美术资产。 - -- **办公室场景与其他素材**:由本项目作者团队自行制作。 - ### 商用限制(重要) - 代码玩法可以基于 MIT 使用与二次开发。 @@ -437,3 +439,53 @@ star-office-ui/ README.md LICENSE ``` + +--- + +## 9、2026-03 增量更新(在原版基础上补充) + +> 本节仅记录“新增/变化”,其余内容保持原版结构不变。 + +### A) 房间装修生图模型推荐(新增) + +在“搬新家 / 找中介”能力中,建议优先接入你自己的 Gemini 并使用: + +1. **gemini nanobanana pro** +2. **gemini nanobanana 2** + +其他模型在“保持原房间结构 + 风格迁移一致性”上可能不如预期。 + +建议配置: + +- `GEMINI_API_KEY` +- `GEMINI_MODEL`(建议 `nanobanana-pro` 或 `nanobanana-2`) + +此外,项目已支持运行时配置入口: +- `GET /config/gemini` +- `POST /config/gemini` + +当 API key 缺失时,侧边栏会出现填写入口,用户可直接补填后重试。 + +### B) 资产编辑侧边栏验证码(新增) + +侧边栏可以修改布局、装饰、默认位置,建议务必设置强验证码: + +- 默认:`ASSET_DRAWER_PASS=1234` +- 部署时建议覆盖: + +```bash +export ASSET_DRAWER_PASS="your-strong-pass" +``` + +必要性: +- 防止拿到访问链接的人修改你的房间布局与素材。 + +### C) 角色素材版权口径更新(重要) + +当前主角状态(Writing / Idle / Syncing / Error)已切换为无版权争议的小猫素材。 + +因此: +- README 中旧的任天堂/宝可梦角色说明不再作为当前版本口径。 +- 保留原则仍不变: + - 代码逻辑 MIT + - 美术资产禁止商用(学习/演示/交流用途) diff --git a/SKILL.md b/SKILL.md index 782e63e..88d0c05 100644 --- a/SKILL.md +++ b/SKILL.md @@ -125,3 +125,78 @@ cloudflared tunnel --url http://127.0.0.1:18791 - 如果主人要对外访问,优先用 Cloudflare Tunnel(最快最方便) - 如果主人要改状态,你可以帮他调用 `set_state.py`,不用让他自己敲命令 - 注意不要暴露你主人的内网地址或私有信息 + +--- + +## 8. 2026-03 增量补充(仅新增项) + +### 8.1 生图模型建议(房间装修) + +当用户使用“搬新家 / 找中介”时,优先推荐: + +1. **gemini nanobanana pro** +2. **gemini nanobanana 2** + +其他模型在房间结构保持与风格一致性上可能不稳定。 + +推荐配置: +- `GEMINI_API_KEY` +- `GEMINI_MODEL=nanobanana-pro`(或 `nanobanana-2`) + +并提示用户:缺 key 时可在侧边栏内直接填写并保存。 + +### 8.2 侧边栏验证码安全提醒(必须) + +默认验证码为 `1234`,但生产/公网场景必须改强密码: + +```bash +export ASSET_DRAWER_PASS="your-strong-pass" +``` + +理由:防止外部访问者修改房间布局、装饰和资产配置。 + +### 8.3 版权口径更新 + +主角状态素材已切换为无版权争议的小猫,不再沿用旧角色版权说明。 + +保留统一口径: +- 代码:MIT +- 美术资产:禁止商用 + +### 8.4 安装时必须提醒(API 可选) + +在帮助主人安装时,需明确提醒: + +- 现在支持接入自己的生图 API 来改美术资产与背景(可持续更换)。 +- 但基础功能(状态看板、多 Agent、资产替换/布局、三语切换)**不依赖 API**,不开 API 也能正常使用。 + +建议对主人口径: +> 先把基础看板跑起来;需要“无限换背景/AI 生图装修”再接入自己的 API。 + +### 8.5 老用户更新指南(从旧版本升级) + +如果主人之前已经下载过旧版,按以下步骤升级: + +1. 进入项目目录并备份本地配置(如 `state.json`、自定义资产)。 +2. 拉取最新代码(`git pull` 或重新克隆到新目录)。 +3. 确认依赖:`python3 -m pip install -r backend/requirements.txt`。 +4. 保留并检查本地运行配置: + - `ASSET_DRAWER_PASS` + - `GEMINI_API_KEY` / `GEMINI_MODEL`(如需生图) +5. 如有自定义位置,确认: + - `asset-positions.json` + - `asset-defaults.json` +6. 重启后端并验收关键功能: + - `/health` + - 三语切换(CN/EN/JP) + - 资产侧栏(选择、替换、设默认) + - 生图入口(有 key 时可用) + +### 8.6 功能更新提醒清单(对主人口播) + +每次升级后,至少提醒主人以下变化: + +1. 已支持 **CN/EN/JP 三语切换**(含 loading 与气泡实时联动)。 +2. 已支持 **自定义美术资产替换**(含动态素材切帧同步,减少闪烁)。 +3. 已支持 **接入自有生图 API** 持续更换背景(推荐 `nanobanana-pro` / `nanobanana-2`)。 +4. 新增/强化了安全项:`ASSET_DRAWER_PASS` 生产环境建议改强密码。 diff --git a/asset-defaults.json b/asset-defaults.json new file mode 100644 index 0000000..a141edb --- /dev/null +++ b/asset-defaults.json @@ -0,0 +1,8 @@ +{ + "flowers-bloom-v2.webp": { + "x": 310.0, + "y": 390.0, + "scale": 0.8, + "updated_at": "2026-03-03T01:32:18.211712" + } +} \ No newline at end of file diff --git a/asset-positions.json b/asset-positions.json new file mode 100644 index 0000000..2c30b35 --- /dev/null +++ b/asset-positions.json @@ -0,0 +1,7 @@ +{ + "desk-v3.webp": { + "x": 218.0, + "y": 417.0, + "updated_at": "2026-03-02T15:58:27.228023" + } +} diff --git a/assets/room-reference.png b/assets/room-reference.png new file mode 100644 index 0000000..839aaf3 Binary files /dev/null and b/assets/room-reference.png differ diff --git a/assets/room-reference.webp b/assets/room-reference.webp new file mode 100644 index 0000000..885d34b Binary files /dev/null and b/assets/room-reference.webp differ diff --git a/backend/app.py b/backend/app.py index 3a79e84..a5540df 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,12 +1,23 @@ #!/usr/bin/env python3 """Star Office UI - Backend State Service""" -from flask import Flask, jsonify, send_from_directory, make_response, request +from flask import Flask, jsonify, send_from_directory, make_response, request, session from datetime import datetime, timedelta import json import os +import random +import math import re +import shutil +import subprocess +import tempfile import threading +from pathlib import Path + +try: + from PIL import Image +except Exception: + Image = None # Paths (project-relative, no hardcoded absolute paths) ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -15,6 +26,21 @@ FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend") STATE_FILE = os.path.join(ROOT_DIR, "state.json") AGENTS_STATE_FILE = os.path.join(ROOT_DIR, "agents-state.json") JOIN_KEYS_FILE = os.path.join(ROOT_DIR, "join-keys.json") +FRONTEND_PATH = Path(FRONTEND_DIR) +ASSET_ALLOWED_EXTS = {".png", ".webp", ".jpg", ".jpeg", ".gif", ".svg", ".avif"} +ASSET_TEMPLATE_ZIP = os.path.join(ROOT_DIR, "assets-replace-template.zip") +WORKSPACE_DIR = os.path.dirname(ROOT_DIR) +GEMINI_SCRIPT = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", "scripts", "gemini_image_generate.py") +GEMINI_PYTHON = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", ".venv", "bin", "python") +ROOM_REFERENCE_IMAGE = ( + os.path.join(ROOT_DIR, "assets", "room-reference.webp") + if os.path.exists(os.path.join(ROOT_DIR, "assets", "room-reference.webp")) + else os.path.join(ROOT_DIR, "assets", "room-reference.png") +) +BG_HISTORY_DIR = os.path.join(ROOT_DIR, "assets", "bg-history") +ASSET_POSITIONS_FILE = os.path.join(ROOT_DIR, "asset-positions.json") +ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json") +RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json") def get_yesterday_date_str(): @@ -132,20 +158,41 @@ def extract_memo_from_file(file_path): return "「昨日记录加载失败」\n\n「往者不可谏,来者犹可追。」" app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static") +app.secret_key = os.getenv("FLASK_SECRET_KEY") or os.getenv("STAR_OFFICE_SECRET") or "star-office-dev-secret-change-me" # Guard join-agent critical section to enforce per-key concurrency under parallel requests join_lock = threading.Lock() # Generate a version timestamp once at server startup for cache busting VERSION_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S") +ASSET_DRAWER_PASS_DEFAULT = os.getenv("ASSET_DRAWER_PASS", "1234") + + +def _is_asset_editor_authed() -> bool: + return bool(session.get("asset_editor_authed")) + + +def _require_asset_editor_auth(): + if _is_asset_editor_authed(): + return None + return jsonify({"ok": False, "code": "UNAUTHORIZED", "msg": "Asset editor auth required"}), 401 @app.after_request def add_no_cache_headers(response): - """Aggressively prevent caching for all responses""" - response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "0" + """Apply cache policy by path: + - HTML/API/state: no-cache (always fresh) + - /static assets: long cache (filenames are versioned with ?v=VERSION_TIMESTAMP) + """ + path = (request.path or "") + if path.startswith('/static/'): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + response.headers.pop("Pragma", None) + response.headers.pop("Expires", None) + else: + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" return response # Default state @@ -264,20 +311,6 @@ DEFAULT_AGENTS = [ "authStatus": "approved", "authExpiresAt": None, "lastPushAt": None - }, - { - "agentId": "npc1", - "name": "NPC 1", - "isMain": False, - "state": "writing", - "detail": "在整理热点日报...", - "updated_at": datetime.now().isoformat(), - "area": "writing", - "source": "demo", - "joinKey": None, - "authStatus": "approved", - "authExpiresAt": None, - "lastPushAt": None } ] @@ -299,6 +332,63 @@ def save_agents_state(agents): json.dump(agents, f, ensure_ascii=False, indent=2) +def load_asset_positions(): + if os.path.exists(ASSET_POSITIONS_FILE): + try: + with open(ASSET_POSITIONS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + except Exception: + pass + return {} + + +def save_asset_positions(data): + with open(ASSET_POSITIONS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def load_asset_defaults(): + if os.path.exists(ASSET_DEFAULTS_FILE): + try: + with open(ASSET_DEFAULTS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + except Exception: + pass + return {} + + +def save_asset_defaults(data): + with open(ASSET_DEFAULTS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def load_runtime_config(): + base = { + "gemini_api_key": os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "", + "gemini_model": os.getenv("GEMINI_MODEL") or "gemini-3.1-flash-image-preview" + } + if os.path.exists(RUNTIME_CONFIG_FILE): + try: + with open(RUNTIME_CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + base.update({k: data.get(k, base.get(k)) for k in ["gemini_api_key", "gemini_model"]}) + except Exception: + pass + return base + + +def save_runtime_config(data): + cfg = load_runtime_config() + cfg.update(data or {}) + with open(RUNTIME_CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + + def load_join_keys(): if os.path.exists(JOIN_KEYS_FILE): try: @@ -316,6 +406,141 @@ def save_join_keys(data): json.dump(data, f, ensure_ascii=False, indent=2) +def _ensure_magick_or_ffmpeg_available(): + if shutil.which("magick"): + return "magick" + if shutil.which("ffmpeg"): + return "ffmpeg" + return None + + +def _probe_animated_frame_size(upload_path: str): + """Return (w,h) from first frame if possible.""" + if Image is not None: + try: + with Image.open(upload_path) as im: + w, h = im.size + return int(w), int(h) + except Exception: + pass + # ffprobe fallback + if shutil.which("ffprobe"): + try: + cmd = [ + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0:s=x", + upload_path, + ] + out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=5).decode().strip() + if "x" in out: + w, h = out.split("x", 1) + return int(w), int(h) + except Exception: + pass + return None, None + + +def _animated_to_spritesheet( + upload_path: str, + frame_w: int, + frame_h: int, + out_ext: str = ".webp", + preserve_original: bool = True, + pixel_art: bool = True, + cols: int | None = None, + rows: int | None = None, +): + """Convert animated GIF/WEBP to spritesheet, return (out_path, columns, rows, frames, out_frame_w, out_frame_h).""" + backend = _ensure_magick_or_ffmpeg_available() + if not backend: + raise RuntimeError("未检测到 ImageMagick/ffmpeg,无法自动转换动图") + + ext = (out_ext or ".webp").lower() + if ext not in {".webp", ".png"}: + ext = ".webp" + + out_fd, out_path = tempfile.mkstemp(suffix=ext) + os.close(out_fd) + + with tempfile.TemporaryDirectory() as td: + frames = 0 + out_fw, out_fh = int(frame_w), int(frame_h) + if Image is not None: + try: + with Image.open(upload_path) as im: + n = getattr(im, "n_frames", 1) + # 默认保留用户原始帧尺寸(避免先压缩再放大导致像素糊) + if preserve_original: + out_fw, out_fh = im.size + for i in range(n): + im.seek(i) + fr = im.convert("RGBA") + if not preserve_original and (fr.size != (out_fw, out_fh)): + resample = Image.Resampling.NEAREST if pixel_art else Image.Resampling.LANCZOS + fr = fr.resize((out_fw, out_fh), resample) + fr.save(os.path.join(td, f"f_{i:04d}.png"), "PNG") + frames = n + except Exception: + frames = 0 + + if frames <= 0: + cmd1 = f"ffmpeg -y -i '{upload_path}' '{td}/f_%04d.png' >/dev/null 2>&1" + if os.system(cmd1) != 0: + raise RuntimeError("动图抽帧失败(Pillow/ffmpeg 都失败)") + files = sorted([x for x in os.listdir(td) if x.startswith("f_") and x.endswith(".png")]) + frames = len(files) + if frames <= 0: + raise RuntimeError("动图无有效帧") + + if backend == "magick": + # 像素风动图转精灵表默认无损,避免颜色/边缘被压缩糊掉 + quality_flag = "-define webp:lossless=true -define webp:method=6 -quality 100" if ext == ".webp" else "" + # 允许按 cols/rows 排布;默认单行 + if cols is None or cols <= 0: + cols_eff = frames + else: + cols_eff = max(1, int(cols)) + rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff)) + + # 先规范单帧尺寸 + prep = "" + if not preserve_original: + magick_filter = "-filter point" if pixel_art else "" + prep = f" {magick_filter} -resize {out_fw}x{out_fh}^ -gravity center -background none -extent {out_fw}x{out_fh}" + + cmd = ( + f"magick '{td}/f_*.png'{prep} " + f"-tile {cols_eff}x{rows_eff} -background none -geometry +0+0 {quality_flag} '{out_path}'" + ) + rc = os.system(cmd) + if rc != 0: + raise RuntimeError("ImageMagick 拼图失败") + return out_path, cols_eff, rows_eff, frames, out_fw, out_fh + + ffmpeg_quality = "-lossless 1 -compression_level 6 -q:v 100" if ext == ".webp" else "" + cols_eff = max(1, int(cols)) if (cols is not None and cols > 0) else frames + rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff)) + if preserve_original: + vf = f"tile={cols_eff}x{rows_eff}" + else: + scale_algo = "neighbor" if pixel_art else "lanczos" + vf = ( + f"scale={out_fw}:{out_fh}:force_original_aspect_ratio=decrease:flags={scale_algo}," + f"pad={out_fw}:{out_fh}:(ow-iw)/2:(oh-ih)/2:color=0x00000000," + f"tile={cols_eff}x{rows_eff}" + ) + cmd2 = ( + f"ffmpeg -y -pattern_type glob -i '{td}/f_*.png' " + f"-vf '{vf}' " + f"{ffmpeg_quality} '{out_path}' >/dev/null 2>&1" + ) + if os.system(cmd2) != 0: + raise RuntimeError("ffmpeg 拼图失败") + return out_path, frames, 1, frames, out_fw, out_fh + + def normalize_agent_state(s): """归一化状态,提高兼容性。 兼容输入:working/busy → writing; run/running → executing; sync → syncing; research → researching. @@ -338,6 +563,155 @@ def normalize_agent_state(s): return 'idle' +def _generate_rpg_background_to_webp(out_webp_path: str, width: int = 1280, height: int = 720, custom_prompt: str = "", speed_mode: str = "fast"): + """Generate RPG-style room background and save as webp. + + speed_mode: + - fast: use nanobanana-2 + 1024x576 intermediate + downscaled reference (faster) + - quality: use configured model (fallback nanobanana-pro) + full 1280x720 path + """ + runtime_cfg = load_runtime_config() + api_key = (runtime_cfg.get("gemini_api_key") or "").strip() + if not api_key: + raise RuntimeError("MISSING_API_KEY") + themes = [ + "8-bit dungeon guild room", + "8-bit stardew-valley inspired cozy farm tavern", + "8-bit nordic fantasy tavern", + "8-bit magitech workshop", + "8-bit elven forest inn", + "8-bit pixel cyber tavern", + "8-bit desert caravan inn", + "8-bit snow mountain lodge", + ] + theme = random.choice(themes) + + if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)): + raise RuntimeError("生图脚本环境缺失:gemini-image-generate 未安装") + + style_hint = (custom_prompt or "").strip() + if not style_hint: + style_hint = theme + + # 默认使用更稳妥的 quality 档,避免 fast 模型在部分 API 通道不可用 + mode = (speed_mode or "quality").strip().lower() + if mode not in {"fast", "quality"}: + mode = "fast" + + configured_model = (runtime_cfg.get("gemini_model") or "").strip() or "gemini-3.1-flash-image-preview" + if mode == "fast": + selected_model = "nanobanana-2" + # fast 也提高基础清晰度:从 1024x576 提升到 1152x648(牺牲少量速度) + gen_width, gen_height = 1152, 648 + ref_width, ref_height = 1152, 648 + else: + selected_model = configured_model + gen_width, gen_height = width, height + ref_width, ref_height = width, height + + if mode == "fast" and selected_model not in {"nanobanana-2", "nanobanana-pro"}: + selected_model = "nanobanana-2" + + prompt = ( + "Use a top-down pixel room composition compatible with an office game scene. " + "STRICTLY preserve the same room geometry, camera angle, wall/floor boundaries and major object placement as the provided reference image. " + "Keep region layout stable (left work area, center lounge, right error area). " + "Only change visual style/theme/material/lighting according to: " + style_hint + ". " + "Do not add text or watermark. Retro 8-bit RPG style." + ) + + tmp_dir = tempfile.mkdtemp(prefix="rpg-bg-") + cmd = [ + GEMINI_PYTHON, + GEMINI_SCRIPT, + "--prompt", prompt, + "--aspect-ratio", "16:9", + "--model", selected_model, + "--out-dir", tmp_dir, + "--cleanup", + ] + + # 强约束:每次都带固定参考图,保持房间区域布局不漂移 + ref_for_call = None + if os.path.exists(ROOM_REFERENCE_IMAGE): + ref_for_call = ROOM_REFERENCE_IMAGE + if mode == "fast" and Image is not None: + try: + ref_fast = os.path.join(tmp_dir, "room-reference-fast.webp") + with Image.open(ROOM_REFERENCE_IMAGE) as rim: + rim = rim.convert("RGBA").resize((ref_width, ref_height), Image.Resampling.LANCZOS) + rim.save(ref_fast, "WEBP", quality=85, method=4) + ref_for_call = ref_fast + except Exception: + ref_for_call = ROOM_REFERENCE_IMAGE + + if ref_for_call: + cmd.extend(["--reference-image", ref_for_call]) + + env = os.environ.copy() + # 运行时配置优先:只保留 GEMINI_API_KEY,避免脚本因双 key 报错 + env.pop("GOOGLE_API_KEY", None) + env["GEMINI_API_KEY"] = api_key + env["GEMINI_MODEL"] = selected_model + + def _run_cmd(cmd_args): + return subprocess.run(cmd_args, capture_output=True, text=True, env=env, timeout=240) + + proc = _run_cmd(cmd) + if proc.returncode != 0 and mode == "fast": + err_text = (proc.stderr or proc.stdout or "").strip().lower() + if ("not found" in err_text and "models/" in err_text) or ("model_not_available" in err_text): + # fast 模型不可用时自动回退到稳定模型 + fallback_model = configured_model or "gemini-3.1-flash-image-preview" + cmd_fallback = cmd[:] + if "--model" in cmd_fallback: + idx = cmd_fallback.index("--model") + if idx + 1 < len(cmd_fallback): + cmd_fallback[idx + 1] = fallback_model + env["GEMINI_MODEL"] = fallback_model + proc = _run_cmd(cmd_fallback) + + if proc.returncode != 0: + err_text = (proc.stderr or proc.stdout or "").strip() + low = err_text.lower() + if "your api key was reported as leaked" in low or "permission_denied" in low: + raise RuntimeError("API_KEY_REVOKED_OR_LEAKED") + if "not found" in low and "models/" in low: + raise RuntimeError("MODEL_NOT_AVAILABLE") + raise RuntimeError(f"生图失败: {err_text}") + + try: + result = json.loads(proc.stdout.strip().splitlines()[-1]) + except Exception: + raise RuntimeError("生图结果解析失败") + + files = result.get("files") or [] + if not files: + raise RuntimeError("生图未返回文件") + + gen_path = files[0] + if not os.path.exists(gen_path): + raise RuntimeError("生图文件不存在") + + if Image is None: + raise RuntimeError("Pillow 不可用,无法做尺寸标准化") + + with Image.open(gen_path) as im: + im = im.convert("RGBA") + # 质量模式优先保细节;快速模式优先速度 + if mode == "fast": + im = im.resize((gen_width, gen_height), Image.Resampling.LANCZOS) + if (gen_width, gen_height) != (width, height): + # fast 的放大改为 LANCZOS,牺牲少量速度换更高细节 + im = im.resize((width, height), Image.Resampling.LANCZOS) + im.save(out_webp_path, "WEBP", quality=96, method=6) + else: + # quality:确保输出标准尺寸,同时使用无损 webp,减少压缩损失 + if im.size != (width, height): + im = im.resize((width, height), Image.Resampling.LANCZOS) + im.save(out_webp_path, "WEBP", lossless=True, quality=100, method=6) + + def state_to_area(state): area_map = { "idle": "breakroom", @@ -806,6 +1180,422 @@ def set_state_endpoint(): return jsonify({"status": "error", "msg": str(e)}), 500 +@app.route("/assets/template.zip", methods=["GET"]) +def assets_template_download(): + if not os.path.exists(ASSET_TEMPLATE_ZIP): + return jsonify({"ok": False, "msg": "模板包不存在,请先生成"}), 404 + return send_from_directory(ROOT_DIR, "assets-replace-template.zip", as_attachment=True) + + +@app.route("/assets/list", methods=["GET"]) +def assets_list(): + items = [] + for p in FRONTEND_PATH.rglob("*"): + if not p.is_file(): + continue + rel = p.relative_to(FRONTEND_PATH).as_posix() + if rel.startswith("fonts/"): + continue + if p.suffix.lower() not in ASSET_ALLOWED_EXTS: + continue + st = p.stat() + width = None + height = None + if Image is not None: + try: + with Image.open(p) as im: + width, height = im.size + except Exception: + pass + items.append({ + "path": rel, + "size": st.st_size, + "ext": p.suffix.lower(), + "width": width, + "height": height, + "mtime": datetime.fromtimestamp(st.st_mtime).isoformat(), + }) + items.sort(key=lambda x: x["path"]) + return jsonify({"ok": True, "count": len(items), "items": items}) + + +@app.route("/assets/generate-rpg-background", methods=["POST"]) +def assets_generate_rpg_background(): + """Generate a new RPG-themed background and replace office_bg_small.webp.""" + guard = _require_asset_editor_auth() + if guard: + return guard + try: + req = request.get_json(silent=True) or {} + custom_prompt = (req.get("prompt") or "").strip() if isinstance(req, dict) else "" + speed_mode = (req.get("speed_mode") or "quality").strip().lower() if isinstance(req, dict) else "quality" + if speed_mode not in {"fast", "quality"}: + speed_mode = "fast" + + target = FRONTEND_PATH / "office_bg_small.webp" + if not target.exists(): + return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404 + + # 覆盖前保留最近一次备份 + bak = target.with_suffix(target.suffix + ".bak") + shutil.copy2(target, bak) + + _generate_rpg_background_to_webp( + str(target), + width=1280, + height=720, + custom_prompt=custom_prompt, + speed_mode=speed_mode, + ) + + # 每次生成都归档一份历史底图(可回溯风格演化) + os.makedirs(BG_HISTORY_DIR, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + hist_file = os.path.join(BG_HISTORY_DIR, f"office_bg_small-{ts}.webp") + shutil.copy2(target, hist_file) + + st = target.stat() + return jsonify({ + "ok": True, + "path": "office_bg_small.webp", + "size": st.st_size, + "history": os.path.relpath(hist_file, ROOT_DIR), + "speed_mode": speed_mode, + "msg": "已生成并替换 RPG 房间底图(已自动归档)", + }) + except Exception as e: + msg = str(e) + if msg == "MISSING_API_KEY": + return jsonify({"ok": False, "code": "MISSING_API_KEY", "msg": "Missing GEMINI_API_KEY or GOOGLE_API_KEY"}), 400 + if msg == "API_KEY_REVOKED_OR_LEAKED": + return jsonify({"ok": False, "code": "API_KEY_REVOKED_OR_LEAKED", "msg": "API key is revoked or flagged as leaked. Please rotate to a new key."}), 400 + if msg == "MODEL_NOT_AVAILABLE": + return jsonify({"ok": False, "code": "MODEL_NOT_AVAILABLE", "msg": "Configured model is not available for this API key/channel."}), 400 + return jsonify({"ok": False, "msg": msg}), 500 + + +@app.route("/assets/restore-reference-background", methods=["POST"]) +def assets_restore_reference_background(): + """Restore office_bg_small.webp from fixed reference image.""" + guard = _require_asset_editor_auth() + if guard: + return guard + try: + target = FRONTEND_PATH / "office_bg_small.webp" + if not target.exists(): + return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404 + if not os.path.exists(ROOM_REFERENCE_IMAGE): + return jsonify({"ok": False, "msg": "参考图不存在"}), 404 + + # 备份当前底图 + bak = target.with_suffix(target.suffix + ".bak") + shutil.copy2(target, bak) + + # 快速路径:若参考图已是 1280x720 的 webp,直接拷贝(秒级) + ref_ext = os.path.splitext(ROOM_REFERENCE_IMAGE)[1].lower() + fast_copied = False + if ref_ext == '.webp': + try: + with Image.open(ROOM_REFERENCE_IMAGE) as rim: + if rim.size == (1280, 720): + shutil.copy2(ROOM_REFERENCE_IMAGE, target) + fast_copied = True + except Exception: + fast_copied = False + + # 慢路径:仅在必要时重编码 + if not fast_copied: + if Image is None: + return jsonify({"ok": False, "msg": "Pillow 不可用"}), 500 + with Image.open(ROOM_REFERENCE_IMAGE) as im: + im = im.convert("RGBA").resize((1280, 720), Image.Resampling.LANCZOS) + im.save(target, "WEBP", quality=92, method=6) + + st = target.stat() + return jsonify({ + "ok": True, + "path": "office_bg_small.webp", + "size": st.st_size, + "msg": "已恢复初始底图", + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/auth", methods=["POST"]) +def assets_auth(): + try: + data = request.get_json(silent=True) or {} + pwd = (data.get("password") or "").strip() + if pwd and pwd == ASSET_DRAWER_PASS_DEFAULT: + session["asset_editor_authed"] = True + return jsonify({"ok": True, "msg": "认证成功"}) + return jsonify({"ok": False, "msg": "验证码错误"}), 401 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/auth/status", methods=["GET"]) +def assets_auth_status(): + return jsonify({"ok": True, "authed": _is_asset_editor_authed()}) + + +@app.route("/assets/positions", methods=["GET"]) +def assets_positions_get(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + return jsonify({"ok": True, "items": load_asset_positions()}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/positions", methods=["POST"]) +def assets_positions_set(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + key = (data.get("key") or "").strip() + x = data.get("x") + y = data.get("y") + scale = data.get("scale") + if not key: + return jsonify({"ok": False, "msg": "缺少 key"}), 400 + if x is None or y is None: + return jsonify({"ok": False, "msg": "缺少 x/y"}), 400 + x = float(x) + y = float(y) + if scale is None: + scale = 1.0 + scale = float(scale) + + all_pos = load_asset_positions() + all_pos[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()} + save_asset_positions(all_pos) + return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/defaults", methods=["GET"]) +def assets_defaults_get(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + return jsonify({"ok": True, "items": load_asset_defaults()}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/defaults", methods=["POST"]) +def assets_defaults_set(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + key = (data.get("key") or "").strip() + x = data.get("x") + y = data.get("y") + scale = data.get("scale") + if not key: + return jsonify({"ok": False, "msg": "缺少 key"}), 400 + if x is None or y is None: + return jsonify({"ok": False, "msg": "缺少 x/y"}), 400 + x = float(x) + y = float(y) + if scale is None: + scale = 1.0 + scale = float(scale) + + all_defaults = load_asset_defaults() + all_defaults[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()} + save_asset_defaults(all_defaults) + return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/config/gemini", methods=["GET"]) +def gemini_config_get(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + cfg = load_runtime_config() + key = (cfg.get("gemini_api_key") or "").strip() + masked = ("*" * max(0, len(key) - 4)) + key[-4:] if key else "" + return jsonify({ + "ok": True, + "has_api_key": bool(key), + "api_key_masked": masked, + "gemini_model": cfg.get("gemini_model") or "gemini-3.1-flash-image-preview", + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/config/gemini", methods=["POST"]) +def gemini_config_set(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + api_key = (data.get("api_key") or "").strip() + model = (data.get("model") or "").strip() or "gemini-3.1-flash-image-preview" + payload = {"gemini_model": model} + if api_key: + payload["gemini_api_key"] = api_key + save_runtime_config(payload) + return jsonify({"ok": True, "msg": "Gemini 配置已保存"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/upload", methods=["POST"]) +def assets_upload(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + rel_path = (request.form.get("path") or "").strip().lstrip("/") + backup = (request.form.get("backup") or "1").strip() != "0" + f = request.files.get("file") + + if not rel_path or f is None: + return jsonify({"ok": False, "msg": "缺少 path 或 file"}), 400 + + target = (FRONTEND_PATH / rel_path).resolve() + try: + target.relative_to(FRONTEND_PATH.resolve()) + except Exception: + return jsonify({"ok": False, "msg": "非法 path"}), 400 + + if target.suffix.lower() not in ASSET_ALLOWED_EXTS: + return jsonify({"ok": False, "msg": "仅允许上传图片/美术资源类型"}), 400 + + if not target.exists(): + return jsonify({"ok": False, "msg": "目标文件不存在,请先从 /assets/list 选择 path"}), 404 + + target.parent.mkdir(parents=True, exist_ok=True) + if backup: + bak = target.with_suffix(target.suffix + ".bak") + shutil.copy2(target, bak) + + auto_sheet = (request.form.get("auto_spritesheet") or "0").strip() == "1" + ext_name = (f.filename or "").lower() + + if auto_sheet and target.suffix.lower() in {".webp", ".png"}: + with tempfile.NamedTemporaryFile(suffix=os.path.splitext(ext_name)[1] or ".gif", delete=False) as tf: + src_path = tf.name + f.save(src_path) + try: + in_w, in_h = _probe_animated_frame_size(src_path) + frame_w = int(request.form.get("frame_w") or (in_w or 64)) + frame_h = int(request.form.get("frame_h") or (in_h or 64)) + + # 如果是静态图上传到精灵表目标,按网格切片而不是整图覆盖 + if not (ext_name.endswith(".gif") or ext_name.endswith(".webp")) and Image is not None: + try: + with Image.open(src_path) as sim: + sim = sim.convert("RGBA") + sw, sh = sim.size + if frame_w <= 0 or frame_h <= 0: + frame_w, frame_h = sw, sh + cols = max(1, sw // frame_w) + rows = max(1, sh // frame_h) + sheet_w = cols * frame_w + sheet_h = rows * frame_h + if sheet_w <= 0 or sheet_h <= 0: + raise RuntimeError("静态图尺寸与帧规格不匹配") + + cropped = sim.crop((0, 0, sheet_w, sheet_h)) + # 目标是 webp 仍按无损保存,避免像素损失 + if target.suffix.lower() == ".webp": + cropped.save(str(target), "WEBP", lossless=True, quality=100, method=6) + else: + cropped.save(str(target), "PNG") + + st = target.stat() + return jsonify({ + "ok": True, + "path": rel_path, + "size": st.st_size, + "backup": backup, + "converted": { + "from": ext_name.split(".")[-1] if "." in ext_name else "image", + "to": "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet", + "frame_w": frame_w, + "frame_h": frame_h, + "columns": cols, + "rows": rows, + "frames": cols * rows, + "preserve_original": False, + "pixel_art": True, + } + }) + finally: + pass + + # 默认:优先保留输入帧尺寸;若前端传了强制值则按前端。 + preserve_original_val = request.form.get("preserve_original") + if preserve_original_val is None: + preserve_original = True + else: + preserve_original = preserve_original_val.strip() == "1" + + pixel_art = (request.form.get("pixel_art") or "1").strip() == "1" + req_cols = int(request.form.get("cols") or 0) + req_rows = int(request.form.get("rows") or 0) + sheet_path, cols, rows, frames, out_fw, out_fh = _animated_to_spritesheet( + src_path, + frame_w, + frame_h, + out_ext=target.suffix.lower(), + preserve_original=preserve_original, + pixel_art=pixel_art, + cols=(req_cols if req_cols > 0 else None), + rows=(req_rows if req_rows > 0 else None), + ) + shutil.move(sheet_path, str(target)) + st = target.stat() + from_type = "gif" if ext_name.endswith(".gif") else "webp" + to_type = "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet" + return jsonify({ + "ok": True, + "path": rel_path, + "size": st.st_size, + "backup": backup, + "converted": { + "from": from_type, + "to": to_type, + "frame_w": out_fw, + "frame_h": out_fh, + "columns": cols, + "rows": rows, + "frames": frames, + "preserve_original": preserve_original, + "pixel_art": pixel_art, + } + }) + finally: + try: + os.remove(src_path) + except Exception: + pass + + f.save(str(target)) + st = target.stat() + return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "backup": backup}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + if __name__ == "__main__": print("=" * 50) print("Star Office UI - Backend State Service") diff --git a/dist/Star-Office-UI-release-20260302/RELEASE_NOTES.md b/dist/Star-Office-UI-release-20260302/RELEASE_NOTES.md new file mode 100644 index 0000000..25bc3e8 --- /dev/null +++ b/dist/Star-Office-UI-release-20260302/RELEASE_NOTES.md @@ -0,0 +1,37 @@ +# Star-Office-UI Release Notes (2026-03-02) + +## Summary +This package is a cleaned release snapshot for handoff/update. +It excludes runtime files, logs, and local backup artifacts. + +## Included +- backend/ +- frontend/ +- docs/ +- assets/room-reference.png +- core scripts and docs (README.md, SKILL.md, LICENSE, set_state.py, etc.) +- asset-defaults.json / asset-positions.json + +## Excluded on purpose +- .git/ +- .venv/ +- __pycache__/ +- *.log / *.out / *.pid +- *.bak (frontend image backups) +- assets/bg-history/ (historical generated backgrounds) +- runtime state files: state.json / agents-state.json / join-keys.json + +## Artifact +- File: `dist/Star-Office-UI-release-20260302.tgz` +- SHA256: `bf52147b7664adc3c457eadd3748f969b1ad5ee7e8d3059ce9c8da4c6030f6ae` + +## Pre-publish checklist +1. Confirm whether `asset-defaults.json` and `asset-positions.json` should be shipped as current defaults. +2. Confirm whether `assets/bg-history/` should remain local-only (currently excluded). +3. On target machine, create fresh `state.json` and `join-keys.json` if needed. +4. Start backend and validate: + - `/health` + - `/status` + - language switches (EN/JP/CN) + - loading overlay + sidebar layering + - asset drawer selection / upload panel behavior diff --git a/docs/CHANGELOG_2026-03.md b/docs/CHANGELOG_2026-03.md new file mode 100644 index 0000000..2aa2ddb --- /dev/null +++ b/docs/CHANGELOG_2026-03.md @@ -0,0 +1,33 @@ +# Changelog — 2026-03 Refresh + +## Highlights + +- Added robust asset editing workflow in drawer (select/deselect, highlight sync, default/override split) +- Added EN/JP/CN language buttons with real-time UI + loading + bubble text switching +- Added room loading overlay with emoji rotation and localized copy +- Fixed layering/layout issues (drawer overlap, detail overflow, canvas border fit) +- Completed multi-round state sprite replacement pipeline (Writing/Idle/Syncing/Error) with auto frame sync +- Updated syncing behavior: non-sync shows frame 0, syncing starts from frame 1 +- Disabled error movement path (error anim stays in place) +- Removed GIF legacy assets and stale references +- Restored `assets/room-reference.png` for reference background restore +- Added configurable asset drawer password via env (`ASSET_DRAWER_PASS`, default `1234`) +- Improved startup performance: + - static assets use long cache headers + - local phaser vendor restored + +## Security / Config + +- Asset drawer default pass changed to `1234` +- Recommend deployment override: + - `ASSET_DRAWER_PASS=` +- Rationale: prevent unauthorized layout/asset modifications from shared links + +## AI model recommendation for room generation + +For best style-transfer quality (while preserving room structure), recommend: + +1. gemini nanobanana pro +2. gemini nanobanana 2 + +Other models may produce unstable structure consistency. diff --git a/docs/PR_DRAFT_2026-03-refresh.md b/docs/PR_DRAFT_2026-03-refresh.md new file mode 100644 index 0000000..61304df --- /dev/null +++ b/docs/PR_DRAFT_2026-03-refresh.md @@ -0,0 +1,87 @@ +# PR Draft — Star Office UI March Refresh + +## Title +feat: asset editor + i18n + loading UX + sprite pipeline + security/perf refinements + +## Summary +This PR delivers a full refresh of Star Office UI across UX, asset pipeline, localization, stability, and deployment security. + +### What changed + +#### 1) Asset editor / room decoration +- Improved drawer selection UX (select/deselect + dual highlight sync) +- Upload panel now appears only when an asset is selected +- Added defaults/overrides split: + - `GET/POST /assets/defaults` + - `GET/POST /assets/positions` +- Added “Set Default” flow for persistent base placement + +#### 2) Localization (CN/EN/JP) +- Replaced language toggles with EN / JP / CN buttons +- Active language button highlighted in green +- Real-time language switching for: + - UI labels + - loading texts + - role/cat/guest bubbles + - initial boot loading sentence + +#### 3) Loading overlay and UX polish +- Added room-bound loading overlay with emoji rotation +- Updated copy to voyage-themed localized sets +- Trigger timing fixed: overlay shows immediately on click +- Overlay and detail placement bound to canvas rect for consistency + +#### 4) Layout and layering fixes +- Fixed canvas border fit and theme color unification (#64477d) +- Ensured status/detail stays inside canvas and single-line clipped +- Drawer open now shifts main stage to avoid overlap and large gaps +- Drawer kept above room loading overlay + +#### 5) Sprite replacement pipeline hardening +- Reworked replacement flow to detect frame size/count from incoming animated webp +- Synced loader + animation frame ranges to avoid flicker +- Applied across Writing / Idle / Syncing / Error replacements +- Syncing behavior adjusted: + - non-sync state shows frame 0 + - syncing animation starts from frame 1 +- Error animation movement path removed (fixed in place) + +#### 6) Cleanup / reliability / perf +- Removed legacy GIF assets +- Removed stale asset references (zero missing static refs) +- Restored `assets/room-reference.png` for restore-reference endpoint +- Added configurable drawer pass via env: + - `ASSET_DRAWER_PASS` (default `1234`) +- Performance improvements: + - static assets served with long cache headers + - local phaser vendor restored to reduce cold-load latency + +## Documentation updates included +- README rewritten for latest behavior/config +- SKILL updated with deployment + safety + replacement SOP +- LICENSE updated to remove old third-party character disclaimer and keep: + - code MIT + - art assets non-commercial +- Added `docs/CHANGELOG_2026-03.md` + +## Deployment notes +- Recommended model for room generation: + 1. gemini nanobanana pro + 2. gemini nanobanana 2 +- Security recommendation: + - always override `ASSET_DRAWER_PASS` in production/public deployments + +## Test checklist +- [ ] Open page cold + warm load +- [ ] Switch CN/EN/JP at any state +- [ ] Trigger Move Home/Broker and observe local status text + loading overlay +- [ ] Replace one animated asset and verify frame sync/no flicker +- [ ] Verify Error is fixed in place +- [ ] Verify `/assets/restore-reference-background` works with `assets/room-reference.png` +- [ ] Verify no missing `/static/*` refs in runtime logs + +## How to create PR +1. `git checkout -b feat/march-refresh` +2. `git push -u origin feat/march-refresh` +3. Open PR to `ringhyacinth/Star-Office-UI:main` +4. Paste this document as PR description diff --git a/docs/PR_FILELIST_2026-03-refresh.md b/docs/PR_FILELIST_2026-03-refresh.md new file mode 100644 index 0000000..f7d5a47 --- /dev/null +++ b/docs/PR_FILELIST_2026-03-refresh.md @@ -0,0 +1,25 @@ +# PR File List — 2026-03 Refresh + +## Core code changes +- `frontend/index.html` +- `backend/app.py` +- `frontend/vendor/phaser-3.80.1.min.js` +- `assets/room-reference.webp` + +## Configuration / templates +- `.gitignore` +- `runtime-config.sample.json` + +## Documentation +- `README.md` +- `SKILL.md` +- `LICENSE` +- `docs/CHANGELOG_2026-03.md` +- `docs/PR_DRAFT_2026-03-refresh.md` +- `docs/PR_FILELIST_2026-03-refresh.md` + +## Notes (excluded from PR) +- `state.json`, `agents-state.json`, `runtime-config.json` (local runtime) +- `assets/bg-history/` (local generated history) +- `frontend/*.bak` (local backups) +- temporary dist packages diff --git a/frontend/btn-back-home-sprite.png b/frontend/btn-back-home-sprite.png new file mode 100644 index 0000000..8140664 Binary files /dev/null and b/frontend/btn-back-home-sprite.png differ diff --git a/frontend/btn-broker-sprite.png b/frontend/btn-broker-sprite.png new file mode 100644 index 0000000..90a9c50 Binary files /dev/null and b/frontend/btn-broker-sprite.png differ diff --git a/frontend/btn-diy-sprite.png b/frontend/btn-diy-sprite.png new file mode 100644 index 0000000..096604d Binary files /dev/null and b/frontend/btn-diy-sprite.png differ diff --git a/frontend/btn-move-house-sprite.png b/frontend/btn-move-house-sprite.png new file mode 100644 index 0000000..065c0ca Binary files /dev/null and b/frontend/btn-move-house-sprite.png differ diff --git a/frontend/btn-open-drawer-sprite.png b/frontend/btn-open-drawer-sprite.png new file mode 100644 index 0000000..a2dc26e Binary files /dev/null and b/frontend/btn-open-drawer-sprite.png differ diff --git a/frontend/btn-state-sprite.png b/frontend/btn-state-sprite.png new file mode 100644 index 0000000..29b4a19 Binary files /dev/null and b/frontend/btn-state-sprite.png differ diff --git a/frontend/cats-spritesheet.png b/frontend/cats-spritesheet.png deleted file mode 100644 index b5d6e11..0000000 Binary files a/frontend/cats-spritesheet.png and /dev/null differ diff --git a/frontend/coffee-machine-shadow-v1.png b/frontend/coffee-machine-shadow-v1.png new file mode 100644 index 0000000..e53d8c4 Binary files /dev/null and b/frontend/coffee-machine-shadow-v1.png differ diff --git a/frontend/coffee-machine-spritesheet.png b/frontend/coffee-machine-spritesheet.png deleted file mode 100644 index cc38670..0000000 Binary files a/frontend/coffee-machine-spritesheet.png and /dev/null differ diff --git a/frontend/coffee-machine-spritesheet.webp b/frontend/coffee-machine-spritesheet.webp deleted file mode 100644 index 21832c6..0000000 Binary files a/frontend/coffee-machine-spritesheet.webp and /dev/null differ diff --git a/frontend/coffee-machine-v3-grid.webp b/frontend/coffee-machine-v3-grid.webp new file mode 100644 index 0000000..2ed8eba Binary files /dev/null and b/frontend/coffee-machine-v3-grid.webp differ diff --git a/frontend/coffee-machine.gif b/frontend/coffee-machine.gif deleted file mode 100644 index 3d24971..0000000 Binary files a/frontend/coffee-machine.gif and /dev/null differ diff --git a/frontend/demo_mercury.png b/frontend/demo_mercury.png deleted file mode 100644 index 9786fb7..0000000 Binary files a/frontend/demo_mercury.png and /dev/null differ diff --git a/frontend/demo_mercury.webp b/frontend/demo_mercury.webp deleted file mode 100644 index 6ee2a0a..0000000 Binary files a/frontend/demo_mercury.webp and /dev/null differ diff --git a/frontend/demo_nika.png b/frontend/demo_nika.png deleted file mode 100644 index f78b996..0000000 Binary files a/frontend/demo_nika.png and /dev/null differ diff --git a/frontend/demo_nika.webp b/frontend/demo_nika.webp deleted file mode 100644 index ad4f283..0000000 Binary files a/frontend/demo_nika.webp and /dev/null differ diff --git a/frontend/desk-v2.jpg b/frontend/desk-v2.jpg deleted file mode 100644 index feaf759..0000000 Binary files a/frontend/desk-v2.jpg and /dev/null differ diff --git a/frontend/desk-v2.png b/frontend/desk-v2.png deleted file mode 100644 index f208935..0000000 Binary files a/frontend/desk-v2.png and /dev/null differ diff --git a/frontend/desk-v2.webp b/frontend/desk-v2.webp deleted file mode 100644 index 4fa3429..0000000 Binary files a/frontend/desk-v2.webp and /dev/null differ diff --git a/frontend/desk-v3.webp b/frontend/desk-v3.webp new file mode 100644 index 0000000..1cd58bc Binary files /dev/null and b/frontend/desk-v3.webp differ diff --git a/frontend/desk.png b/frontend/desk.png deleted file mode 100644 index a393bec..0000000 Binary files a/frontend/desk.png and /dev/null differ diff --git a/frontend/desk.webp b/frontend/desk.webp deleted file mode 100644 index 1bdb302..0000000 Binary files a/frontend/desk.webp and /dev/null differ diff --git a/frontend/error-bug-spritesheet-grid.png b/frontend/error-bug-spritesheet-grid.png deleted file mode 100644 index daeda0c..0000000 Binary files a/frontend/error-bug-spritesheet-grid.png and /dev/null differ diff --git a/frontend/error-bug-spritesheet-grid.webp b/frontend/error-bug-spritesheet-grid.webp index 13dc219..8a17864 100644 Binary files a/frontend/error-bug-spritesheet-grid.webp and b/frontend/error-bug-spritesheet-grid.webp differ diff --git a/frontend/error-bug-spritesheet.png b/frontend/error-bug-spritesheet.png deleted file mode 100644 index ef7721b..0000000 Binary files a/frontend/error-bug-spritesheet.png and /dev/null differ diff --git a/frontend/error-bug.webp b/frontend/error-bug.webp deleted file mode 100644 index 3104f3e..0000000 Binary files a/frontend/error-bug.webp and /dev/null differ diff --git a/frontend/flowers-bloom-v2.webp b/frontend/flowers-bloom-v2.webp new file mode 100644 index 0000000..bd2a609 Binary files /dev/null and b/frontend/flowers-bloom-v2.webp differ diff --git a/frontend/flowers-spritesheet.png b/frontend/flowers-spritesheet.png deleted file mode 100644 index 1cb0246..0000000 Binary files a/frontend/flowers-spritesheet.png and /dev/null differ diff --git a/frontend/flowers-spritesheet.webp b/frontend/flowers-spritesheet.webp deleted file mode 100644 index 337d27b..0000000 Binary files a/frontend/flowers-spritesheet.webp and /dev/null differ diff --git a/frontend/game.js b/frontend/game.js index 7d73179..512de0c 100644 --- a/frontend/game.js +++ b/frontend/game.js @@ -462,6 +462,7 @@ function create() { 'flowers', randomFlowerFrame ).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y); + flower.setScale(LAYOUT.furniture.flower.scale || 1); flower.setDepth(LAYOUT.furniture.flower.depth); flower.setInteractive({ useHandCursor: true }); window.flowerSprite = flower; diff --git a/frontend/guest_anim_1.png b/frontend/guest_anim_1.png deleted file mode 100644 index 389052c..0000000 Binary files a/frontend/guest_anim_1.png and /dev/null differ diff --git a/frontend/guest_anim_2.png b/frontend/guest_anim_2.png deleted file mode 100644 index b2c0231..0000000 Binary files a/frontend/guest_anim_2.png and /dev/null differ diff --git a/frontend/guest_anim_3.png b/frontend/guest_anim_3.png deleted file mode 100644 index 8160616..0000000 Binary files a/frontend/guest_anim_3.png and /dev/null differ diff --git a/frontend/guest_anim_4.png b/frontend/guest_anim_4.png deleted file mode 100644 index 0e6f5b5..0000000 Binary files a/frontend/guest_anim_4.png and /dev/null differ diff --git a/frontend/guest_anim_5.png b/frontend/guest_anim_5.png deleted file mode 100644 index bd5c87d..0000000 Binary files a/frontend/guest_anim_5.png and /dev/null differ diff --git a/frontend/guest_anim_6.png b/frontend/guest_anim_6.png deleted file mode 100644 index bd5c87d..0000000 Binary files a/frontend/guest_anim_6.png and /dev/null differ diff --git a/frontend/guestagent1.png b/frontend/guestagent1.png deleted file mode 100644 index 9786fb7..0000000 Binary files a/frontend/guestagent1.png and /dev/null differ diff --git a/frontend/guestagent1.webp b/frontend/guestagent1.webp deleted file mode 100644 index 6ee2a0a..0000000 Binary files a/frontend/guestagent1.webp and /dev/null differ diff --git a/frontend/guestagent2.png b/frontend/guestagent2.png deleted file mode 100644 index f78b996..0000000 Binary files a/frontend/guestagent2.png and /dev/null differ diff --git a/frontend/guestagent2.webp b/frontend/guestagent2.webp deleted file mode 100644 index ad4f283..0000000 Binary files a/frontend/guestagent2.webp and /dev/null differ diff --git a/frontend/index.html b/frontend/index.html index 41d06cd..79bc08f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,26 +21,38 @@ min-height: 100vh; font-family: 'ArkPixel', 'Courier New', monospace; padding: 20px 0; - gap: 20px; + gap: 10px; overflow-x: hidden; } /* 底部面板容器 */ + #main-stage { + position: relative; + width: 1280px; + transition: margin-left .25s ease; + will-change: margin-left; + } + body.drawer-open #main-stage { + /* 右侧紧贴侧边栏左缘,避免出现大空隙 */ + margin-left: max(0px, calc(100vw - min(420px, 92vw) - 1280px)); + } #bottom-panels { display: flex; gap: 20px; width: 1280px; - max-width: 95vw; + max-width: none; + justify-content: flex-start; + margin-top: 20px; } #game-container { - border: 4px solid #e94560; + position: relative; + border: 0; image-rendering: pixelated; width: 1280px; height: 720px; - max-width: 95vw; - /* 手机上用“可见高度”限制,避免被底部面板挤压导致裁切 */ - max-height: 70vh; - /* 关键:强制容器保持 16:9,避免 canvas 在不同布局下被拉伸变形 */ - aspect-ratio: 16 / 9; + max-width: none; + max-height: none; + aspect-ratio: auto; + overflow: hidden; } #game-container canvas { width: 100% !important; @@ -48,6 +60,10 @@ image-rendering: pixelated; /* 再兜底一次:即使外层高度变化,也不要拉伸变形 */ object-fit: contain; + /* 边框改为直接贴合画布内部,避免“框比地图大” */ + box-shadow: inset 0 0 0 4px #64477d; + position: relative; + z-index: 10; } #loading-overlay { position: fixed; @@ -81,31 +97,83 @@ transition: width 0.3s ease; } #status-text { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); + position: absolute; + bottom: 12px; + left: 12px; + transform: none; color: #eee; font-size: 14px; background: rgba(0,0,0,0.7); - padding: 10px 20px; + padding: 8px 12px; border-radius: 4px; - max-width: 90%; - text-align: center; + max-width: calc(100% - 24px); + text-align: left; font-family: 'ArkPixel', 'Courier New', monospace; + z-index: 30; + pointer-events: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; } /* 状态控制栏 */ #control-bar { - background: #2c2f3a; + position: relative; + background: #141722; padding: 10px 10px 12px; - border-radius: 8px; - border: 2px solid #e94560; - box-shadow: 0 4px 20px rgba(0,0,0,0.5); - width: 380px; + border-radius: 0; + border: 4px solid #0e1119; + box-shadow: none; + width: 390px; height: 300px; display: flex; flex-direction: column; gap: 10px; + overflow: hidden; + } + #control-bar::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc); + background-repeat: no-repeat; + background-size: + calc(50% - 14px) 2px, calc(50% - 14px) 2px, + 2px calc(50% - 14px), 2px calc(50% - 14px), + calc(50% - 14px) 2px, calc(50% - 14px) 2px, + 2px calc(50% - 14px), 2px calc(50% - 14px); + background-position: + 9px 8px, calc(50% + 5px) 8px, + 8px 9px, calc(100% - 10px) 9px, + 9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px), + 8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px); + } + #control-bar::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc); + background-repeat: no-repeat; + background-size: + 9px 4px, 4px 9px, + 9px 4px, 4px 9px, + 9px 4px, 4px 9px, + 9px 4px, 4px 9px; + background-position: + left top, left top, + right top, right top, + left bottom, left bottom, + right bottom, right bottom; } #control-bar-title { color: #ffd700; @@ -114,14 +182,307 @@ text-align: center; letter-spacing: 1px; padding: 6px 0 10px; - border-bottom: 2px solid #4a4f5f; + border-bottom: 0; } #control-buttons { display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 10px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; align-content: start; padding-top: 4px; + padding-left: 10px; + padding-right: 10px; + box-sizing: border-box; + } + #btn-open-drawer { + grid-column: 1 / -1; + background: #78a340; + border-color: #8fbe4a; + color: #f3ffe6; + font-weight: 700; + } + + #asset-drawer { + position: fixed; + top: 0; + right: 0; + width: 320px; + max-width: 92vw; + height: 100vh; + background: #111827; + border-left: 2px solid #22c55e; + box-shadow: -8px 0 24px rgba(0,0,0,0.45); + transform: translateX(100%); + transition: transform 0.25s ease; + z-index: 1000010; + display: flex; + flex-direction: column; + } + #asset-drawer.open { transform: translateX(0); } + #asset-drawer-header { + color: #ecfdf5; + font-size: 15px; + padding: 12px; + border-bottom: 1px solid #374151; + display: flex; + justify-content: space-between; + align-items: center; + background: #0b1220; + } + #asset-drawer-body { + padding: 10px; + padding-bottom: 150px; + overflow: auto; + color: #e5e7eb; + font-size: 12px; + position: relative; + } + .asset-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; } + .asset-toolbar input { flex:1; min-width: 150px; padding:6px 8px; border-radius:6px; border:1px solid #374151; background:#1f2937; color:#fff; } + .asset-toolbar button, #asset-drawer-header button { cursor:pointer; border:1px solid #4b5563; background:#1f2937; color:#fff; border-radius:6px; padding:6px 8px; font-family:'ArkPixel', monospace; } + .asset-toolbar button:hover, #asset-drawer-header button:hover { border-color:#22c55e; } + #asset-list { + display:flex; + flex-direction:column; + gap:6px; + flex: 1 1 auto; + min-height: 120px; + max-height: 40vh; + overflow-y: auto; + padding-right: 2px; + scrollbar-color: #1f2937 #0b1220; + scrollbar-width: thin; + } + #asset-list::-webkit-scrollbar { width: 8px; } + #asset-list::-webkit-scrollbar-track { background: #0b1220; } + #asset-list::-webkit-scrollbar-thumb { background: #1f2937; border-radius: 0; border: 1px solid #111827; } + #asset-upload-panel { + position: sticky; + bottom: 8px; + margin-top: 10px; + background: #0b1220; + border: 1px solid #334155; + border-radius: 8px; + padding: 8px; + z-index: 50; + display: none; + box-shadow: 0 -4px 12px rgba(0,0,0,.35); + } + #asset-upload-panel.active { + display: block; + } + .asset-item { + border: 1px solid #374151; + background: #0f172a; + border-radius: 8px; + padding: 8px; + display: grid; + grid-template-columns: 56px 1fr 44px; + gap: 8px; + align-items: center; + cursor: pointer; + } + .asset-item.active { border-color: #22c55e; box-shadow: 0 0 0 1px #22c55e inset; } + .asset-thumb { width:56px; height:56px; object-fit: contain; background:#0b1220; border:1px solid #374151; border-radius:6px; } + .asset-meta { line-height: 1.45; } + .asset-path { color:#d1fae5; word-break: break-all; } + .asset-sub { color:#9ca3af; font-size:11px; } + #asset-upload-result { white-space: normal; line-height: 1.5; } + #asset-upload-result .hint-p { margin: 0 0 6px 0; } + #asset-upload-result .hint-p:last-child { margin-bottom: 0; } + .asset-plus-box { width:100%; height:92px; border:2px dashed #4b5563; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#9ca3af; font-size:34px; cursor:pointer; user-select:none; } + .asset-plus-box:hover { border-color:#22c55e; color:#22c55e; } + .asset-preview-box { border:1px solid #374151; border-radius:8px; padding:6px; background:#0b1220; margin-bottom:8px; } + .asset-preview-title { color:#9ca3af; font-size:11px; margin-bottom:4px; } + .asset-preview-img { width:100%; height:92px; object-fit:contain; background:#111827; border:1px solid #1f2937; border-radius:6px; } + + + #asset-move-panel { border:1px solid #334155; background:#0b1220; border-radius:10px; padding:10px; margin-bottom:10px; } + #asset-move-row { justify-content: center; gap:12px; margin-bottom:0; } + #asset-move-row .btn-move, + #asset-move-row .btn-home, + #asset-broker-row .btn-broker, + #asset-broker-row .btn-diy { + width:122px; + height:42px; + padding:8px 8px 0; + border:none; + border-radius:0; + background-color: transparent !important; + background-repeat:no-repeat; + background-size:300% 100%; + background-position:0 0; + image-rendering: pixelated; + appearance:none; + -webkit-appearance:none; + color:#fff; + text-align:center; + font-size:14px; + font-weight:400; + letter-spacing:.2px; + text-shadow:none; + display:inline-flex; + align-items:flex-start; + justify-content:center; + transition: padding-top .08s ease, filter .12s ease; + box-shadow:none; + } + #asset-move-row .btn-move { color:#1f2937; } + #asset-move-row .btn-move { + background-image:url('/static/btn-move-house-sprite.png?v={{VERSION_TIMESTAMP}}'); + } + #asset-move-row .btn-home { + background-image:url('/static/btn-back-home-sprite.png?v={{VERSION_TIMESTAMP}}'); + } + #asset-broker-row { justify-content:center; gap:12px; margin-top:8px; margin-bottom:0; } + #asset-broker-row .btn-broker { + background-image:url('/static/btn-broker-sprite.png?v={{VERSION_TIMESTAMP}}'); + } + #asset-broker-row .btn-diy { + background-image:url('/static/btn-diy-sprite.png?v={{VERSION_TIMESTAMP}}'); + } + #asset-manual-panel { + margin-top:0; + max-height:0; + opacity:0; + transform:translateY(-6px); + overflow:hidden; + pointer-events:none; + transition:max-height .28s ease, opacity .22s ease, transform .28s ease, margin-top .28s ease; + } + #asset-manual-panel.open { + margin-top:8px; + max-height:1600px; + opacity:1; + transform:translateY(0); + pointer-events:auto; + } + #asset-broker-panel { + margin-top:0; + border:1px dashed #334155; + border-radius:8px; + padding:8px; + background:#0f172a; + max-height:0; + opacity:0; + transform:translateY(-6px); + overflow:hidden; + pointer-events:none; + transition:max-height .28s ease, opacity .22s ease, transform .28s ease, margin-top .28s ease; + } + #asset-broker-panel.open { + margin-top:8px; + max-height:520px; + opacity:1; + transform:translateY(0); + pointer-events:auto; + } + #asset-broker-prompt { + width:100%; min-height:66px; resize:vertical; + padding:8px; border-radius:6px; + border:1px solid #334155; background:#111827; color:#e5e7eb; + font-family:'ArkPixel', monospace; font-size:12px; + box-sizing:border-box; + } + #asset-broker-actions { margin-top:8px; display:flex; justify-content:flex-end; } + #asset-broker-actions button { + background:#0ea5e9; + color:#e0f2fe; + border-color:#38bdf8; + font-weight:700; + font-size:12px; + padding:7px 10px; + min-width:112px; + text-align:center; + box-shadow: 0 2px 0 rgba(0,0,0,.25); + transition: transform .08s ease, filter .12s ease, box-shadow .12s ease; + } + #asset-move-row .btn-move:hover, + #asset-move-row .btn-home:hover, + #asset-broker-row .btn-broker:hover, + #asset-broker-row .btn-diy:hover, + #asset-broker-actions button:hover { + filter: brightness(1.06); + background-color: transparent !important; + } + #asset-move-row .btn-move:active, + #asset-move-row .btn-home:active, + #asset-broker-row .btn-broker:active, + #asset-broker-row .btn-diy:active, + #asset-broker-actions button:active, + #asset-move-row .btn-move.is-active, + #asset-move-row .btn-home.is-active, + #asset-broker-row .btn-broker.is-active, + #asset-broker-row .btn-diy.is-active, + #asset-broker-actions button.is-active { + padding-top:13px; + filter: brightness(0.96); + background-color: transparent !important; + } + + #asset-move-row .btn-move:active, + #asset-move-row .btn-move.is-active, + #asset-move-row .btn-home:active, + #asset-move-row .btn-home.is-active, + #asset-broker-row .btn-broker:active, + #asset-broker-row .btn-broker.is-active, + #asset-broker-row .btn-diy:active, + #asset-broker-row .btn-diy.is-active { + background-position:50% 0; + } + #asset-move-row .btn-move.is-done, + #asset-move-row .btn-home.is-done, + #asset-broker-row .btn-broker.is-done, + #asset-broker-row .btn-diy.is-done { + background-position:100% 0; + } + + + #asset-highlight { + position: fixed; + border: 3px solid #22c55e; + background: transparent; + box-shadow: none; + pointer-events: none; + display: none; + z-index: 999998; + } + #room-loading-overlay { + position: fixed; + left: 0; + top: 0; + width: 0; + height: 0; + background: rgba(0, 0, 0, 0.62); + z-index: 1000000; + display: none; + align-items: center; + justify-content: center; + pointer-events: auto; + border-radius: 10px; + } + .room-loading-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 16px 20px; + border-radius: 10px; + border: 1px solid rgba(255,255,255,.2); + background: rgba(0,0,0,.36); + color: #fff; + font-family: 'ArkPixel', monospace; + font-size: 20px; + text-shadow: 0 2px 6px rgba(0,0,0,.45); + } + #room-loading-emoji { + font-size: 52px; + line-height: 1; + min-height: 56px; + } + #room-loading-text { + font-size: 20px; + letter-spacing: 1px; } #control-buttons button { height: 52px; } #control-bar button { @@ -139,18 +500,132 @@ background: #4a4f5f; border-color: #e94560; } + /* Star 状态四按钮(不含装修)使用像素精灵皮肤 */ + #control-bar #btn-state-idle, + #control-bar #btn-state-writing, + #control-bar #btn-state-syncing, + #control-bar #btn-state-error { + background-image: url('/static/btn-state-sprite.png?v={{VERSION_TIMESTAMP}}'); + background-color: transparent !important; + background-repeat: no-repeat; + background-size: 300% 100%; + background-position: 0 0; + border: none; + border-radius: 0; + appearance: none; + -webkit-appearance: none; + image-rendering: pixelated; + color: #5e6366; + font-weight: 400; + text-shadow: none; + padding: 0 8px 9px; + line-height: 1; + transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease; + } + #control-bar #btn-state-idle:hover, + #control-bar #btn-state-writing:hover, + #control-bar #btn-state-syncing:hover, + #control-bar #btn-state-error:hover { + background-color: transparent !important; + filter: brightness(1.04); + } + #control-bar #btn-state-idle:active, + #control-bar #btn-state-writing:active, + #control-bar #btn-state-syncing:active, + #control-bar #btn-state-error:active { + background-position: 50% 0; + padding-top: 5px; + padding-bottom: 0; + filter: brightness(0.97); + } + /* 装修房间按钮使用像素精灵皮肤 */ + #control-bar #btn-open-drawer { + background-image: url('/static/btn-open-drawer-sprite.png?v={{VERSION_TIMESTAMP}}') !important; + background-color: transparent !important; + background-repeat: no-repeat !important; + background-size: 300% 100% !important; + background-position: 0 0 !important; + border: none !important; + border-radius: 0 !important; + appearance: none; + -webkit-appearance: none; + image-rendering: pixelated; + color: #5e6366 !important; + font-weight: 400 !important; + font-size: 15px !important; + text-shadow: none !important; + padding: 0 10px 10px !important; + line-height: 1 !important; + transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease; + } + #control-bar #btn-open-drawer:hover { + background-color: transparent !important; + filter: brightness(1.04); + } + #control-bar #btn-open-drawer:active { + background-position: 50% 0 !important; + padding-top: 5px !important; + padding-bottom: 5px !important; + filter: brightness(0.97); + } /* Guest Agent 名单面板(右下角) */ #guest-agent-panel { - width: 360px; + position: relative; + width: 390px; height: 300px; - background: #2c2f3a; - padding: 12px; - border-radius: 8px; - border: 2px solid #e94560; - box-shadow: 0 4px 20px rgba(0,0,0,0.5); + background: #141722; + padding: 10px 10px 12px; + border-radius: 0; + border: 4px solid #0e1119; + box-shadow: none; display: flex; flex-direction: column; gap: 10px; + overflow: hidden; + } + #guest-agent-panel::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc); + background-repeat: no-repeat; + background-size: + calc(50% - 14px) 2px, calc(50% - 14px) 2px, + 2px calc(50% - 14px), 2px calc(50% - 14px), + calc(50% - 14px) 2px, calc(50% - 14px) 2px, + 2px calc(50% - 14px), 2px calc(50% - 14px); + background-position: + 9px 8px, calc(50% + 5px) 8px, + 8px 9px, calc(100% - 10px) 9px, + 9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px), + 8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px); + } + #guest-agent-panel::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc); + background-repeat: no-repeat; + background-size: + 9px 4px, 4px 9px, + 9px 4px, 4px 9px, + 9px 4px, 4px 9px, + 9px 4px, 4px 9px; + background-position: + left top, left top, + right top, right top, + left bottom, left bottom, + right bottom, right bottom; } #guest-agent-panel-title { color: #ffd700; @@ -158,9 +633,9 @@ font-weight: bold; text-align: center; letter-spacing: 1px; - padding-bottom: 8px; - border-bottom: 2px solid #4a4f5f; - margin-bottom: 6px; + padding: 6px 0 10px; + border-bottom: 0; + margin-bottom: 0; } #guest-agent-list { flex-grow: 1; @@ -217,26 +692,78 @@ } /* Memo 区域 - 4:3 小正方形 */ #memo-panel { - width: 400px; + position: relative; + width: 460px; height: 300px; - background-image: url('/static/memo-bg.png'); + background-image: url('/static/memo-bg.webp'); background-size: cover; background-position: center; - border: 3px solid #e94560; - border-radius: 8px; + border: 4px solid #0e1119; + border-radius: 0; padding: 14px 16px; - box-shadow: 0 4px 20px rgba(0,0,0,0.5); + box-shadow: none; display: flex; flex-direction: column; overflow: hidden; } + #memo-panel::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc); + background-repeat: no-repeat; + background-size: + calc(50% - 14px) 2px, calc(50% - 14px) 2px, + 2px calc(50% - 14px), 2px calc(50% - 14px), + calc(50% - 14px) 2px, calc(50% - 14px) 2px, + 2px calc(50% - 14px), 2px calc(50% - 14px); + background-position: + 9px 8px, calc(50% + 5px) 8px, + 8px 9px, calc(100% - 10px) 9px, + 9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px), + 8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px); + } + #memo-panel::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc), + linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc); + background-repeat: no-repeat; + background-size: + 9px 4px, 4px 9px, + 9px 4px, 4px 9px, + 9px 4px, 4px 9px, + 9px 4px, 4px 9px; + background-position: + left top, left top, + right top, right top, + left bottom, left bottom, + right bottom, right bottom; + } + #memo-panel.no-bg { + background-image: none !important; + background-color: #111827; + } #memo-title { - color: #ffd700; - font-size: 14px; + color: #1b192e; + font-size: 16px; + font-weight: bold; margin-bottom: 6px; text-align: center; letter-spacing: 1px; flex-shrink: 0; + position: relative; + top: 15px; } #memo-date { color: #888; @@ -245,7 +772,8 @@ text-align: right; flex-shrink: 0; position: relative; - left: -30px; /* move date left by 30px */ + left: -40px; /* move date left by 40px */ + top: -10px; } #memo-content { color: #3b3b32; @@ -257,7 +785,8 @@ flex-grow: 1; padding-right: 4px; position: relative; - left: 80px; /* move content right by 80px */ + left: 100px; /* move content right by 100px */ + top: -10px; } #memo-content::-webkit-scrollbar { width: 6px; @@ -292,8 +821,9 @@ body { padding: 0; gap: 0; - overflow-x: hidden; + overflow-x: auto; overflow-y: auto; + -webkit-overflow-scrolling: touch; align-items: stretch; } @@ -306,10 +836,16 @@ border-radius: 0; aspect-ratio: auto; flex: 0 0 auto; - touch-action: pan-y; /* 保留页面纵向滚动手势 */ + touch-action: auto; overflow: hidden; } + #main-stage { + width: 100vw; + min-width: 0; + margin-left: 0 !important; + } + #bottom-panels { width: 100vw; max-width: 100vw; @@ -321,6 +857,10 @@ flex: 0 0 auto; } + body.drawer-open #main-stage { + margin-left: 0 !important; + } + #memo-panel, #control-bar, #guest-agent-panel { @@ -356,6 +896,15 @@ font-size: 12px; min-height: 44px; } + #control-buttons { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + } + #control-buttons button { + min-height: 40px; + padding: 4px 2px; + font-size: 11px; + } .guest-agent-item { align-items: flex-start; @@ -371,34 +920,77 @@ #status-text { bottom: 8px; + left: 8px; + max-width: 64vw; font-size: 12px; padding: 8px 12px; } #coords-toggle, - #pan-toggle { + #pan-toggle, + #lang-btn-en, + #lang-btn-jp, + #lang-btn-cn { font-size: 12px !important; padding: 6px 8px !important; } + + #asset-drawer { + width: 92vw; + max-width: 92vw; + } + #asset-drawer-body { padding: 8px; } + #asset-list { + display: flex; + flex-direction: column; + gap: 6px; + } + .asset-item { + grid-template-columns: 52px 1fr 36px; + padding: 6px; + gap: 6px; + } + .asset-thumb { width:52px; height:52px; } + .asset-path { font-size: 11px; line-height: 1.3; } + .asset-sub { font-size: 10px; } + #asset-upload-panel { + position: sticky; + bottom: 0; + padding: 8px; + } + #asset-upload-panel .asset-toolbar { + gap: 6px; + margin-bottom: 6px; + } + #asset-upload-panel input { + min-width: 0; + flex: 1 1 42%; + } + #asset-upload-panel button { + min-height: 38px; + } }
-
正在加载 Star 的像素办公室...
+
Loading Star’s pixel office...
-
- - -
+
+
+
加载中...
+
+ + +
-
— 昨 日 小 记 —
+
昨 日 小 记
─ ─ ─ ─ ─
@@ -411,23 +1003,100 @@
Star 状态
- - - - + + + + +
-
— 访 客 列 表 —
+
访 客 列 表
正在加载访客...
+
-
加载中...
+
+
+
+
🦞
+
正在打包虾头……
+
+
+ + @@ -437,9 +1106,199 @@ +
+ + + +
- +