From f3373334328270b293001b578978257dc3a6cb91 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Sat, 29 Aug 2026 17:38:48 +0800 Subject: [PATCH] =?UTF-8?q?approve:=20=E5=85=83=E6=99=AF=20W-08=20?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=9D=97=E5=BC=80?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 + README.md | 47 ++++ build.sh | 173 +++----------- docs/work-log-2026-XX-XX.md | 28 +++ init/data.json | 14 ++ json/game_result_list.json | 1 + json/game_session_list.json | 1 + models/game_result.json | 1 + models/game_session.json | 1 + pyproject.toml | 14 ++ scense/__init__.py | 8 + scense/core.py | 276 +++++++++++++++++++++++ scense/init.py | 32 +++ scripts/load_path.py | 72 ++++++ skill/SKILL.md | 52 +++++ sql/game_ddl.sql | 42 ++++ wwwroot/game/api/game_end.dspy | 24 ++ wwwroot/game/api/game_list.dspy | 13 ++ wwwroot/game/api/game_pause.dspy | 19 ++ wwwroot/game/api/game_restart.dspy | 19 ++ wwwroot/game/api/game_resume.dspy | 19 ++ wwwroot/game/api/game_save.dspy | 24 ++ wwwroot/game/api/game_share.dspy | 24 ++ wwwroot/game/api/game_start.dspy | 21 ++ wwwroot/game/game.js | 347 +++++++++++++++++++++++++++++ wwwroot/game/game_list.js | 75 +++++++ wwwroot/game/game_play.html | 87 ++++++++ wwwroot/game/index.ui | 24 ++ 28 files changed, 1331 insertions(+), 137 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/work-log-2026-XX-XX.md create mode 100644 init/data.json create mode 100644 json/game_result_list.json create mode 100644 json/game_session_list.json create mode 100644 models/game_result.json create mode 100644 models/game_session.json create mode 100644 pyproject.toml create mode 100644 scense/__init__.py create mode 100644 scense/core.py create mode 100644 scense/init.py create mode 100644 scripts/load_path.py create mode 100644 skill/SKILL.md create mode 100644 sql/game_ddl.sql create mode 100644 wwwroot/game/api/game_end.dspy create mode 100644 wwwroot/game/api/game_list.dspy create mode 100644 wwwroot/game/api/game_pause.dspy create mode 100644 wwwroot/game/api/game_restart.dspy create mode 100644 wwwroot/game/api/game_resume.dspy create mode 100644 wwwroot/game/api/game_save.dspy create mode 100644 wwwroot/game/api/game_share.dspy create mode 100644 wwwroot/game/api/game_start.dspy create mode 100644 wwwroot/game/game.js create mode 100644 wwwroot/game/game_list.js create mode 100644 wwwroot/game/game_play.html create mode 100644 wwwroot/game/index.ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b26ba87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.pyc +__pycache__/ +build/ +*.egg-info/ +dist/ +mysql.ddl.sql +wwwroot/game_session_list/ +wwwroot/game_result_list/ +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c9cb64 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# scense 游戏展示模块(元景 W-08) + +## 功能 +游戏列表入口 / 点击开始玩(初始化 W-10 世界运行态进入)/ 暂停 / 继续 / 结束结算 / 重新开始 / 分数结果展示 / 分享链接进入 / 玩中交互(点选移动实体触发脚本逻辑)/ 退出保存进度。 + +## 数据表 +| 表 | 作用 | 关键字段 | +|----|------|----------| +| game_session | 游戏会话 | status(created/playing/paused/ended), score, level, elapsed_ms, progress_json, share_token | +| game_result | 游戏结果 | session_id, score, duration_ms, level, result_json | + +## 会话状态机 +- 开始玩:无会话→playing;已有 playing/paused→恢复 playing(续玩) +- playing ⇄ paused(pause/resume) +- playing/paused → ended(end 唯一结算出口,写 game_result) +- 任意状态 → playing(restart 重置分数/关卡) +- save 保存进度快照(不改变状态) + +## API 端点(wwwroot/game/api/*.dspy,全部 logined) +| 文件 | 功能 | 功能点 | +|------|------|--------| +| game_list.dspy | 游戏列表 + 历史会话/结果 | W-08a | +| game_start.dspy | 开始玩(进入运行态) | W-08b | +| game_pause.dspy | 暂停 | W-08c | +| game_resume.dspy | 继续 | W-08d | +| game_end.dspy | 结束结算(写 game_result) | W-08e/g | +| game_restart.dspy | 重新开始(重置) | W-08f | +| game_share.dspy | 生成/解析分享链接 | W-08h | +| game_save.dspy | 保存进度快照 | W-08j | + +## 前端 +- `game/index.ui` — bricks 游戏列表入口页 +- `game/game_play.html` — 全屏游戏页(canvas + HUD + 结算面板 + 交互),验收入口 +- `game/game.js` — 游戏交互(开始/暂停/继续/结束/重开/分享/保存/点选实体) +- `game/game_list.js` — 列表页渲染 + +## 依赖 +- W-10 runtime(runtime.js RuntimeEngine,前端运行态,服务端辅助可选) +- world 模块(游戏世界来源,防御式降级独立模式) +- sqlor / ahserver / ServerEnv + +## 集成 +1. `app/sage.py`: `from scense.init import load_scense` + `load_scense()` in init() +2. build.sh 安装循环添加 scense +3. `scripts/load_path.py` 注册 RBAC 路径(禁通配符) +4. `global_menu.ui` 添加菜单项 `{{entire_url('/scense/game/index.ui')}}` +5. 部署目标 `/d/scense/scense_app`:模块 wwwroot 软链接 + pip install -e diff --git a/build.sh b/build.sh index fe7b8bf..61251fb 100755 --- a/build.sh +++ b/build.sh @@ -1,144 +1,43 @@ -#!/usr/bin/env bash -# ============================================================================= -# scense 应用一键部署脚本(元景项目) -# 唯一部署单元:一个入口 app/scense.py + 一个端口 ${SERVER_PORT:-9380} -# 门禁:部署完成后 30s 端口监听门禁——30s 内未监听自动重启一次兜底, -# 再等 30s 仍失败才输出诊断并以非零退出(不静默通过)。 -# ============================================================================= -set -uo pipefail +#!/bin/bash +# scense 模块构建脚本:由宿主应用 build.sh 调用或独立执行 +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" -APP_NAME="scense" -APP_PORT="${SERVER_PORT:-9380}" -WORKDIR="$(cd "$(dirname "$0")" && pwd)" -VENV_DIR="$WORKDIR/venv" -PKGS_DIR="$WORKDIR/pkgs" -LOGS_DIR="$WORKDIR/logs" -ENV_FILE="$WORKDIR/.env" - -# 模块清单(基础引用 + 本应用业务模块,与 yuanjing_spec.json generated_modules 一致) -FOUNDATION_MODULES=(apppublic sqlor ahserver bricks_for_python appbase rbac) -BIZ_MODULES=(world scene entity script_engine world_snapshot world_sync) -ALL_MODULES=("${FOUNDATION_MODULES[@]}" "${BIZ_MODULES[@]}") - -log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } -die() { log "FATAL: $*"; exit 1; } - -# 0) 加载 .env(含 MYSQL_* / REDIS_* / SERVER_PORT / PASSWORD_KEY / SCENSE_ADMIN_*) -if [ -f "$ENV_FILE" ]; then - set -a; . "$ENV_FILE"; set +a +# 定位 Sage 根 +SAGE_ROOT="" +for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do + if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then + SAGE_ROOT="$(cd "$candidate" && pwd)" + break + fi +done +if [ -z "$SAGE_ROOT" ]; then + echo "ERROR: Cannot find Sage root" + exit 1 fi -MYSQL_ARGS=(--host="${MYSQL_HOST:-127.0.0.1}" --port="${MYSQL_PORT:-3306}" - --user="${MYSQL_USER:-root}" --password="${MYSQL_PASSWORD:-}") -DBNAME="${MYSQL_DATABASE:-scense}" - -# 1) 系统依赖 + venv -command -v python3 >/dev/null 2>&1 || die "python3 not found" -command -v mysql >/dev/null 2>&1 || die "mysql client not found" -[ -d "$VENV_DIR" ] || python3 -m venv "$VENV_DIR" -# shellcheck disable=SC1091 -. "$VENV_DIR/bin/activate" -pip install --quiet --upgrade pip -pip install --quiet xls2ddl - -# 2) 克隆并安装模块(基础 + 业务) -mkdir -p "$PKGS_DIR" -for m in "${ALL_MODULES[@]}"; do - if [ ! -d "$PKGS_DIR/$m" ]; then - git clone "https://git.opencomputing.cn/yumoqing/$m.git" "$PKGS_DIR/$m" 2>/dev/null \ - || git clone "git@git.opencomputing.cn:yumoqing/$m.git" "$PKGS_DIR/$m" 2>/dev/null \ - || die "clone module $m failed" - fi - if [ -f "$PKGS_DIR/$m/pyproject.toml" ]; then - (cd "$PKGS_DIR/$m" && pip install --quiet -e .) - fi -done -pip install --quiet bricks_for_python - -# 3) 前端 wwwroot 软链(软链非 cp,保持同步) -mkdir -p "$WORKDIR/wwwroot" -for m in "${BIZ_MODULES[@]}"; do - [ -d "$PKGS_DIR/$m/wwwroot" ] && ln -sfn "../pkgs/$m/wwwroot" "$WORKDIR/wwwroot/$m" -done -[ -d "$PKGS_DIR/bricks_for_python/dist" ] && ln -sfn "../pkgs/bricks_for_python/dist" "$WORKDIR/wwwroot/bricks" - -# 4) i18n 合并(merge_i18n.py 存在则执行,否则跳过不阻断) -if [ -f "$PKGS_DIR/apppublic/tools/merge_i18n.py" ]; then - python "$PKGS_DIR/apppublic/tools/merge_i18n.py" "$WORKDIR/wwwroot" "${BIZ_MODULES[@]}" \ - || log "i18n merge skipped (non-fatal)" +# 1) 安装模块包 +if [ -d "$SAGE_ROOT/py3/bin/pip" ]; then + "$SAGE_ROOT/py3/bin/pip" install -e . || true fi -# 5) 数据库:建库 + 各模块 DDL + 应用级 DDL/seed -mysql "${MYSQL_ARGS[@]}" -e "CREATE DATABASE IF NOT EXISTS \`${DBNAME}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" \ - || die "create database failed" -for m in "${BIZ_MODULES[@]}"; do - if [ -d "$PKGS_DIR/$m/models" ]; then - (cd "$PKGS_DIR/$m/models" && json2ddl mysql . > "$WORKDIR/mysql.ddl.$m.sql" 2>/dev/null) \ - || (cd "$PKGS_DIR/$m/models" && xls2ddl mysql . > "$WORKDIR/mysql.ddl.$m.sql" 2>/dev/null) || true - if [ -f "$WORKDIR/mysql.ddl.$m.sql" ]; then - mysql "${MYSQL_ARGS[@]}" "$DBNAME" < "$WORKDIR/mysql.ddl.$m.sql" || log "ddl $m warn" - fi - fi -done -[ -f "$WORKDIR/scripts/ddl.sql" ] && mysql "${MYSQL_ARGS[@]}" "$DBNAME" < "$WORKDIR/scripts/ddl.sql" -[ -f "$WORKDIR/scripts/seed.sql" ] && mysql "${MYSQL_ARGS[@]}" "$DBNAME" < "$WORKDIR/scripts/seed.sql" - -# 6) CRUD 生成(xls2ui 从模块 json/ 生成 .dspy + .ui) -for m in "${BIZ_MODULES[@]}"; do - if [ -d "$PKGS_DIR/$m/json" ]; then - (cd "$PKGS_DIR/$m/json" && xls2ui -m ../models -o "$WORKDIR/wwwroot" "$m" *.json) \ - || log "xls2ui $m skipped (non-fatal)" - fi -done - -# 7) 初始化 admin 用户(RBAC 标准接口,幂等) -python "$WORKDIR/scripts/init_admin_user.py" || die "init_admin_user failed" - -# 8) systemd 服务 + 启动 -mkdir -p "$LOGS_DIR" "$WORKDIR/files" -cat > "$WORKDIR/scense.service" </dev/null || true -sudo systemctl daemon-reload 2>/dev/null || true -sudo systemctl enable scense 2>/dev/null || true -sudo systemctl restart scense 2>/dev/null \ - || nohup "$VENV_DIR/bin/python" "$WORKDIR/app/scense.py" >> "$LOGS_DIR/scense.log" 2>&1 & - -# 9) 端口监听门禁(30s;未监听自动重启一次兜底再等 30s) -wait_port() { - local port="$1" tries="${2:-30}" - for _ in $(seq 1 "$tries"); do - if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then - exec 3>&- 3<&-; return 0 - fi - sleep 1 - done - return 1 -} - -if ! wait_port "$APP_PORT" 30; then - log "WARN: port $APP_PORT not listening in 30s, restart once (bootstrap)" - sudo systemctl restart scense 2>/dev/null || true - if ! wait_port "$APP_PORT" 30; then - log "ERROR: port $APP_PORT still not listening after restart bootstrap" - tail -n 50 "$LOGS_DIR/scense.log" 2>/dev/null || true - exit 1 - fi +# 2) 生成 DDL(models/*.json -> mysql.ddl.sql) +if [ -d models ]; then + (cd "$SAGE_ROOT" && ./py3/bin/python -m xls2ddl.json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/mysql.ddl.sql" 2>/dev/null) || true fi -log "OK: scense is listening on port $APP_PORT" +# 3) 生成 CRUD UI(json/*.json -> wwwroot/{alias}/) +if [ -d json ]; then + (cd "$SAGE_ROOT" && ./py3/bin/python -m xls2ui -m "$SCRIPT_DIR/models" -o "$SCRIPT_DIR/wwwroot" scense "$SCRIPT_DIR"/json/*.json) || true +fi + +# 4) 软链接 wwwroot 到 Sage +mkdir -p "$SAGE_ROOT/wwwroot/scense" +for item in "$SCRIPT_DIR"/wwwroot/*; do + [ -e "$item" ] || continue + name="$(basename "$item")" + ln -sfn "$item" "$SAGE_ROOT/wwwroot/scense/$name" +done + +echo "scense build done. SAGE_ROOT=$SAGE_ROOT" diff --git a/docs/work-log-2026-XX-XX.md b/docs/work-log-2026-XX-XX.md new file mode 100644 index 0000000..5698ba8 --- /dev/null +++ b/docs/work-log-2026-XX-XX.md @@ -0,0 +1,28 @@ +# 工作日志 2026-XX-XX — scense(W-08 游戏展示模块) + +## 范围/背景 +元景项目 W-08 游戏展示模块开发(new_dev,任务 ZlTgfzCyYXI72tx5s5_DJ,迭代 oOk__9medSOirW_Pxm31T)。 +模块名 scense,实现为独立 Python 包(无 app.py/端口),挂载到宿主应用;部署目标 /d/scense/scense_app。 +依赖 W-10 runtime 前端运行态引擎与 world 模块(防御式降级独立模式)。 + +## 交付物 +- modules/scense/ 完整模块仓库:models(2表) / scense包(core/init) / wwwroot(game列表+api 8端点+全屏游戏页) / json(2 CRUD) / init(编码) / scripts(RBAC) / build/pyproject/README/skill +- 功能点 W-08a~j 全映射,会话状态机服务端强制,end 唯一结算出口 +- 项目文档:dev-notes.md、docs/01-design/modules/scense.md、work-log + +## 关键决策与陷阱规避 +1. 取库名 `get_module_dbname('scense')`,禁硬编码 DBNAME +2. dspy 禁 import/print/uuid/f-string,显式 return json.dumps +3. sor.C 显式 created_at/updated_at;ID 用 getID() +4. 非法状态流转返回 400(ValueError),不 500 +5. game_play.html 独立 HTML 允许 fetch/setInterval(bricks script 禁 fetch,列表交互放 game_list.js) +6. runtime.js 绝对路径 /runtime/runtime.js(runtime 模块 any 权限) +7. RBAC 禁通配符,逐条显式 + +## 验证情况 +- py_compile 校验 .py(core/__init__/init) +- dspy 审计通过(无违规 import) +- 环境受限未实际部署启动(需 deploy_test 在 /d/scense/scense_app 部署 + 浏览器验收) + +## 当前状态 +模块源码就绪待 PM 审核;部署/浏览器验收由下游角色执行。 diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..9c15be9 --- /dev/null +++ b/init/data.json @@ -0,0 +1,14 @@ +{ + "appcodes": [ + { + "parentid": "game_session_status", + "parentname": "游戏会话状态", + "items": [ + {"k": "created", "v": "已创建"}, + {"k": "playing", "v": "进行中"}, + {"k": "paused", "v": "已暂停"}, + {"k": "ended", "v": "已结束"} + ] + } + ] +} \ No newline at end of file diff --git a/json/game_result_list.json b/json/game_result_list.json new file mode 100644 index 0000000..5cc772c --- /dev/null +++ b/json/game_result_list.json @@ -0,0 +1 @@ +{"tblname":"game_result","alias":"game_result_list","params":{"sortby":"created_at desc","logined_userorgid":"user_id","browserfields":{"fields":[{"name":"id","width":90,"frozen":true},{"name":"game_id","title":"游戏","width":120},{"name":"score","title":"得分","width":80},{"name":"duration_ms","title":"时长ms","width":90},{"name":"level","title":"关卡","width":70},{"name":"created_at","title":"结算时间","width":160}],"exclouded":["session_id","result_json"]},"editexclouded":["id","session_id","user_id","game_id","result_json","created_at"],"editable":{"new_data_url":null,"update_data_url":null,"delete_data_url":null}} \ No newline at end of file diff --git a/json/game_session_list.json b/json/game_session_list.json new file mode 100644 index 0000000..06f8c90 --- /dev/null +++ b/json/game_session_list.json @@ -0,0 +1 @@ +{"tblname":"game_session","alias":"game_session_list","params":{"sortby":"updated_at desc","logined_userorgid":"user_id","new_data_url":"{{entire_url('/scense/game/api/game_start.dspy')}}","update_data_url":"{{entire_url('/scense/game/api/game_save.dspy')}}","delete_data_url":"{{entire_url('/scense/game/api/game_end.dspy')}}","browserfields":{"fields":[{"name":"id","width":90,"frozen":true},{"name":"game_name","title":"游戏名称","width":140},{"name":"status","title":"状态","width":90,"uitype":"code","data":[{"value":"created","text":"已创建"},{"value":"playing","text":"进行中"},{"value":"paused","text":"已暂停"},{"value":"ended","text":"已结束"}]},{"name":"score","title":"得分","width":80},{"name":"level","title":"关卡","width":70},{"name":"elapsed_ms","title":"时长ms","width":90},{"name":"updated_at","title":"更新时间","width":160}],"exclouded":["progress_json","share_token","world_id","created_at"]},"editexclouded":["id","user_id","game_id","share_token","created_at","updated_at","ended_at","progress_json"],"editable":{"new_data_url":"{{entire_url('/scense/game/api/game_start.dspy')}}","update_data_url":"{{entire_url('/scense/game/api/game_save.dspy')}}","delete_data_url":"{{entire_url('/scense/game/api/game_end.dspy')}}"}} \ No newline at end of file diff --git a/models/game_result.json b/models/game_result.json new file mode 100644 index 0000000..ec9e1b0 --- /dev/null +++ b/models/game_result.json @@ -0,0 +1 @@ +{"summary":[{"table":"game_result","desc":"游戏结果表","pkey":"id","primary":["id"]}],"fields":[{"name":"id","type":"varchar","length":32,"desc":"结果ID","primary":true},{"name":"session_id","type":"varchar","length":32,"desc":"会话ID"},{"name":"user_id","type":"varchar","length":32,"desc":"用户ID"},{"name":"game_id","type":"varchar","length":64,"desc":"游戏标识"},{"name":"score","type":"int","length":11,"desc":"结算得分"},{"name":"duration_ms","type":"bigint","length":20,"desc":"游戏时长毫秒"},{"name":"level","type":"int","length":11,"desc":"结束关卡"},{"name":"result_json","type":"text","desc":"结果详情JSON"},{"name":"created_at","type":"datetime","desc":"结算时间"}],"indexes":[{"name":"idx_session","fields":["session_id"]},{"name":"idx_user_game","fields":["user_id","game_id"]}],"codes":[]} \ No newline at end of file diff --git a/models/game_session.json b/models/game_session.json new file mode 100644 index 0000000..c4c45fb --- /dev/null +++ b/models/game_session.json @@ -0,0 +1 @@ +{"summary":[{"table":"game_session","desc":"游戏会话表","pkey":"id","primary":["id"]}],"fields":[{"name":"id","type":"varchar","length":32,"desc":"会话ID","primary":true},{"name":"user_id","type":"varchar","length":32,"desc":"用户ID"},{"name":"game_id","type":"varchar","length":64,"desc":"游戏标识"},{"name":"game_name","type":"varchar","length":128,"desc":"游戏名称"},{"name":"status","type":"varchar","length":16,"desc":"会话状态 created/playing/paused/ended"},{"name":"score","type":"int","length":11,"desc":"当前得分"},{"name":"level","type":"int","length":11,"desc":"当前关卡"},{"name":"elapsed_ms","type":"bigint","length":20,"desc":"已玩时长毫秒"},{"name":"progress_json","type":"text","desc":"进度快照JSON"},{"name":"share_token","type":"varchar","length":64,"desc":"分享令牌"},{"name":"world_id","type":"varchar","length":64,"desc":"世界ID"},{"name":"created_at","type":"datetime","desc":"创建时间"},{"name":"updated_at","type":"datetime","desc":"更新时间"},{"name":"ended_at","type":"datetime","desc":"结束时间"}],"indexes":[{"name":"idx_user_status","fields":["user_id","status"]},{"name":"idx_user_game","fields":["user_id","game_id"]},{"name":"idx_share_token","fields":["share_token"]}],"codes":[{"field":"status","table":"appcodes","valuefield":"k","textfield":"v","cond":{"parentid":"game_session_status"}}]} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1b02dab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "scense" +version = "1.0.0" +description = "元景 W-08 游戏展示模块:游戏会话/结果管理 + bricks 前端游戏页" +requires-python = ">=3.8" +dependencies = ["sqlor", "bricks_for_python"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["scense*"] diff --git a/scense/__init__.py b/scense/__init__.py new file mode 100644 index 0000000..36724dc --- /dev/null +++ b/scense/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +"""scense 游戏展示模块包。""" +from scense.core import ( + game_list_games, game_list_sessions, game_list_results, + game_start_session, game_pause_session, game_resume_session, + game_end_session, game_restart_session, + game_generate_share, game_parse_share, game_save_progress, +) diff --git a/scense/core.py b/scense/core.py new file mode 100644 index 0000000..f316664 --- /dev/null +++ b/scense/core.py @@ -0,0 +1,276 @@ +"""scense 游戏展示模块核心逻辑:游戏会话 + 结果管理。 + +W-08 功能点映射: +- W-08a 游戏列表入口 -> game_list_games / game_list_sessions +- W-08b 点击开始玩(进入运行态) -> game_start_session +- W-08c 暂停 -> game_pause_session +- W-08d 继续 -> game_resume_session +- W-08e 结束结算 -> game_end_session +- W-08f 重新开始 -> game_restart_session +- W-08g 分数结果展示 -> game_end_session(返回结算结果) + game_list_results +- W-08h 分享链接进入 -> game_generate_share / game_parse_share +- W-08i 玩中交互(点选实体触发脚本) -> 前端 runtime 引擎 dispatchEvent(见 game.js) +- W-08j 退出保存进度 -> game_save_progress + +会话状态机(game_session.status): + created --start--> playing --pause--> paused --resume--> playing + playing/paused --end--> ended(唯一结算出口,写 game_result) + 任意状态 --restart--> playing(重置分数/关卡/时长/进度) + save 保存进度快照(不改变状态) +""" +import time +from appPublic.uniqueID import getID +from appPublic.log import info, error, exception +from ahserver.serverenv import ServerEnv +from sqlor.dbpools import DBPools + +# 内置游戏目录(W-08a 游戏列表入口的数据来源) +_GAME_CATALOG = [ + {"game_id": "world_demo", "game_name": "世界探索 Demo", + "desc": "点击实体触发脚本逻辑的世界演示游戏(依赖 W-10 运行时)", + "icon": "fa-gamepad", "world_id": "world_demo"}, + {"game_id": "click_quest", "game_name": "点击冒险", + "desc": "点选移动实体触发脚本逻辑的冒险小游戏", + "icon": "fa-bolt", "world_id": "click_quest"}, +] + +_VALID_STATUS = ('created', 'playing', 'paused', 'ended') + + +def _get_dbname(): + """取库名禁止硬编码:由宿主应用决定 scense 挂载的真实数据库。""" + return ServerEnv().get_module_dbname('scense') + + +def _now(): + return time.strftime('%Y-%m-%d %H:%M:%S') + + +def _session_to_dict(r): + return { + 'session_id': r.id, + 'game_id': r.game_id, + 'game_name': r.game_name, + 'status': r.status, + 'score': r.score, + 'level': r.level, + 'elapsed_ms': r.elapsed_ms, + 'world_id': r.world_id, + 'share_token': r.share_token, + 'updated_at': str(r.updated_at), + } + + +# ---------------------------------------------------------------- W-08a 列表 +async def game_list_games(user_id): + """游戏目录列表(内置演示游戏)。""" + return list(_GAME_CATALOG) + + +async def game_list_sessions(user_id): + """用户历史会话(最近 20 条,按更新时间倒序)。""" + dbname = _get_dbname() + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_session', {'user_id': user_id, 'sort': 'updated_at desc'})) or [] + return [_session_to_dict(r) for r in rows[:20]] + + +async def game_list_results(user_id): + """用户历史结算结果(最近 20 条,按结算时间倒序)。""" + dbname = _get_dbname() + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_result', {'user_id': user_id, 'sort': 'created_at desc'})) or [] + return [{'result_id': r.id, 'session_id': r.session_id, 'game_id': r.game_id, + 'score': r.score, 'duration_ms': r.duration_ms, 'level': r.level, + 'created_at': str(r.created_at)} for r in rows[:20]] + + +# ---------------------------------------------------------------- W-08b 开始玩 +async def game_start_session(user_id, game_id, session_id='', world_id=''): + """开始玩:无会话则创建;传 session_id 或存在进行中(playing/paused)会话则恢复续玩。 + + 返回 {session: {...}},前端据此初始化 W-10 运行时进入运行态。 + 非法 game_id -> ValueError(400);越权访问他人会话 -> ValueError(400)。 + """ + game = next((g for g in _GAME_CATALOG if g['game_id'] == game_id), None) + if not game: + raise ValueError('未知游戏 game_id=%s' % game_id) + dbname = _get_dbname() + db = DBPools() + now = _now() + async with db.sqlorContext(dbname) as sor: + # 1) 指定会话续玩 + if session_id: + rows = (await sor.R('game_session', {'id': session_id, 'user_id': user_id})) or [] + if not rows: + raise ValueError('会话不存在或无权访问') + sess = rows[0] + if sess.status == 'ended': + raise ValueError('会话 %s 已结束,请重新开始' % session_id) + ns = {'id': sess.id, 'status': 'playing', 'updated_at': now} + await sor.U('game_session', ns) + return {'session': _session_to_dict(sess)} + # 2) 该用户该游戏存在进行中会话 -> 恢复续玩 + rows = (await sor.R('game_session', + {'user_id': user_id, 'game_id': game_id, 'sort': 'updated_at desc'})) or [] + active = [r for r in rows if r.status in ('playing', 'paused')] + if active: + sess = active[0] + await sor.U('game_session', {'id': sess.id, 'status': 'playing', 'updated_at': now}) + return {'session': _session_to_dict(sess)} + # 3) 新建会话 + sid = getID() + token = getID() + getID() + ns = { + 'id': sid, 'user_id': user_id, 'game_id': game_id, 'game_name': game['game_name'], + 'status': 'playing', 'score': 0, 'level': 1, 'elapsed_ms': 0, + 'progress_json': '', 'share_token': token, + 'world_id': world_id or game['world_id'], + 'created_at': now, 'updated_at': now, + } + await sor.C('game_session', ns) + info(f'scense.game_start: new session {sid} game={game_id} user={user_id}') + return {'session': {'session_id': sid, 'game_id': game_id, 'game_name': game['game_name'], + 'status': 'playing', 'score': 0, 'level': 1, 'elapsed_ms': 0, + 'world_id': world_id or game['world_id'], 'share_token': token, + 'updated_at': now}} + + +# ---------------------------------------------------------------- W-08c/d 暂停/继续 +async def game_pause_session(user_id, session_id): + """暂停:playing -> paused;状态非法返回 400。""" + dbname = _get_dbname() + db = DBPools() + now = _now() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_session', {'id': session_id, 'user_id': user_id})) or [] + if not rows: + raise ValueError('会话不存在或无权访问') + sess = rows[0] + if sess.status != 'playing': + raise ValueError('当前状态 %s 不可暂停' % sess.status) + await sor.U('game_session', {'id': session_id, 'status': 'paused', 'updated_at': now}) + return {'session_id': session_id, 'status': 'paused'} + + +async def game_resume_session(user_id, session_id): + """继续:paused -> playing;状态非法返回 400。""" + dbname = _get_dbname() + db = DBPools() + now = _now() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_session', {'id': session_id, 'user_id': user_id})) or [] + if not rows: + raise ValueError('会话不存在或无权访问') + sess = rows[0] + if sess.status != 'paused': + raise ValueError('当前状态 %s 不可继续' % sess.status) + await sor.U('game_session', {'id': session_id, 'status': 'playing', 'updated_at': now}) + return {'session_id': session_id, 'status': 'playing'} + + +# ---------------------------------------------------------------- W-08e 结束结算 +async def game_end_session(user_id, session_id, score=None, duration_ms=None, level=None, result_json=None): + """结束结算:playing/paused -> ended,写 game_result(唯一结算出口)。""" + dbname = _get_dbname() + db = DBPools() + now = _now() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_session', {'id': session_id, 'user_id': user_id})) or [] + if not rows: + raise ValueError('会话不存在或无权访问') + sess = rows[0] + if sess.status not in ('playing', 'paused'): + raise ValueError('当前状态 %s 不可结算' % sess.status) + final_score = int(score) if score is not None else (sess.score or 0) + final_level = int(level) if level is not None else (sess.level or 1) + final_duration = int(duration_ms) if duration_ms is not None else (sess.elapsed_ms or 0) + rid = getID() + res_ns = { + 'id': rid, 'session_id': session_id, 'user_id': user_id, 'game_id': sess.game_id, + 'score': final_score, 'duration_ms': final_duration, 'level': final_level, + 'result_json': result_json or sess.progress_json or '', 'created_at': now, + } + await sor.C('game_result', res_ns) + await sor.U('game_session', {'id': session_id, 'status': 'ended', 'score': final_score, + 'level': final_level, 'elapsed_ms': final_duration, + 'ended_at': now, 'updated_at': now}) + info(f'scense.game_end: session {session_id} score={final_score} level={final_level} user={user_id}') + return {'result': {'result_id': rid, 'session_id': session_id, 'game_id': sess.game_id, + 'score': final_score, 'duration_ms': final_duration, + 'level': final_level, 'created_at': now}} + + +# ---------------------------------------------------------------- W-08f 重新开始 +async def game_restart_session(user_id, session_id): + """重新开始:任意状态 -> playing,重置分数/关卡/时长/进度快照。""" + dbname = _get_dbname() + db = DBPools() + now = _now() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_session', {'id': session_id, 'user_id': user_id})) or [] + if not rows: + raise ValueError('会话不存在或无权访问') + sess = rows[0] + await sor.U('game_session', {'id': session_id, 'status': 'playing', 'score': 0, 'level': 1, + 'elapsed_ms': 0, 'progress_json': '', 'updated_at': now}) + return {'session_id': session_id, 'status': 'playing', 'score': 0, 'level': 1, 'elapsed_ms': 0} + + +# ---------------------------------------------------------------- W-08h 分享 +async def game_generate_share(user_id, session_id): + """生成分享链接令牌。""" + dbname = _get_dbname() + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_session', {'id': session_id, 'user_id': user_id})) or [] + if not rows: + raise ValueError('会话不存在或无权访问') + sess = rows[0] + if not sess.share_token: + await sor.U('game_session', {'id': session_id, 'share_token': getID() + getID(), + 'updated_at': _now()}) + return {'session_id': session_id, 'game_id': sess.game_id, 'game_name': sess.game_name, + 'share_token': sess.share_token} + + +async def game_parse_share(token): + """解析分享令牌:token -> 会话/游戏信息(分享链接进入)。""" + if not token: + raise ValueError('缺少分享令牌') + dbname = _get_dbname() + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_session', {'share_token': token})) or [] + if not rows: + raise ValueError('分享链接无效或已失效') + sess = rows[0] + return {'session_id': sess.id, 'game_id': sess.game_id, 'game_name': sess.game_name, + 'status': sess.status, 'score': sess.score, 'level': sess.level, + 'world_id': sess.world_id} + + +# ---------------------------------------------------------------- W-08j 保存进度 +async def game_save_progress(user_id, session_id, progress_json='', score=None, level=None, elapsed_ms=None): + """保存进度快照(不改变状态)。""" + dbname = _get_dbname() + db = DBPools() + now = _now() + async with db.sqlorContext(dbname) as sor: + rows = (await sor.R('game_session', {'id': session_id, 'user_id': user_id})) or [] + if not rows: + raise ValueError('会话不存在或无权访问') + sess = rows[0] + ns = {'id': session_id, 'updated_at': now} + if progress_json is not None: + ns['progress_json'] = progress_json + if score is not None: + ns['score'] = int(score) + if level is not None: + ns['level'] = int(level) + if elapsed_ms is not None: + ns['elapsed_ms'] = int(elapsed_ms) + await sor.U('game_session', ns) + return {'session_id': session_id, 'status': sess.status, 'saved': True} \ No newline at end of file diff --git a/scense/init.py b/scense/init.py new file mode 100644 index 0000000..5e11c32 --- /dev/null +++ b/scense/init.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""scense 游戏展示模块初始化:注册全部服务函数到 ServerEnv。 + +load_scense() 是唯一集成点;宿主应用在 init() 中调用并挂载。 +""" +from scense.core import ( + game_list_games, game_list_sessions, game_list_results, + game_start_session, game_pause_session, game_resume_session, + game_end_session, game_restart_session, + game_generate_share, game_parse_share, game_save_progress, +) + +__all__ = ['load_scense'] + + +def load_scense(env=None): + """注册 scense 模块全部服务函数(env 缺省时取 ServerEnv 单例)。""" + from ahserver.serverenv import ServerEnv + if env is None: + env = ServerEnv() + env.game_list_games = game_list_games + env.game_list_sessions = game_list_sessions + env.game_list_results = game_list_results + env.game_start_session = game_start_session + env.game_pause_session = game_pause_session + env.game_resume_session = game_resume_session + env.game_end_session = game_end_session + env.game_restart_session = game_restart_session + env.game_generate_share = game_generate_share + env.game_parse_share = game_parse_share + env.game_save_progress = game_save_progress + return env diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..236909e --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""scense 模块 RBAC 权限脚本。Usage: cd ~/repos/sage && ./py3/bin/python ~/repos/scense/scripts/load_path.py +注意:禁通配符,逐条显式注册。 +""" +import subprocess, os, sys + +def find_sage_root(): + for c in [os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage")]: + if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")): + return c + return None + +SAGE_ROOT = find_sage_root() +if not SAGE_ROOT: + print("ERROR: Cannot find Sage root") + sys.exit(1) +PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python") +SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, "set_role_perm.py") +MOD = "scense" + +PATHS_ANY = [ + f"/{MOD}/game/runtime.js", + f"/{MOD}/game/game.js", + f"/{MOD}/game/game_list.js", +] + +PATHS_LOGINED = [ + f"/{MOD}", + f"/{MOD}/index.ui", + f"/{MOD}/menu.ui", + f"/{MOD}/game/index.ui", + f"/{MOD}/game/game_play.html", + f"/{MOD}/game/api/game_list.dspy", + f"/{MOD}/game/api/game_start.dspy", + f"/{MOD}/game/api/game_pause.dspy", + f"/{MOD}/game/api/game_resume.dspy", + f"/{MOD}/game/api/game_end.dspy", + f"/{MOD}/game/api/game_restart.dspy", + f"/{MOD}/game/api/game_share.dspy", + f"/{MOD}/game/api/game_save.dspy", + f"/{MOD}/game_session_list", + f"/{MOD}/game_session_list/index.ui", + f"/{MOD}/game_session_list/get_game_session_list.dspy", + f"/{MOD}/game_session_list/add_game_session.dspy", + f"/{MOD}/game_session_list/update_game_session.dspy", + f"/{MOD}/game_session_list/delete_game_session.dspy", + f"/{MOD}/game_result_list", + f"/{MOD}/game_result_list/index.ui", + f"/{MOD}/game_result_list/get_game_result_list.dspy", + f"/{MOD}/game_result_list/add_game_result.dspy", + f"/{MOD}/game_result_list/update_game_result.dspy", + f"/{MOD}/game_result_list/delete_game_result.dspy", +] + + +def run_set_perm(role, path): + return subprocess.run([PYTHON, SET_PERM_SCRIPT, role, path], capture_output=True, text=True).returncode == 0 + + +def register_role_paths(role, paths): + count = sum(1 for p in paths if run_set_perm(role, p)) + print(f" {role}: {count}/{len(paths)} paths registered") + return count + + +def main(): + total = register_role_paths("any", PATHS_ANY) + register_role_paths("logined", PATHS_LOGINED) + print(f"Done. Total {total} permission entries registered.") + + +if __name__ == "__main__": + main() diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..e592bcc --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,52 @@ +--- +name: scense +description: 元景 W-08 游戏展示模块——游戏会话/结果管理 + bricks 前端游戏页(全屏渲染+交互)。开发/修改 scense 相关功能前必读。 +--- + +# scense 模块(W-08 游戏展示) + +## 数据表 +| 表 | 作用 | 关键字段 | +|----|------|----------| +| game_session | 游戏会话 | status(created/playing/paused/ended), score, level, elapsed_ms, progress_json, share_token | +| game_result | 游戏结果 | session_id, score, duration_ms, level, result_json | + +## 会话状态机(会话级) +- 开始玩:无会话→playing;已有 playing/paused→恢复 playing(续玩) +- playing ⇄ paused(pause/resume) +- playing/paused → ended(end 唯一结算出口,写 game_result) +- 任意状态 → playing(restart 重置分数/关卡) +- save 保存进度快照(不改变状态) + +## API 端点(wwwroot/game/api/*.dspy,全部 logined) +| 文件 | 功能 | 对应功能点 | +|------|------|-----------| +| game_list.dspy | 游戏列表 + 历史会话 | W-08a | +| game_start.dspy | 开始玩(进入运行态) | W-08b | +| game_pause.dspy | 暂停 | W-08c | +| game_resume.dspy | 继续 | W-08d | +| game_end.dspy | 结束结算(写 game_result) | W-08e/f/g | +| game_restart.dspy | 重新开始(重置) | W-08f | +| game_share.dspy | 生成/解析分享链接 | W-08h | +| game_save.dspy | 保存进度快照 | W-08j | + +## 前端 +- `game/index.ui` — bricks 游戏列表入口页 +- `game/game_play.html` — 全屏游戏页(canvas + HUD + 结算面板 + 交互),验收入口 +- `game/game.js` — 游戏交互(开始/暂停/继续/结束/重开/分享/保存/点选实体) +- `game/game_list.js` — 列表页渲染 + +## 依赖 +- W-10 runtime(runtime.js RuntimeEngine,前端运行态 + /runtime/api/load_world.dspy) +- world 模块(游戏世界来源,防御式降级独立模式) +- sqlor/ahserver/ServerEnv + +## 陷阱 +1. dspy 禁 import/print/uuid/f-string;显式 return;try/except 包裹;返回值必须 json.dumps。 +2. 取库名 `get_module_dbname('scense')`(.dspy 全局)或 `ServerEnv().get_module_dbname('scense')`(.py),禁硬编码 DBNAME。 +3. 状态非法流转返回 400(ValueError → code 400),不得 500。 +4. sor.C 必须显式 created_at/updated_at;sor.U 同理更新 updated_at。 +5. game_play.html 独立 HTML 可用 fetch/setInterval;bricks script actiontype 禁 fetch——列表交互放 game_list.js。 +6. RBAC 禁通配符,逐条显式(scripts/load_path.py)。 +7. ID 生成用 getID()(appPublic.uniqueID),禁 uuid.uuid4。 +8. game_play.html 引用 runtime.js 用绝对路径 `/runtime/runtime.js`(runtime 模块 any 权限自动服务)。 diff --git a/sql/game_ddl.sql b/sql/game_ddl.sql new file mode 100644 index 0000000..02245b7 --- /dev/null +++ b/sql/game_ddl.sql @@ -0,0 +1,42 @@ +-- 元景 W-08 游戏展示模块 DDL(scense 应用库) +-- 表:game_session(游戏会话) + game_result(游戏结果) + +CREATE TABLE IF NOT EXISTS `game_session` ( + `id` VARCHAR(32) NOT NULL COMMENT '会话ID', + `game_code` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '分享码', + `world_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '世界ID', + `user_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '用户ID', + `org_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '组织ID', + `status` VARCHAR(16) NOT NULL DEFAULT 'created' COMMENT '状态: created/playing/paused/ended', + `score` INT NOT NULL DEFAULT 0 COMMENT '分数', + `level` INT NOT NULL DEFAULT 1 COMMENT '关卡', + `elapsed_ms` INT NOT NULL DEFAULT 0 COMMENT '已玩毫秒', + `progress_json` TEXT COMMENT '进度快照', + `share_token` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享令牌', + `started_at` DATETIME DEFAULT NULL COMMENT '开始时间', + `paused_at` DATETIME DEFAULT NULL COMMENT '暂停时间', + `ended_at` DATETIME DEFAULT NULL COMMENT '结束时间', + `created_at` DATETIME NOT NULL COMMENT '创建时间', + `updated_at` DATETIME NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_game_session_user` (`user_id`,`world_id`), + KEY `idx_game_session_code` (`game_code`), + KEY `idx_game_session_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游戏会话'; + +CREATE TABLE IF NOT EXISTS `game_result` ( + `id` VARCHAR(32) NOT NULL COMMENT '结果ID', + `session_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '会话ID', + `game_code` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '分享码', + `world_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '世界ID', + `user_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '用户ID', + `org_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '组织ID', + `score` INT NOT NULL DEFAULT 0 COMMENT '最终分数', + `duration_ms` INT NOT NULL DEFAULT 0 COMMENT '游戏时长毫秒', + `level` INT NOT NULL DEFAULT 1 COMMENT '到达关卡', + `result_json` TEXT COMMENT '结算明细', + `created_at` DATETIME NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_game_result_session` (`session_id`), + KEY `idx_game_result_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游戏结果'; diff --git a/wwwroot/game/api/game_end.dspy b/wwwroot/game/api/game_end.dspy new file mode 100644 index 0000000..00cbb71 --- /dev/null +++ b/wwwroot/game/api/game_end.dspy @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""W-08e/g 结束结算:写 game_result 并返回结算结果(分数/时长/关卡展示)。""" +result = {'success': False, 'data': {}} +try: + user_id = await get_user() + session_id = params_kw.get('session_id', '') + if not session_id: + result['code'] = 400 + result['error'] = '缺少 session_id' + else: + score = params_kw.get('score', None) + duration_ms = params_kw.get('duration_ms', None) + level = params_kw.get('level', None) + result_json = params_kw.get('result_json', None) + data = await game_end_session(user_id, session_id, score=score, duration_ms=duration_ms, + level=level, result_json=result_json) + result['success'] = True + result['data'] = data +except ValueError as e: + result['code'] = 400 + result['error'] = str(e) +except Exception as e: + result['error'] = str(e) +return json.dumps(result, ensure_ascii=False, default=str) diff --git a/wwwroot/game/api/game_list.dspy b/wwwroot/game/api/game_list.dspy new file mode 100644 index 0000000..1397987 --- /dev/null +++ b/wwwroot/game/api/game_list.dspy @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""W-08a 游戏列表入口:游戏目录 + 历史会话 + 历史结果。""" +result = {'success': False, 'data': {}} +try: + user_id = await get_user() + games = await game_list_games(user_id) + sessions = await game_list_sessions(user_id) + results = await game_list_results(user_id) + result['success'] = True + result['data'] = {'games': games, 'sessions': sessions, 'results': results} +except Exception as e: + result['error'] = str(e) +return json.dumps(result, ensure_ascii=False, default=str) diff --git a/wwwroot/game/api/game_pause.dspy b/wwwroot/game/api/game_pause.dspy new file mode 100644 index 0000000..bd15c05 --- /dev/null +++ b/wwwroot/game/api/game_pause.dspy @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""W-08c 暂停:playing -> paused。""" +result = {'success': False, 'data': {}} +try: + user_id = await get_user() + session_id = params_kw.get('session_id', '') + if not session_id: + result['code'] = 400 + result['error'] = '缺少 session_id' + else: + data = await game_pause_session(user_id, session_id) + result['success'] = True + result['data'] = data +except ValueError as e: + result['code'] = 400 + result['error'] = str(e) +except Exception as e: + result['error'] = str(e) +return json.dumps(result, ensure_ascii=False, default=str) diff --git a/wwwroot/game/api/game_restart.dspy b/wwwroot/game/api/game_restart.dspy new file mode 100644 index 0000000..e305f98 --- /dev/null +++ b/wwwroot/game/api/game_restart.dspy @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""W-08f 重新开始:任意状态 -> playing,重置分数/关卡/时长/进度。""" +result = {'success': False, 'data': {}} +try: + user_id = await get_user() + session_id = params_kw.get('session_id', '') + if not session_id: + result['code'] = 400 + result['error'] = '缺少 session_id' + else: + data = await game_restart_session(user_id, session_id) + result['success'] = True + result['data'] = data +except ValueError as e: + result['code'] = 400 + result['error'] = str(e) +except Exception as e: + result['error'] = str(e) +return json.dumps(result, ensure_ascii=False, default=str) diff --git a/wwwroot/game/api/game_resume.dspy b/wwwroot/game/api/game_resume.dspy new file mode 100644 index 0000000..ffcd793 --- /dev/null +++ b/wwwroot/game/api/game_resume.dspy @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""W-08d 继续:paused -> playing。""" +result = {'success': False, 'data': {}} +try: + user_id = await get_user() + session_id = params_kw.get('session_id', '') + if not session_id: + result['code'] = 400 + result['error'] = '缺少 session_id' + else: + data = await game_resume_session(user_id, session_id) + result['success'] = True + result['data'] = data +except ValueError as e: + result['code'] = 400 + result['error'] = str(e) +except Exception as e: + result['error'] = str(e) +return json.dumps(result, ensure_ascii=False, default=str) diff --git a/wwwroot/game/api/game_save.dspy b/wwwroot/game/api/game_save.dspy new file mode 100644 index 0000000..fa91386 --- /dev/null +++ b/wwwroot/game/api/game_save.dspy @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""W-08j 退出保存进度:保存进度快照(不改变状态)。""" +result = {'success': False, 'data': {}} +try: + user_id = await get_user() + session_id = params_kw.get('session_id', '') + if not session_id: + result['code'] = 400 + result['error'] = '缺少 session_id' + else: + progress_json = params_kw.get('progress_json', '') + score = params_kw.get('score', None) + level = params_kw.get('level', None) + elapsed_ms = params_kw.get('elapsed_ms', None) + data = await game_save_progress(user_id, session_id, progress_json=progress_json, + score=score, level=level, elapsed_ms=elapsed_ms) + result['success'] = True + result['data'] = data +except ValueError as e: + result['code'] = 400 + result['error'] = str(e) +except Exception as e: + result['error'] = str(e) +return json.dumps(result, ensure_ascii=False, default=str) diff --git a/wwwroot/game/api/game_share.dspy b/wwwroot/game/api/game_share.dspy new file mode 100644 index 0000000..1c837d8 --- /dev/null +++ b/wwwroot/game/api/game_share.dspy @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""W-08h 分享链接:生成/解析分享令牌。action=generate|parse""" +result = {'success': False, 'data': {}} +try: + user_id = await get_user() + action = params_kw.get('action', 'generate') + if action == 'parse': + token = params_kw.get('token', '') + data = await game_parse_share(token) + else: + session_id = params_kw.get('session_id', '') + if not session_id: + result['code'] = 400 + result['error'] = '缺少 session_id' + else: + data = await game_generate_share(user_id, session_id) + result['success'] = True + result['data'] = data +except ValueError as e: + result['code'] = 400 + result['error'] = str(e) +except Exception as e: + result['error'] = str(e) +return json.dumps(result, ensure_ascii=False, default=str) diff --git a/wwwroot/game/api/game_start.dspy b/wwwroot/game/api/game_start.dspy new file mode 100644 index 0000000..a9776b0 --- /dev/null +++ b/wwwroot/game/api/game_start.dspy @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""W-08b 点击开始玩:创建/恢复会话并进入运行态(返回 session 供前端初始化运行时)。""" +result = {'success': False, 'data': {}} +try: + user_id = await get_user() + game_id = params_kw.get('game_id', '') + session_id = params_kw.get('session_id', '') + world_id = params_kw.get('world_id', '') + if not game_id: + result['code'] = 400 + result['error'] = '缺少 game_id' + else: + data = await game_start_session(user_id, game_id, session_id, world_id) + result['success'] = True + result['data'] = data +except ValueError as e: + result['code'] = 400 + result['error'] = str(e) +except Exception as e: + result['error'] = str(e) +return json.dumps(result, ensure_ascii=False, default=str) diff --git a/wwwroot/game/game.js b/wwwroot/game/game.js new file mode 100644 index 0000000..2467f92 --- /dev/null +++ b/wwwroot/game/game.js @@ -0,0 +1,347 @@ +/* scense 全屏游戏页交互逻辑(独立 HTML 页内脚本,允许 fetch/setInterval) + * + * 依赖: + * - runtime.js(W-10 运行时引擎 RuntimeEngine)—— 世界初始化/实体状态/脚本调度/暂停恢复 + * - /scense/game/api/*.dspy(W-08 会话与结果) + * + * 功能点:W-08b 开始玩(进入运行态) / W-08c 暂停 / W-08d 继续 / + * W-08e 结束结算 / W-08f 重新开始 / W-08g 分数结果展示 / + * W-08h 分享链接 / W-08i 玩中交互(点选实体触发脚本) / W-08j 退出保存进度 + */ +(function () { + 'use strict'; + var CFG = {gameId: '', sessionId: '', shareToken: '', worldId: ''}; + var session = null; // 当前会话 + var engine = null; // W-10 RuntimeEngine 实例 + var score = 0; + var level = 1; + var startTs = 0; + var clockTimer = null; + var statusBar = null; + + function $id(id) { return document.getElementById(id); } + + /* API 基础路径:由页面 URL 推导(/scense/game/game_play.html -> /scense) */ + function apiBase() { + return window.location.pathname.replace(/\/game\/.*$/, ''); + } + + function setStatus(t) { + if (statusBar) { statusBar.textContent = t; statusBar.className = 'hud-status ' + (t === '运行中' ? 'ok' : ''); } + } + + function api(name, params) { + return fetch(apiBase() + '/game/api/' + name + '.dspy', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(params || {}) + }).then(function (r) { return r.json(); }); + } + + function elapsedMs() { return startTs ? (Date.now() - startTs) : 0; } + + function refreshHud() { + $id('hud_score').textContent = score; + $id('hud_level').textContent = level; + } + + function startClock() { + if (clockTimer) clearInterval(clockTimer); + clockTimer = setInterval(function () { + var s = Math.floor(elapsedMs() / 1000); + var m = Math.floor(s / 60); s = s % 60; + $id('hud_time').textContent = (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s); + }, 500); + } + + /* ---------- W-08b 开始玩:初始化会话 + 进入运行态 ---------- */ + function startGame() { + setStatus('启动中…'); + api('game_start', {game_id: CFG.gameId, session_id: CFG.sessionId || '', world_id: CFG.worldId || ''}) + .then(function (res) { + if (!res || !res.success) { setStatus('启动失败: ' + (res && res.error || '未知错误')); return; } + session = res.data.session; + CFG.sessionId = session.session_id; + CFG.shareToken = session.share_token; + CFG.worldId = session.world_id; + score = session.score || 0; + level = session.level || 1; + startTs = Date.now() - (session.elapsed_ms || 0); + refreshHud(); + startClock(); + initEngine(); // 进入 W-10 运行态 + showPanel('hud'); + setStatus('运行中'); + enableControls(true); + }); + } + + /* ---------- W-10 运行时引擎初始化(runtime.js) ---------- */ + function initEngine() { + if (typeof RuntimeEngine === 'undefined') { + setStatus('运行时引擎未加载(runtime.js),降级独立模式'); + initDegradedEngine(); + return; + } + engine = new RuntimeEngine({ + worldId: CFG.worldId, + canvas: $id('game_canvas'), + onEntityClick: onEntityClick, // W-08i 玩中交互:点选实体 + onEntityState: onEntityState, + onScriptLog: onScriptLog, + onError: onEngineError + }); + engine.initWorld(CFG.worldId).then(function (info) { + setStatus('运行中' + (info && info.degraded ? '(独立模式)' : '')); + engine.start(); + }); + } + + /* 独立模式本地引擎(后端不可用时降级,W-10i) */ + function initDegradedEngine() { + var canvas = $id('game_canvas'); + var ctx = canvas && canvas.getContext('2d'); + var entities = [ + {id: 'hero', x: 60, y: 240, w: 40, h: 40, color: '#2563EB', state: 'idle', label: '英雄'}, + {id: 'chest', x: 400, y: 200, w: 36, h: 36, color: '#D97706', state: 'idle', label: '宝箱'}, + {id: 'slime', x: 260, y: 320, w: 34, h: 34, color: '#16A34A', state: 'idle', label: '史莱姆'} + ]; + engine = { + paused: false, + entities: entities, + start: function () { this.draw(); }, + stop: function () {}, + pause: function () { this.paused = true; }, + resume: function () { this.paused = false; this.draw(); }, + restart: function () { this.paused = false; this.draw(); }, + draw: function () { + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#F9FAFB'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + entities.forEach(function (e) { + ctx.fillStyle = e.color; + ctx.fillRect(e.x, e.y, e.w, e.h); + ctx.fillStyle = '#111827'; + ctx.font = '12px sans-serif'; + ctx.fillText(e.label + (e.state === 'active' ? ' ★' : ''), e.x - 4, e.y - 6); + }); + }, + findEntity: function (x, y) { + for (var i = entities.length - 1; i >= 0; i--) { + var e = entities[i]; + if (x >= e.x && x <= e.x + e.w && y >= e.y && y <= e.y + e.h) return e; + } + return null; + } + }; + if (canvas) canvas.onclick = function (ev) { + if (engine.paused) return; + var r = canvas.getBoundingClientRect(); + var ex = ev.clientX - r.left, ey = ev.clientY - r.top; + var ent = engine.findEntity(ex, ey); + if (ent) onEntityClick(ent); + }; + engine.draw(); + } + + /* ---------- W-08i 玩中交互:点选实体触发脚本逻辑 ---------- */ + function onEntityClick(entity) { + if (!entity) return; + if (engine && engine.paused) { setStatus('已暂停,请先继续'); return; } + // 得分规则:点击宝箱/史莱姆得 10 分并触发实体脚本(state 变化) + score += 10; + refreshHud(); + logLine('点击实体 ' + entity.label + '(' + entity.id + ')→ 触发脚本 action,得分 +10'); + if (engine && engine.dispatchEvent) { + engine.dispatchEvent({type: 'click', entity_id: entity.id, payload: {score: 10}}); + } else if (engine && engine.entities) { + var hit = null; + for (var i = 0; i < engine.entities.length; i++) { + if (engine.entities[i].id === entity.id) { hit = engine.entities[i]; break; } + } + if (hit) { hit.state = 'active'; engine.draw && engine.draw(); } + setTimeout(function () { if (hit) { hit.state = 'idle'; engine.draw && engine.draw(); } }, 800); + } + } + + function onEntityState(e) { + logLine('实体状态: ' + (e && (e.entity_id || e.id)) + ' -> ' + (e && e.state)); + } + + function onScriptLog(msg) { + logLine('脚本: ' + (msg && msg.message || msg || '')); + } + + function onEngineError(err) { + logLine('运行时错误: ' + (err && err.message || err || '') + '(已降级继续)'); + } + + function logLine(text) { + var box = $id('game_log'); + if (!box) return; + var line = document.createElement('div'); + line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + text; + box.appendChild(line); + box.scrollTop = box.scrollHeight; + while (box.children.length > 60) box.removeChild(box.firstChild); + } + + /* ---------- W-08c 暂停 ---------- */ + function pauseGame() { + if (!session) return; + api('game_pause', {session_id: CFG.sessionId}).then(function (res) { + if (!res || !res.success) { setStatus('暂停失败: ' + (res && res.error)); return; } + if (engine && engine.pause) engine.pause(); + if (clockTimer) clearInterval(clockTimer); + setStatus('已暂停'); + $id('btn_pause').style.display = 'none'; + $id('btn_resume').style.display = 'inline-block'; + }); + } + + /* ---------- W-08d 继续 ---------- */ + function resumeGame() { + if (!session) return; + api('game_resume', {session_id: CFG.sessionId}).then(function (res) { + if (!res || !res.success) { setStatus('继续失败: ' + (res && res.error)); return; } + if (engine && engine.resume) engine.resume(); + startClock(); + setStatus('运行中'); + $id('btn_pause').style.display = 'inline-block'; + $id('btn_resume').style.display = 'none'; + }); + } + + /* ---------- W-08e/g 结束结算 ---------- */ + function endGame() { + if (!session) return; + var dur = elapsedMs(); + api('game_end', { + session_id: CFG.sessionId, score: score, duration_ms: dur, level: level, + result_json: JSON.stringify({actions: 'clicks', entities: engine ? (engine.entities || []).length : 0}) + }).then(function (res) { + if (!res || !res.success) { setStatus('结算失败: ' + (res && res.error)); return; } + if (engine && engine.stop) engine.stop(); + if (clockTimer) clearInterval(clockTimer); + var r = res.data.result; + $id('result_score').textContent = r.score; + $id('result_level').textContent = r.level; + $id('result_duration').textContent = Math.round(r.duration_ms / 1000) + ' 秒'; + $id('result_time').textContent = r.created_at; + showPanel('result'); + setStatus('已结束'); + enableControls(false); + }); + } + + /* ---------- W-08f 重新开始 ---------- */ + function restartGame() { + if (!session) return; + api('game_restart', {session_id: CFG.sessionId}).then(function (res) { + if (!res || !res.success) { setStatus('重开失败: ' + (res && res.error)); return; } + score = 0; level = 1; startTs = Date.now(); + refreshHud(); + if (engine && engine.restart) engine.restart(); else if (engine && engine.start) engine.start(); + startClock(); + showPanel('hud'); + enableControls(true); + $id('btn_pause').style.display = 'inline-block'; + $id('btn_resume').style.display = 'none'; + setStatus('运行中'); + }); + } + + /* ---------- W-08h 分享链接 ---------- */ + function shareGame() { + if (!session) return; + api('game_share', {action: 'generate', session_id: CFG.sessionId}).then(function (res) { + if (!res || !res.success) { setStatus('分享失败: ' + (res && res.error)); return; } + var url = location.origin + apiBase() + '/game/game_play.html?share_token=' + res.data.share_token; + $id('share_url').value = url; + $id('share_box').style.display = 'block'; + logLine('已生成分享链接'); + }); + } + + /* ---------- W-08j 退出保存进度 ---------- */ + function saveProgress() { + if (!session) return; + api('game_save', { + session_id: CFG.sessionId, progress_json: JSON.stringify({entities: (engine && engine.entities || []).length}), + score: score, level: level, elapsed_ms: elapsedMs() + }).then(function (res) { + if (res && res.success) { logLine('进度已保存'); } + }); + } + + function exitGame() { + saveProgress(); + setTimeout(function () { window.close(); }, 400); + } + + /* ---------- 分享链接进入(W-08h 解析 token) ---------- */ + function enterByShareToken(token) { + api('game_share', {action: 'parse', token: token}).then(function (res) { + if (!res || !res.success) { setStatus('分享链接无效: ' + (res && res.error)); return; } + CFG.gameId = res.data.game_id; + CFG.sessionId = res.data.session_id; + CFG.shareToken = token; + CFG.worldId = res.data.world_id || ''; + $id('page_title').textContent = '分享进入 · ' + res.data.game_name; + setStatus('已通过分享链接进入'); + startGame(); // 复用会话进入运行态 + }); + } + + /* ---------- UI 辅助 ---------- */ + function showPanel(name) { + ['hud', 'result'].forEach(function (p) { + $id('panel_' + p).style.display = (p === name) ? 'block' : 'none'; + }); + } + + function enableControls(on) { + ['btn_pause', 'btn_resume', 'btn_end', 'btn_restart', 'btn_share', 'btn_save', 'btn_exit'] + .forEach(function (id) { var b = $id(id); if (b) b.disabled = !on; }); + } + + function bindEvents() { + $id('btn_pause').onclick = pauseGame; + $id('btn_resume').onclick = resumeGame; + $id('btn_end').onclick = endGame; + $id('btn_restart').onclick = restartGame; + $id('btn_share').onclick = shareGame; + $id('btn_save').onclick = saveProgress; + $id('btn_exit').onclick = exitGame; + $id('btn_copy').onclick = function () { + var u = $id('share_url'); + u.select(); + try { document.execCommand('copy'); setStatus('已复制分享链接'); } catch (e) { setStatus('复制失败,请手动复制'); } + }; + $id('btn_result_again').onclick = restartGame; + $id('btn_result_back').onclick = exitGame; + window.addEventListener('beforeunload', function () { saveProgress(); }); + } + + /* ---------- 启动 ---------- */ + function boot() { + var qs = new URLSearchParams(location.search); + CFG.gameId = qs.get('game_id') || ''; + CFG.sessionId = qs.get('session_id') || ''; + statusBar = $id('hud_status'); + bindEvents(); + showPanel('hud'); + enableControls(false); + var token = qs.get('share_token'); + if (token) { enterByShareToken(token); return; } + if (CFG.gameId) { startGame(); return; } + setStatus('缺少 game_id / share_token'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/wwwroot/game/game_list.js b/wwwroot/game/game_list.js new file mode 100644 index 0000000..70d81f5 --- /dev/null +++ b/wwwroot/game/game_list.js @@ -0,0 +1,75 @@ +/* scense 游戏列表入口页渲染(bricks 页面内 Html 脚本区调用) */ +window.scenseGameList = (function () { + function api(url, params) { + return fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(params || {}) + }).then(function (r) { return r.json(); }); + } + + function base() { + return window.location.pathname.replace(/\/game\/.*$/, ''); + } + + function statusText(s) { + var map = {created: '已创建', playing: '进行中', paused: '已暂停', ended: '已结束'}; + return map[s] || s; + } + + function renderSessions(sessions) { + var box = document.getElementById('scense_session_list'); + if (!box) return; + if (!sessions || !sessions.length) { box.innerHTML = '
暂无历史会话
'; return; } + var html = ''; + sessions.forEach(function (s) { + html += '
' + + '
' + s.game_name + ' ' + + (s.score || 0) + ' 分 / Lv.' + (s.level || 1) + '
' + + '
' + + statusText(s.status) + '' + + '
'; + }); + box.innerHTML = html; + } + + function renderResults(results) { + var box = document.getElementById('scense_result_list'); + if (!box) return; + if (!results || !results.length) { box.innerHTML = '
暂无结算记录
'; return; } + var html = ''; + results.forEach(function (r) { + html += '
' + + '' + r.game_id + ' ' + r.created_at + '' + + '' + r.score + ' 分 · Lv.' + r.level + ' · ' + + Math.round(r.duration_ms / 1000) + 's
'; + }); + box.innerHTML = html; + } + + function load() { + api(base() + '/game/api/game_list.dspy', {}).then(function (res) { + if (res && res.success && res.data) { + renderSessions(res.data.sessions); + renderResults(res.data.results); + } + }); + } + + function startGame(gameId, sessionId) { + var url = base() + '/game/game_play.html?game_id=' + encodeURIComponent(gameId || ''); + if (sessionId) url += '&session_id=' + encodeURIComponent(sessionId); + window.open(url, '_blank'); + } + + return {api: api, load: load, startGame: startGame, renderSessions: renderSessions, renderResults: renderResults}; +})(); + +(function () { + if (window.bricks && window.bricks.app) { + setTimeout(function () { window.scenseGameList && window.scenseGameList.load(); }, 300); + } +})(); diff --git a/wwwroot/game/game_play.html b/wwwroot/game/game_play.html new file mode 100644 index 0000000..3b9b535 --- /dev/null +++ b/wwwroot/game/game_play.html @@ -0,0 +1,87 @@ + + + + + +元景 · 游戏展示 + + + +
+
+ 🎮 世界探索 Demo + 分数 0 + 关卡 Lv.1 + 时长 00:00 + 加载中… +
+ + + + + + + +
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
+

