commit 82f007121a7010cb3ef638589efb26d472cfb56d Author: pipeline-agent Date: Sat Aug 29 21:20:39 2026 +0800 feat: 元景 W-08 游戏展示模块(自 scense 仓库拆分独立) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d091bac --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# scense_game + +唤景平台(scense)W-08 游戏展示模块:游戏列表/开始玩/暂停继续/结束结算/重新开始/分数展示/分享链接/玩中交互/退出保存。 +部署方式:git clone 进 pkgs/scense_game,由宿主 build.sh 统一安装。 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/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_end.dspy b/wwwroot/game/game_end.dspy new file mode 100644 index 0000000..00cbb71 --- /dev/null +++ b/wwwroot/game/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/game_list.dspy b/wwwroot/game/game_list.dspy new file mode 100644 index 0000000..1397987 --- /dev/null +++ b/wwwroot/game/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/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_pause.dspy b/wwwroot/game/game_pause.dspy new file mode 100644 index 0000000..bd15c05 --- /dev/null +++ b/wwwroot/game/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/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/game_restart.dspy b/wwwroot/game/game_restart.dspy new file mode 100644 index 0000000..e305f98 --- /dev/null +++ b/wwwroot/game/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/game_resume.dspy b/wwwroot/game/game_resume.dspy new file mode 100644 index 0000000..ffcd793 --- /dev/null +++ b/wwwroot/game/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/game_save.dspy b/wwwroot/game/game_save.dspy new file mode 100644 index 0000000..fa91386 --- /dev/null +++ b/wwwroot/game/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/game_share.dspy b/wwwroot/game/game_share.dspy new file mode 100644 index 0000000..1c837d8 --- /dev/null +++ b/wwwroot/game/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/game_start.dspy b/wwwroot/game/game_start.dspy new file mode 100644 index 0000000..a9776b0 --- /dev/null +++ b/wwwroot/game/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/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": "
加载中…
"}} +]}