feat(工作台v3): 纯bricks声明式工作台——Tree懒加载对象树+CodeEditor编程+PopupWindow建游戏/场景/实体;删除裸html+js版

This commit is contained in:
pipeline-agent 2026-08-30 20:02:57 +08:00
parent 3847a1ec65
commit 833ffdea03
8 changed files with 466 additions and 262 deletions

View File

@ -3,6 +3,9 @@
from scense_game.aggregation import ( from scense_game.aggregation import (
game_create, game_workbench, scene_add, entity_add, game_create, game_workbench, scene_add, entity_add,
) )
from scense_game.workbench_api import (
workbench_tree, workbench_load_script, workbench_save_script,
)
from scense_game.core import ( from scense_game.core import (
game_list_games, game_list_sessions, game_list_results, game_list_games, game_list_sessions, game_list_results,
game_start_session, game_pause_session, game_resume_session, game_start_session, game_pause_session, game_resume_session,
@ -21,6 +24,9 @@ def load_scense_game(env=None):
env.game_workbench = game_workbench env.game_workbench = game_workbench
env.scene_add = scene_add env.scene_add = scene_add
env.entity_add = entity_add env.entity_add = entity_add
env.workbench_tree = workbench_tree
env.workbench_load_script = workbench_load_script
env.workbench_save_script = workbench_save_script
env.game_list_games = game_list_games env.game_list_games = game_list_games
env.game_list_sessions = game_list_sessions env.game_list_sessions = game_list_sessions
env.game_list_results = game_list_results env.game_list_results = game_list_results

View File

@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""域模型 v2 聚合 API对象树 + 保存脚本(声明式工作台后端)。"""
import json
from appPublic.log import info, error, exception
from sqlor.dbpools import DBPools
def _get_dbname():
from ahserver.serverenv import ServerEnv
return ServerEnv().get_module_dbname('scense_game')
async def workbench_tree(ns):
"""对象树Tree 懒加载。id 空=根(全部游戏+其世界);
id=game:xx世界world:xx场景+世界级实体scene:xx实体"""
gid = (ns or {}).get('game_id') or ''
nid = (ns or {}).get('id') or ''
nodes = []
dbname = _get_dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
if not nid:
rows = await sor.sqlExe(
'select id, name from game order by created_at', {})
for r in rows:
nodes.append({'id': 'game:' + r.id, 'parentid': '',
'text': '🎮 ' + r.name, 'is_leaf': 0,
'obj_type': 'game', 'real_id': r.id})
if gid:
w = await sor.sqlExe(
'select id, name from world where game_id=${gid}$', {'gid': gid})
for r in w:
nodes.append({'id': 'world:' + r.id, 'parentid': '',
'text': '🌍 ' + r.name, 'is_leaf': 0,
'obj_type': 'world', 'real_id': r.id, 'game_id': gid})
elif nid.startswith('game:'):
g_real = nid.split(':', 1)[1]
w = await sor.sqlExe(
'select id, name from world where game_id=${gid}$', {'gid': g_real})
for r in w:
nodes.append({'id': 'world:' + r.id, 'parentid': nid,
'text': '🌍 ' + r.name, 'is_leaf': 0,
'obj_type': 'world', 'real_id': r.id, 'game_id': g_real})
elif nid.startswith('world:'):
w_real = nid.split(':', 1)[1]
sc = await sor.sqlExe(
'select id, name from scene where world_id=${wid}$', {'wid': w_real})
for r in sc:
nodes.append({'id': 'scene:' + r.id, 'parentid': nid,
'text': '🎬 ' + r.name, 'is_leaf': 0,
'obj_type': 'scene', 'real_id': r.id})
ent = await sor.sqlExe(
"select id, name from entity where world_id=${wid}$ and (scene_id is null or scene_id='')",
{'wid': w_real})
for r in ent:
nodes.append({'id': 'entity:' + r.id, 'parentid': nid,
'text': '📦 ' + r.name + '(世界级)', 'is_leaf': 1,
'obj_type': 'entity', 'real_id': r.id})
elif nid.startswith('scene:'):
s_real = nid.split(':', 1)[1]
ent = await sor.sqlExe(
'select id, name from entity where scene_id=${sid}$', {'sid': s_real})
for r in ent:
nodes.append({'id': 'entity:' + r.id, 'parentid': nid,
'text': '📦 ' + r.name, 'is_leaf': 1,
'obj_type': 'entity', 'real_id': r.id})
return {'success': True, 'data': nodes}
except Exception as e:
exception('workbench_tree: %s' % e)
return {'success': False, 'message': '对象树查询失败: %s' % e, 'data': []}
async def workbench_load_script(ns):
"""读当前对象+事件的已有脚本。ns: {obj_type, obj_id, game_id, trigger_event}"""
obj_type = (ns or {}).get('obj_type') or ''
obj_id = (ns or {}).get('obj_id') or ''
evt = (ns or {}).get('trigger_event') or ''
if not obj_id or not evt:
return {'success': False, 'message': 'obj_id/trigger_event 必填'}
dbname = _get_dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
col = {'game': 'game_id', 'world': 'world_id',
'scene': 'scene_id', 'entity': 'entity_id'}.get(obj_type)
if not col:
return {'success': False, 'message': 'obj_type 非法'}
rows = await sor.sqlExe(
'select id, name, content, world_id, entity_id from script_engine '
'where ' + col + '=${oid}$ and trigger_event=${evt}$ limit 1',
{'oid': obj_id, 'evt': evt})
if rows:
return {'success': True, 'data': {'id': rows[0].id, 'name': rows[0].name,
'content': rows[0].content or ''}}
return {'success': True, 'data': None}
except Exception as e:
exception('workbench_load_script: %s' % e)
return {'success': False, 'message': '读取脚本失败: %s' % e}
async def workbench_save_script(ns):
"""保存脚本存在则更新。ns: {obj_type, obj_id, game_id, trigger_event, content}"""
from appPublic.uniqueID import getID
obj_type = (ns or {}).get('obj_type') or ''
obj_id = (ns or {}).get('obj_id') or ''
gid = (ns or {}).get('game_id') or ''
evt = (ns or {}).get('trigger_event') or ''
content = (ns or {}).get('content') or ''
if not obj_id or not evt:
return {'success': False, 'message': 'obj_id/trigger_event 必填'}
dbname = _get_dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
# 解析对象所属世界world_id 为 NOT NULL 约束)
wid = ''
if obj_type == 'world':
wid = obj_id
elif obj_type == 'scene':
rows = await sor.sqlExe(
'select world_id from scene where id=${oid}$', {'oid': obj_id})
wid = rows[0].world_id if rows else ''
elif obj_type == 'entity':
rows = await sor.sqlExe(
'select world_id from entity where id=${oid}$', {'oid': obj_id})
wid = rows[0].world_id if rows else ''
elif obj_type == 'game':
rows = await sor.sqlExe(
'select id from world where game_id=${gid}$', {'gid': obj_id})
wid = rows[0].id if rows else ''
if not gid and wid:
rows = await sor.sqlExe(
'select game_id from world where id=${wid}$', {'wid': wid})
gid = (rows[0].game_id if rows and rows[0].game_id else '') or gid
if not wid:
return {'success': False, 'message': '无法解析对象所属世界'}
col = {'game': 'game_id', 'world': 'world_id',
'scene': 'scene_id', 'entity': 'entity_id'}[obj_type]
old = await sor.sqlExe(
'select id from script_engine where ' + col + '=${oid}$ and trigger_event=${evt}$ limit 1',
{'oid': obj_id, 'evt': evt})
import time
now = time.strftime('%Y-%m-%d %H:%M:%S')
if old:
await sor.sqlExe(
'update script_engine set content=${content}$, updated_at=${now}$ where id=${sid}$',
{'content': content, 'now': now, 'sid': old[0].id})
sid = old[0].id
else:
sid = getID()
fields = {'id': sid, 'name': obj_type + '_' + obj_id[:8] + '_' + evt,
'code': 'SC_' + obj_id[:8] + '_' + evt,
'script_type': '0', 'trigger_event': evt, 'content': content,
'status': 'active', 'world_id': wid, 'game_id': gid,
'created_at': now, 'updated_at': now}
if col != 'world_id':
fields[col] = obj_id
await sor.C('script_engine', fields)
return {'success': True, 'data': {'script_id': sid}}
except Exception as e:
exception('workbench_save_script: %s' % e)
return {'success': False, 'message': '保存脚本失败: %s' % e}

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# scense_game/workbench/api/load_script.dspy —— 读当前对象+事件的脚本
r = await workbench_load_script(dict(params_kw))
return json.dumps(r, ensure_ascii=False, default=str)

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# scense_game/workbench/api/save_script.dspy —— 保存脚本
r = await workbench_save_script(dict(params_kw))
return json.dumps(r, ensure_ascii=False, default=str)

View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# scense_game/workbench/api/tree.dspy —— 对象树懒加载Tree dataurl
r = await workbench_tree(dict(params_kw))
if not r.get('success'):
return []
return r.get('data') or []

View File

@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>元景 · 游戏工作台</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #F3F4F6; color: #1F2937; height: 100vh; display: flex; flex-direction: column; }
header { background: #111827; color: #F9FAFB; padding: 10px 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
header h1 { font-size: 16px; margin-right: 8px; }
header input[type=text] { padding: 6px 10px; border-radius: 4px; border: 1px solid #374151; background: #1F2937; color: #F9FAFB; width: 220px; }
header label { font-size: 13px; display: flex; gap: 6px; align-items: center; }
header button { padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
.btn-primary { background: #2563EB; color: #fff; }
.btn-green { background: #16A34A; color: #fff; }
.btn-gray { background: #4B5563; color: #fff; }
main { flex: 1; display: flex; gap: 0; overflow: hidden; }
#tree { width: 280px; background: #fff; border-right: 1px solid #E5E7EB; overflow-y: auto; padding: 10px; }
#tree h3 { font-size: 13px; color: #6B7280; margin: 8px 0 4px; }
.node { padding: 6px 8px; border-radius: 4px; cursor: pointer; font-size: 13px; display: flex; justify-content: space-between; align-items: center; }
.node:hover { background: #EFF6FF; }
.node.sel { background: #DBEAFE; font-weight: 600; }
.node .add { color: #2563EB; font-weight: 700; padding: 0 6px; }
#editor { flex: 1; display: flex; flex-direction: column; padding: 12px; gap: 10px; overflow: hidden; }
#editor .bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
#editor select, #editor button { padding: 6px 10px; border-radius: 4px; border: 1px solid #D1D5DB; background: #fff; font-size: 13px; cursor: pointer; }
#editor textarea { flex: 1; border: 1px solid #D1D5DB; border-radius: 6px; padding: 12px; font-family: "JetBrains Mono", Consolas, monospace; font-size: 13px; resize: none; background: #1A1D26; color: #D1D5DB; }
#msg { font-size: 13px; min-height: 20px; }
.ok { color: #16A34A; } .err { color: #DC2626; }
</style>
</head>
<body>
<header>
<h1>🎮 游戏工作台</h1>
<select id="game_sel" style="padding:6px 10px;border-radius:4px;border:1px solid #374151;background:#1F2937;color:#F9FAFB"></select>
<button class="btn-gray" onclick="wb.newGame()"> 新建游戏</button>
<input type="text" id="game_name" placeholder="游戏名称">
<label><input type="checkbox" id="game_shared"> 世界共享</label>
<button class="btn-primary" onclick="wb.saveGame()">保存游戏</button>
<button class="btn-green" onclick="wb.test()">▶ 测试(新窗口运行 demo</button>
<button class="btn-gray" onclick="wb.reload()">刷新</button>
</header>
<main>
<div id="tree">
<h3>对象树(世界 1 · 场景 N · 实体 N</h3>
<div id="tree_body">加载中…</div>
</div>
<div id="editor">
<div class="bar">
<span id="cur_obj" style="font-weight:600;">未选择对象</span>
<select id="event_sel">
<option value="event_start">事件:开始</option>
<option value="event_click">事件:点击</option>
<option value="event_timer">事件:定时</option>
<option value="event_collision">事件:碰撞</option>
</select>
<button class="btn-primary" onclick="wb.saveScript()">💾 保存脚本</button>
<button onclick="wb.validate()">校验</button>
</div>
<textarea id="code" placeholder="为当前对象的所选事件编写逻辑脚本Python…&#10;&#10;可用上下文entity / scene / world / game / api事件冒泡实体→场景→世界→游戏"></textarea>
<div id="msg"></div>
</div>
</main>
<script src="workbench.js?v=20260830c"></script>
</body>
</html>

View File

@ -1,196 +0,0 @@
/* 元景游戏工作台 v2game 聚合根单页编辑器(内联输入,无 prompt */
(function () {
function api(path, params) {
return fetch(path, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params || {})
}).then(function (r) { return r.json(); });
}
function base() { return '/scense_game'; }
var state = { game: null, data: null, sel: null, games: [], cur_script: null, inline: null };
function msg(t, ok) {
var el = document.getElementById('msg');
el.textContent = t;
el.className = ok ? 'ok' : 'err';
}
function newGame() {
state.game = null; state.data = null; state.sel = null; state.cur_script = null;
document.getElementById('game_name').value = '';
document.getElementById('game_sel').value = '';
renderTree(null);
document.getElementById('cur_obj').textContent = '未选择对象';
document.getElementById('code').value = '';
msg('填写名称后点保存游戏(将自动创建 1:1 世界)', true);
}
function saveGame() {
var name = document.getElementById('game_name').value.trim();
if (state.game) { msg('当前游戏: ' + state.game.name + '' + state.game.id + '', true); return; }
if (!name) { msg('先填游戏名称', false); return; }
api(base() + '/game/api/game_create.dspy', {name: name,
description: document.getElementById('game_shared').checked ? 'shared' : 'independent'})
.then(function (r) {
if (r.success) { msg('游戏创建成功,世界已自动建立', true); reload(r.data.game_id); }
else msg(r.message || '创建失败', false);
});
}
function switchGame(gid) {
if (!gid) return;
state.game = null; state.sel = null;
reload(gid);
}
function reload(forceGid) {
api(base() + '/game/api/game_list.dspy', {}).then(function (r) {
var games = (r.data && r.data.games) || r || [];
if (!Array.isArray(games)) games = [];
state.games = games;
var sel = document.getElementById('game_sel');
sel.innerHTML = '<option value="">— 选择游戏 —</option>' +
games.map(function (g) {
var gid = g.game_id || g.id;
return '<option value="' + gid + '">' + (g.game_name || g.name) + '</option>';
}).join('');
var gid = forceGid || (state.game && state.game.id) || (games[0] && (games[0].game_id || games[0].id));
if (!gid) { state.game = null; renderTree(null); return; }
sel.value = gid;
api(base() + '/game/api/game_workbench.dspy', {game_id: gid}).then(function (w) {
if (!w.success) { msg(w.message, false); return; }
state.game = w.data.game; state.data = w.data;
document.getElementById('game_name').value = (w.data.game && w.data.game.name) || '';
renderTree(w.data);
if (state.sel) loadSelKeep(); else selectWorld();
});
});
}
function renderTree(d) {
var el = document.getElementById('tree_body');
if (!d || !d.world) { el.innerHTML = '<div style="color:#9CA3AF">尚无游戏,先填名称保存</div>'; return; }
var h = '';
h += node('world', d.world.id, '🌍 ' + esc(d.world.name));
(d.scenes || []).forEach(function (s) {
h += node('scene', s.id, '🎬 ' + esc(s.name), 12);
});
h += addRow('scene', ' 场景', 12);
(d.entities || []).forEach(function (e) {
h += node('entity', e.id, '📦 ' + esc(e.name) + (e.scene_id ? '' : '(世界级)'), 24);
});
h += addRow('entity', ' 实体', 24);
el.innerHTML = h;
}
function node(type, id, label, ml) {
var sel = state.sel && state.sel.id === id ? ' sel' : '';
return '<div class="node' + sel + '" style="margin-left:' + (ml || 0) + 'px" ' +
'onclick="wb.selObj(\'' + type + '\',\'' + id + '\')">' + label + '</div>';
}
function addRow(type, label, ml) {
if (state.inline === type) {
return '<div class="node" style="margin-left:' + ml + 'px;flex-direction:column;align-items:stretch">' +
'<input type="text" id="inline_name" placeholder="' + (type === 'scene' ? '场景名称' : '实体名称') + '" ' +
'style="padding:4px 8px;border:1px solid #93C5FD;border-radius:4px;margin-bottom:4px" ' +
'onkeydown="if(event.key===\'Enter\')wb.inlineOk(\'' + type + '\')">' +
'<div style="display:flex;gap:6px"><button onclick="wb.inlineOk(\'' + type + '\')" style="flex:1;padding:4px;background:#2563EB;color:#fff;border:none;border-radius:4px;cursor:pointer">添加</button>' +
'<button onclick="wb.inlineCancel()" style="flex:1;padding:4px;background:#E5E7EB;border:none;border-radius:4px;cursor:pointer">取消</button></div></div>';
}
return '<div class="node" style="margin-left:' + ml + 'px;color:#2563EB" onclick="wb.inlineStart(\'' + type + '\')">' + label + '</div>';
}
function esc(s) { return String(s || '').replace(/</g, '&lt;'); }
function inlineStart(type) { state.inline = type; renderTree(state.data); setTimeout(function () { var i = document.getElementById('inline_name'); if (i) i.focus(); }, 50); }
function inlineCancel() { state.inline = null; renderTree(state.data); }
function inlineOk(type) {
var name = (document.getElementById('inline_name') || {}).value;
if (!name || !name.trim()) { msg('名称不能为空', false); return; }
state.inline = null;
if (type === 'scene') {
api(base() + '/game/api/scene_add.dspy', {game_id: state.game.id, name: name.trim()})
.then(function (r) { r.success ? (msg('场景已添加', true), reload()) : msg(r.message, false); });
} else {
var sceneId = state.sel && state.sel.type === 'scene' ? state.sel.id : '';
api(base() + '/game/api/entity_add.dspy', {game_id: state.game.id, scene_id: sceneId, name: name.trim()})
.then(function (r) { r.success ? (msg('实体已添加' + (sceneId ? '' : '(世界级)'), true), reload()) : msg(r.message, false); });
}
}
function selectWorld() {
if (state.data && state.data.world) selObj('world', state.data.world.id);
}
function selObj(type, id) {
var obj = null;
if (type === 'world') obj = state.data.world;
if (type === 'scene') obj = (state.data.scenes || []).find(function (x) { return x.id === id; });
if (type === 'entity') obj = (state.data.entities || []).find(function (x) { return x.id === id; });
if (!obj) return;
state.sel = {type: type, id: id, name: obj.name};
document.getElementById('cur_obj').textContent =
({world: '🌍 世界', scene: '🎬 场景', entity: '📦 实体'})[type] + ' · ' + obj.name;
renderTree(state.data);
loadScript();
}
function loadSelKeep() { if (state.sel) selObj(state.sel.type, state.sel.id); }
function bindParams() {
var s = state.sel;
if (!s) return null;
var p = {game_id: state.game.id, trigger_event: document.getElementById('event_sel').value};
// 世界外键所有级别脚本都归属该游戏的世界world_id NOT NULL 约束)
if (state.data && state.data.world) p.world_id = state.data.world.id;
if (s.type === 'scene') p.scene_id = s.id;
if (s.type === 'entity') p.entity_id = s.id;
return p;
}
function loadScript() {
var p = bindParams();
if (!p) return;
var sc = (state.data.scripts || []).find(function (x) {
return (state.sel.type === 'world' && x.world_id === state.sel.id && x.trigger_event === p.trigger_event) ||
(state.sel.type === 'scene' && x.scene_id === state.sel.id && x.trigger_event === p.trigger_event) ||
(state.sel.type === 'entity' && x.entity_id === state.sel.id && x.trigger_event === p.trigger_event);
});
document.getElementById('code').value = sc ? (sc.content || '') : '';
state.cur_script = sc || null;
msg(sc ? '已加载现有脚本: ' + sc.name : '新脚本(保存后绑定到该对象+事件)', true);
}
function saveScript() {
var p = bindParams();
if (!p) { msg('先在左侧选择对象', false); return; }
p.name = state.sel.name + '_' + p.trigger_event;
p.code = 'SC_' + state.sel.id.slice(0, 8) + '_' + p.trigger_event;
p.content = document.getElementById('code').value;
p.script_type = '0';
if (state.cur_script) p.id = state.cur_script.id;
var url = state.cur_script ? '/script_engine/api/script_engine_update.dspy' : '/script_engine/api/script_engine_create.dspy';
api(url, p).then(function (r) {
if (r && r.success === false) { msg(r.error || r.message || '保存失败', false); return; }
msg(state.cur_script ? '脚本已更新' : '脚本已保存', true);
reload();
}).catch(function (e) { msg('保存失败: ' + e, false); });
}
function validate() {
api('/script_engine/api/validate_script.dspy', {content: document.getElementById('code').value})
.then(function (r) { msg(JSON.stringify(r).slice(0, 200), true); });
}
function test() {
if (!state.game) { msg('先保存游戏', false); return; }
var wid = state.data && state.data.world ? state.data.world.id : '';
window.open('/scense_demo/demo_viewer.ui?world_id=' + wid + '&game_id=' + state.game.id, '_blank');
}
window.wb = {newGame: newGame, saveGame: saveGame, switchGame: switchGame, reload: function () { reload(); },
selObj: selObj, inlineStart: inlineStart, inlineCancel: inlineCancel, inlineOk: inlineOk,
saveScript: saveScript, validate: validate, test: test};
document.getElementById('game_sel').onchange = function () { switchGame(this.value); };
reload();
})();

View File

@ -0,0 +1,285 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"css": "filler"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"padding": "8px 12px",
"gap": "8px",
"alignItems": "center",
"borderBottom": "1px solid #e5e7eb"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "🎮 游戏工作台",
"cfontsize": 1.2,
"fontWeight": "bold",
"halign": "left"
}
},
{
"widgettype": "Button",
"id": "wb_new_game",
"options": {
"label": " 新建游戏"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "var pw=new bricks.PopupWindow({title:'新建游戏',cwidth:32,cheight:10,auto_open:true,id:'wb_ng_popup'});var vb=new bricks.VBox({padding:'12px',gap:'10px'});var inp=new bricks.UiText({name:'wb_ng_name',placeholder:'游戏名称',cwidth:28});var chk=new bricks.UiCheck({name:'wb_ng_shared',value:false,label:'世界共享'});vb.add_widget(inp);vb.add_widget(chk);var bt=new bricks.Button({label:'创建游戏',css:'primary'});bt.bind('click',function(){var n=inp.resultValue();if(!n||!n.trim())return;var b=new URLSearchParams();b.append('name',n.trim());b.append('description',chk.resultValue()?'shared':'independent');fetch('/scense_game/game/api/game_create.dspy',{method:'POST',body:b}).then(function(r){return r.json()}).then(function(r){var ms=bricks.getWidgetById('wb_msg',bricks.app);if(ms)ms.set_text(r.success?'✔ 游戏已创建,世界已自动建立':'✘ '+(r.message||'创建失败'));var w=bricks.getWidgetById('wb_ng_popup',bricks.app);if(w){w.dismiss();w.destroy()};var t=bricks.getWidgetById('wb_tree',bricks.app);if(t&&t.container){t.container.clear_widgets();t.get_children_data(t)}})});vb.add_widget(bt);pw.content_w.add_widget(vb);setTimeout(function(){var el=inp.dom_element?inp.dom_element.querySelector('input,textarea'):null;if(el)el.focus()},300)"
}
]
},
{
"widgettype": "Text",
"id": "wb_msg",
"options": {
"text": "选择对象树节点开始编程",
"color": "#6b7280",
"halign": "left"
}
},
{
"widgettype": "Filler"
},
{
"widgettype": "Button",
"id": "wb_test",
"options": {
"label": "▶ 测试新窗口运行demo",
"css": "primary"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "var t=bricks.getWidgetById('wb_tree',bricks.app);var wid='';var gid='';if(t&&t.selected_node){var u=t.selected_node.user_data||{};gid=wb_state.game_id||'';if(u.obj_type==='world')wid=u.real_id;else{var w=t.container?null:null;var sel=t.selected_node;while(sel&&sel.parent_widget){if(sel.user_data&&sel.user_data.obj_type==='world'){wid=sel.user_data.real_id;break}sel=sel.parent_widget}}}if(!wid){var ms=bricks.getWidgetById('wb_msg',bricks.app);if(ms)ms.set_text('请先选中一个世界节点再测试');return}window.open('/scense_demo/demo_viewer.ui?world_id='+encodeURIComponent(wid)+'&game_id='+encodeURIComponent(gid),'_blank')"
}
]
}
]
},
{
"widgettype": "HBox",
"options": {
"css": "filler",
"width": "100%"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"width": "280px",
"borderRight": "1px solid #e5e7eb",
"padding": "8px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"gap": "6px",
"alignItems": "center",
"marginBottom": "6px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "对象树",
"fontWeight": "bold",
"halign": "left"
}
},
{
"widgettype": "Filler"
},
{
"widgettype": "Button",
"options": {
"label": "+场景",
"css": "small"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"params": {
"add_type": "scene",
"scene_id": "",
"hint": "新场景将加入当前游戏的世界下"
},
"script": "window.wb_state=window.wb_state||{};var wb_state=window.wb_state;window.wb_add_type=params.add_type;window.wb_add_scene_id=params.scene_id||'';var pw=new bricks.PopupWindow({title:params.add_type==='scene'?'添加场景':'添加实体',cwidth:28,cheight:8,auto_open:true,id:'wb_add_popup'});var vb=new bricks.VBox({padding:'12px',gap:'10px'});var inp=new bricks.UiText({name:'wb_add_name',placeholder:'输入名称',cwidth:24});vb.add_widget(new bricks.Text({text:params.hint,halign:'left'}));vb.add_widget(inp);var bt=new bricks.Button({label:'添加',css:'primary'});bt.bind('click',function(){window.wb_state=window.wb_state||{};var wb_state=window.wb_state;var inp=bricks.getWidgetById('wb_add_name',bricks.app);var name=inp?inp.resultValue():'';if(!name||!name.trim()){return}var b=new URLSearchParams();b.append('game_id',wb_state.game_id||'');b.append('scene_id',wb_add_scene_id||'');b.append('name',name.trim());var url=wb_add_type==='scene'?'/scense_game/game/api/scene_add.dspy':'/scense_game/game/api/entity_add.dspy';fetch(url,{method:'POST',body:b}).then(function(r){return r.json()}).then(function(r){var ms=bricks.getWidgetById('wb_msg',bricks.app);if(ms)ms.set_text(r.success?'✔ 已添加':'✘ '+(r.message||'添加失败'));var w=bricks.getWidgetById('wb_add_popup',bricks.app);if(w){w.dismiss();w.destroy()};var t=bricks.getWidgetById('wb_tree',bricks.app);if(t&&t.container){t.container.clear_widgets();t.get_children_data(t)}})});vb.add_widget(bt);pw.content_w.add_widget(vb);setTimeout(function(){var el=inp.dom_element?inp.dom_element.querySelector('input,textarea'):null;if(el)el.focus()},300)"
}
]
},
{
"widgettype": "Button",
"options": {
"label": "+实体",
"css": "small"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"params": {
"add_type": "entity",
"scene_id": "",
"hint": "选中场景节点则加入该场景,否则为世界级实体"
},
"script": "window.wb_state=window.wb_state||{};var wb_state=window.wb_state;window.wb_add_type=params.add_type;window.wb_add_scene_id=params.scene_id||'';var pw=new bricks.PopupWindow({title:params.add_type==='scene'?'添加场景':'添加实体',cwidth:28,cheight:8,auto_open:true,id:'wb_add_popup'});var vb=new bricks.VBox({padding:'12px',gap:'10px'});var inp=new bricks.UiText({name:'wb_add_name',placeholder:'输入名称',cwidth:24});vb.add_widget(new bricks.Text({text:params.hint,halign:'left'}));vb.add_widget(inp);var bt=new bricks.Button({label:'添加',css:'primary'});bt.bind('click',function(){window.wb_state=window.wb_state||{};var wb_state=window.wb_state;var inp=bricks.getWidgetById('wb_add_name',bricks.app);var name=inp?inp.resultValue():'';if(!name||!name.trim()){return}var b=new URLSearchParams();b.append('game_id',wb_state.game_id||'');b.append('scene_id',wb_add_scene_id||'');b.append('name',name.trim());var url=wb_add_type==='scene'?'/scense_game/game/api/scene_add.dspy':'/scense_game/game/api/entity_add.dspy';fetch(url,{method:'POST',body:b}).then(function(r){return r.json()}).then(function(r){var ms=bricks.getWidgetById('wb_msg',bricks.app);if(ms)ms.set_text(r.success?'✔ 已添加':'✘ '+(r.message||'添加失败'));var w=bricks.getWidgetById('wb_add_popup',bricks.app);if(w){w.dismiss();w.destroy()};var t=bricks.getWidgetById('wb_tree',bricks.app);if(t&&t.container){t.container.clear_widgets();t.get_children_data(t)}})});vb.add_widget(bt);pw.content_w.add_widget(vb);setTimeout(function(){var el=inp.dom_element?inp.dom_element.querySelector('input,textarea'):null;if(el)el.focus()},300)"
}
]
}
]
},
{
"widgettype": "VScrollPanel",
"options": {
"css": "filler"
},
"subwidgets": [
{
"widgettype": "Tree",
"id": "wb_tree",
"options": {
"dataurl": "{{entire_url('/scense_game/workbench/api/tree.dspy')}}",
"method": "POST",
"idField": "id",
"textField": "text",
"select_only": true,
"title": ""
},
"binds": [
{
"wid": "self",
"event": "node_selected",
"actiontype": "script",
"target": "self",
"script": "window.wb_state=window.wb_state||{};var wb_state=window.wb_state;var t=bricks.getWidgetById('wb_tree',bricks.app);if(!event.params||!event.params.id)return;var ot=event.params.obj_type||'';var oid=event.params.real_id||'';var lbl=bricks.getWidgetById('wb_cur_obj',bricks.app);if(lbl)lbl.set_text(({'game':'🎮 游戏','world':'🌍 世界','scene':'🎬 场景','entity':'📦 实体'})[ot]+' · '+event.params.text);wb_state.obj_type=ot;wb_state.obj_id=oid;wb_state.game_id=(ot==='game')?oid:(event.params.game_id||wb_state.game_id);var es=bricks.getWidgetById('wb_event_sel',bricks.app);var evt=es?es.resultValue():'event_start';if(!oid)return;var b=new URLSearchParams();b.append('obj_type',ot);b.append('obj_id',oid);b.append('trigger_event',evt);fetch('/scense_game/workbench/api/load_script.dspy',{method:'POST',body:b}).then(function(r){return r.json()}).then(function(r){var ed=bricks.getWidgetById('wb_code',bricks.app);if(ed&&r.success){ed.set_value(r.data?r.data.content:'')}})"
}
]
}
]
}
]
},
{
"widgettype": "VBox",
"options": {
"css": "filler",
"padding": "10px",
"gap": "8px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"gap": "10px",
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Text",
"id": "wb_cur_obj",
"options": {
"text": "未选择对象",
"fontWeight": "bold",
"halign": "left"
}
},
{
"widgettype": "UiCode",
"id": "wb_event_sel",
"options": {
"name": "wb_event_sel",
"data": [
{
"value": "event_start",
"text": "事件:开始"
},
{
"value": "event_click",
"text": "事件:点击"
},
{
"value": "event_timer",
"text": "事件:定时"
},
{
"value": "event_collision",
"text": "事件:碰撞"
}
],
"valueField": "value",
"textField": "text",
"cwidth": 10
}
},
{
"widgettype": "Filler"
},
{
"widgettype": "Button",
"options": {
"label": "校验"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "var ms=bricks.getWidgetById('wb_msg',bricks.app);var ed=bricks.getWidgetById('wb_code',bricks.app);var code=ed?ed.get_value():'';var b=new URLSearchParams();b.append('content',code);fetch('/script_engine/api/validate_script.dspy',{method:'POST',body:b}).then(function(r){return r.json()}).then(function(r){if(ms)ms.set_text(r.success!==false?'✔ 语法校验通过':'✘ '+(r.error||r.message||'校验失败'))})"
}
]
},
{
"widgettype": "Button",
"options": {
"label": "💾 保存脚本",
"css": "primary"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "window.wb_state=window.wb_state||{};var wb_state=window.wb_state;var ms=bricks.getWidgetById('wb_msg',bricks.app);if(!wb_state.obj_id){if(ms)ms.set_text('先在左侧树选择对象');return}var es=bricks.getWidgetById('wb_event_sel',bricks.app);var evt=es?es.resultValue():'event_start';var ed=bricks.getWidgetById('wb_code',bricks.app);var code=ed?ed.get_value():'';var b=new URLSearchParams();b.append('obj_type',wb_state.obj_type);b.append('obj_id',wb_state.obj_id);b.append('game_id',wb_state.game_id||'');b.append('trigger_event',evt);b.append('content',code);fetch('/scense_game/workbench/api/save_script.dspy',{method:'POST',body:b}).then(function(r){return r.json()}).then(function(r){if(ms)ms.set_text(r.success?'✔ 脚本已保存':'✘ '+(r.message||'保存失败'))})"
}
]
}
]
},
{
"widgettype": "CodeEditor",
"id": "wb_code",
"options": {
"css": "filler",
"mode": "python",
"value": "# 选择左侧对象后,为它的事件编写逻辑脚本"
}
}
]
}
]
}
]
}