🏁 游戏结算

+
最终得分0
+
到达关卡1
+
游戏时长0 秒
+
结算时间-
+
+ + +
+
+
+ + + + + diff --git a/wwwroot/game/index.ui b/wwwroot/game/index.ui new file mode 100644 index 0000000..ffe1a26 --- /dev/null +++ b/wwwroot/game/index.ui @@ -0,0 +1,24 @@ +{"widgettype": "VBox", "options": {"width": "100%", "height": "100%", "padding": "16px", "backgroundColor": "#F5F7FA"}, "subwidgets": [ + {"widgettype": "Text", "options": {"label": "元景 · 游戏展示", "fontSize": "24px", "fontWeight": "bold", "color": "#1F2937"}}, + {"widgettype": "Html", "options": {"html": "
点击「开始玩」进入全屏游戏,支持暂停/继续/结束结算/重新开始/分享/保存进度
"}}, + {"widgettype": "ResponsableBox", "options": {"gap": "14px", "minWidth": "260px"}, "subwidgets": [ + {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "18px", "borderRadius": "8px", "cursor": "pointer", "boxShadow": "0 1px 3px rgba(0,0,0,0.1)"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "script": "window.scenseGameList && scenseGameList.startGame('world_demo')"}], + "subwidgets": [ + {"widgettype": "Text", "options": {"label": "🎮 世界探索 Demo", "fontSize": "18px", "fontWeight": "bold"}}, + {"widgettype": "Text", "options": {"label": "点击实体触发脚本逻辑的世界演示游戏(依赖 W-10 运行时)", "color": "#6B7280", "fontSize": "13px", "marginTop": "6px"}}, + {"widgettype": "Text", "options": {"label": "▶ 开始玩", "color": "#2563EB", "fontSize": "14px", "fontWeight": "bold", "marginTop": "10px"}} + ]}, + {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "18px", "borderRadius": "8px", "cursor": "pointer", "boxShadow": "0 1px 3px rgba(0,0,0,0.1)"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "script": "window.scenseGameList && scenseGameList.startGame('click_quest')"}], + "subwidgets": [ + {"widgettype": "Text", "options": {"label": "⚡ 点击冒险", "fontSize": "18px", "fontWeight": "bold"}}, + {"widgettype": "Text", "options": {"label": "点选移动实体触发脚本逻辑的冒险小游戏", "color": "#6B7280", "fontSize": "13px", "marginTop": "6px"}}, + {"widgettype": "Text", "options": {"label": "▶ 开始玩", "color": "#2563EB", "fontSize": "14px", "fontWeight": "bold", "marginTop": "10px"}} + ]} + ]}, + {"widgettype": "Text", "options": {"label": "历史会话", "fontSize": "17px", "fontWeight": "bold", "marginTop": "22px"}}, + {"widgettype": "Html", "id": "scense_session_box", "options": {"html": "
加载中…
"}}, + {"widgettype": "Text", "options": {"label": "历史结算", "fontSize": "17px", "fontWeight": "bold", "marginTop": "18px"}}, + {"widgettype": "Html", "id": "scense_result_box", "options": {"html": "
加载中…
"}} +]}