approve: 元景 W-08 游戏展示模块开发
This commit is contained in:
parent
873a83ab44
commit
f337333432
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
*.pyc
|
||||
__pycache__/
|
||||
build/
|
||||
*.egg-info/
|
||||
dist/
|
||||
mysql.ddl.sql
|
||||
wwwroot/game_session_list/
|
||||
wwwroot/game_result_list/
|
||||
*.swp
|
||||
*.swo
|
||||
47
README.md
Normal file
47
README.md
Normal file
@ -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
|
||||
173
build.sh
173
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" <<EOF
|
||||
[Unit]
|
||||
Description=scense app (yuanjing)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$(whoami)
|
||||
WorkingDirectory=$WORKDIR
|
||||
EnvironmentFile=$ENV_FILE
|
||||
ExecStart=$VENV_DIR/bin/python $WORKDIR/app/scense.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo cp "$WORKDIR/scense.service" /etc/systemd/system/scense.service 2>/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"
|
||||
|
||||
28
docs/work-log-2026-XX-XX.md
Normal file
28
docs/work-log-2026-XX-XX.md
Normal file
@ -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 审核;部署/浏览器验收由下游角色执行。
|
||||
14
init/data.json
Normal file
14
init/data.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"appcodes": [
|
||||
{
|
||||
"parentid": "game_session_status",
|
||||
"parentname": "游戏会话状态",
|
||||
"items": [
|
||||
{"k": "created", "v": "已创建"},
|
||||
{"k": "playing", "v": "进行中"},
|
||||
{"k": "paused", "v": "已暂停"},
|
||||
{"k": "ended", "v": "已结束"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
json/game_result_list.json
Normal file
1
json/game_result_list.json
Normal file
@ -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}}
|
||||
1
json/game_session_list.json
Normal file
1
json/game_session_list.json
Normal file
@ -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')}}"}}
|
||||
1
models/game_result.json
Normal file
1
models/game_result.json
Normal file
@ -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":[]}
|
||||
1
models/game_session.json
Normal file
1
models/game_session.json
Normal file
@ -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"}}]}
|
||||
14
pyproject.toml
Normal file
14
pyproject.toml
Normal file
@ -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*"]
|
||||
8
scense/__init__.py
Normal file
8
scense/__init__.py
Normal file
@ -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,
|
||||
)
|
||||
276
scense/core.py
Normal file
276
scense/core.py
Normal file
@ -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}
|
||||
32
scense/init.py
Normal file
32
scense/init.py
Normal file
@ -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
|
||||
72
scripts/load_path.py
Normal file
72
scripts/load_path.py
Normal file
@ -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()
|
||||
52
skill/SKILL.md
Normal file
52
skill/SKILL.md
Normal file
@ -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 权限自动服务)。
|
||||
42
sql/game_ddl.sql
Normal file
42
sql/game_ddl.sql
Normal file
@ -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='游戏结果';
|
||||
24
wwwroot/game/api/game_end.dspy
Normal file
24
wwwroot/game/api/game_end.dspy
Normal file
@ -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)
|
||||
13
wwwroot/game/api/game_list.dspy
Normal file
13
wwwroot/game/api/game_list.dspy
Normal file
@ -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)
|
||||
19
wwwroot/game/api/game_pause.dspy
Normal file
19
wwwroot/game/api/game_pause.dspy
Normal file
@ -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)
|
||||
19
wwwroot/game/api/game_restart.dspy
Normal file
19
wwwroot/game/api/game_restart.dspy
Normal file
@ -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)
|
||||
19
wwwroot/game/api/game_resume.dspy
Normal file
19
wwwroot/game/api/game_resume.dspy
Normal file
@ -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)
|
||||
24
wwwroot/game/api/game_save.dspy
Normal file
24
wwwroot/game/api/game_save.dspy
Normal file
@ -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)
|
||||
24
wwwroot/game/api/game_share.dspy
Normal file
24
wwwroot/game/api/game_share.dspy
Normal file
@ -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)
|
||||
21
wwwroot/game/api/game_start.dspy
Normal file
21
wwwroot/game/api/game_start.dspy
Normal file
@ -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)
|
||||
347
wwwroot/game/game.js
Normal file
347
wwwroot/game/game.js
Normal file
@ -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();
|
||||
}
|
||||
})();
|
||||
75
wwwroot/game/game_list.js
Normal file
75
wwwroot/game/game_list.js
Normal file
@ -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 = '<div style="color:#9CA3AF;">暂无历史会话</div>'; return; }
|
||||
var html = '';
|
||||
sessions.forEach(function (s) {
|
||||
html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;' +
|
||||
'background:#FFF;border-radius:6px;margin-bottom:8px;border:1px solid #E5E7EB;">' +
|
||||
'<div><b>' + s.game_name + '</b> <span style="color:#6B7280;font-size:12px;">' +
|
||||
(s.score || 0) + ' 分 / Lv.' + (s.level || 1) + '</span></div>' +
|
||||
'<div><span style="color:' + (s.status === 'playing' ? '#16A34A' : '#6B7280') + ';margin-right:10px;">' +
|
||||
statusText(s.status) + '</span>' +
|
||||
'<button onclick="scenseGameList.startGame(\'' + s.game_id + '\',\'' + s.session_id + '\')" ' +
|
||||
'style="padding:4px 12px;background:#2563EB;color:#FFF;border:none;border-radius:4px;cursor:pointer;">继续</button></div></div>';
|
||||
});
|
||||
box.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
var box = document.getElementById('scense_result_list');
|
||||
if (!box) return;
|
||||
if (!results || !results.length) { box.innerHTML = '<div style="color:#9CA3AF;">暂无结算记录</div>'; return; }
|
||||
var html = '';
|
||||
results.forEach(function (r) {
|
||||
html += '<div style="display:flex;justify-content:space-between;padding:8px 12px;background:#FFF;' +
|
||||
'border-radius:6px;margin-bottom:6px;border:1px solid #E5E7EB;">' +
|
||||
'<span><b>' + r.game_id + '</b> <span style="color:#6B7280;font-size:12px;">' + r.created_at + '</span></span>' +
|
||||
'<span style="color:#2563EB;"><b>' + r.score + '</b> 分 · Lv.' + r.level + ' · ' +
|
||||
Math.round(r.duration_ms / 1000) + 's</span></div>';
|
||||
});
|
||||
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);
|
||||
}
|
||||
})();
|
||||
87
wwwroot/game/game_play.html
Normal file
87
wwwroot/game/game_play.html
Normal file
@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>元景 · 游戏展示</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; background: #0F172A; color: #E2E8F0; }
|
||||
#game_wrap { position: fixed; inset: 0; display: flex; flex-direction: column; }
|
||||
#hud_bar { height: 48px; background: #1E293B; display: flex; align-items: center; padding: 0 14px; gap: 12px; border-bottom: 1px solid #334155; flex-shrink: 0; }
|
||||
#hud_bar .title { font-weight: bold; font-size: 15px; }
|
||||
.hud-item { font-size: 13px; color: #94A3B8; }
|
||||
.hud-item b { color: #FBBF24; font-size: 15px; }
|
||||
#hud_status { margin-left: auto; font-size: 13px; padding: 4px 10px; border-radius: 12px; background: #334155; color: #CBD5E1; }
|
||||
#hud_status.ok { background: #14532D; color: #4ADE80; }
|
||||
#hud_btns { display: flex; gap: 8px; }
|
||||
#hud_btns button { padding: 5px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; background: #2563EB; color: #FFF; }
|
||||
#hud_btns button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
#hud_btns button.warn { background: #DC2626; }
|
||||
#hud_btns button.ghost { background: #475569; }
|
||||
#stage { flex: 1; position: relative; display: flex; }
|
||||
#game_canvas { flex: 1; background: #F8FAFC; cursor: pointer; }
|
||||
#side_panel { width: 300px; background: #1E293B; border-left: 1px solid #334155; display: flex; flex-direction: column; padding: 10px; gap: 8px; }
|
||||
#game_log { flex: 1; overflow-y: auto; font-size: 12px; color: #94A3B8; line-height: 1.7; background: #0F172A; border-radius: 6px; padding: 8px; }
|
||||
#share_box { display: none; }
|
||||
#share_box input { width: 100%; padding: 6px; font-size: 12px; border-radius: 4px; border: 1px solid #475569; background: #0F172A; color: #E2E8F0; }
|
||||
#share_box button { margin-top: 4px; padding: 4px 10px; font-size: 12px; border: none; border-radius: 4px; background: #059669; color: #FFF; cursor: pointer; }
|
||||
#panel_result { position: absolute; inset: 0; display: none; background: rgba(15,23,42,.88); z-index: 10; }
|
||||
#panel_hud { display: none; }
|
||||
.result-card { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 400px; background: #1E293B; border: 1px solid #334155; border-radius: 12px; padding: 28px; text-align: center; }
|
||||
.result-card h2 { margin-bottom: 18px; color: #FBBF24; }
|
||||
.result-card .row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px dashed #334155; font-size: 14px; }
|
||||
.result-card .row b { color: #F8FAFC; }
|
||||
.result-card .btns { margin-top: 20px; display: flex; gap: 10px; justify-content: center; }
|
||||
.result-card .btns button { padding: 8px 22px; border: none; border-radius: 6px; cursor: pointer; background: #2563EB; color: #FFF; font-size: 14px; }
|
||||
.result-card .btns button.ghost { background: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game_wrap">
|
||||
<div id="hud_bar">
|
||||
<span class="title" id="page_title">🎮 世界探索 Demo</span>
|
||||
<span class="hud-item">分数 <b id="hud_score">0</b></span>
|
||||
<span class="hud-item">关卡 Lv.<b id="hud_level">1</b></span>
|
||||
<span class="hud-item">时长 <b id="hud_time">00:00</b></span>
|
||||
<span id="hud_status">加载中…</span>
|
||||
<div id="hud_btns">
|
||||
<button id="btn_pause" class="ghost" disabled>⏸ 暂停</button>
|
||||
<button id="btn_resume" class="ghost" style="display:none" disabled>▶ 继续</button>
|
||||
<button id="btn_share" class="ghost" disabled>🔗 分享</button>
|
||||
<button id="btn_save" class="ghost" disabled>💾 保存</button>
|
||||
<button id="btn_end" class="warn" disabled>🏁 结束</button>
|
||||
<button id="btn_restart" class="ghost" disabled>🔄 重开</button>
|
||||
<button id="btn_exit" class="ghost" disabled>✖ 退出</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stage">
|
||||
<canvas id="game_canvas" width="900" height="560"></canvas>
|
||||
<div id="side_panel">
|
||||
<div id="share_box">
|
||||
<input id="share_url" readonly>
|
||||
<button id="btn_copy">复制链接</button>
|
||||
</div>
|
||||
<div id="game_log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="panel_hud"></div>
|
||||
<div id="panel_result">
|
||||
<div class="result-card">
|
||||
<h2>🏁 游戏结算</h2>
|
||||
<div class="row"><span>最终得分</span><b id="result_score">0</b></div>
|
||||
<div class="row"><span>到达关卡</span><b id="result_level">1</b></div>
|
||||
<div class="row"><span>游戏时长</span><b id="result_duration">0 秒</b></div>
|
||||
<div class="row"><span>结算时间</span><b id="result_time">-</b></div>
|
||||
<div class="btns">
|
||||
<button id="btn_result_again">🔄 再来一局</button>
|
||||
<button id="btn_result_back" class="ghost">✖ 退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- W-10 运行时引擎(runtime 模块,any 权限自动服务) -->
|
||||
<script src="/runtime/runtime.js" defer></script>
|
||||
<script src="game.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
24
wwwroot/game/index.ui
Normal file
24
wwwroot/game/index.ui
Normal file
@ -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": "<div style=\"color:#6B7280;margin:4px 0 12px 0;\">点击「开始玩」进入全屏游戏,支持暂停/继续/结束结算/重新开始/分享/保存进度</div>"}},
|
||||
{"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": "<div id=\"scense_session_list\">加载中…</div>"}},
|
||||
{"widgettype": "Text", "options": {"label": "历史结算", "fontSize": "17px", "fontWeight": "bold", "marginTop": "18px"}},
|
||||
{"widgettype": "Html", "id": "scense_result_box", "options": {"html": "<div id=\"scense_result_list\">加载中…</div>"}}
|
||||
]}
|
||||
Loading…
x
Reference in New Issue
Block a user