Merge branch 'main' into fix/asset-catalog-pipeline
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM === ChildHood Input Setup - Create v0.1 InputAction assets + update IMC ===
|
||||
REM
|
||||
REM Usage:
|
||||
REM setup_input.bat (default, editor quits after done)
|
||||
REM setup_input.bat --no-quit (keep editor open)
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "PROJECT_DIR=%SCRIPT_DIR%..\.."
|
||||
for %%i in ("%PROJECT_DIR%\.") do set "PROJECT_ROOT=%%~fi"
|
||||
set "UPROJECT=%PROJECT_ROOT%\ChildHood.uproject"
|
||||
|
||||
REM ---- Defaults ----
|
||||
set "NO_QUIT=0"
|
||||
set "DRY_RUN=0"
|
||||
|
||||
REM ---- Parse Arguments ----
|
||||
:parse_args
|
||||
if "%~1"=="" goto done_args
|
||||
if /i "%~1"=="--no-quit" (
|
||||
set "NO_QUIT=1"
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
if /i "%~1"=="--dry-run" (
|
||||
set "DRY_RUN=1"
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
if /i "%~1"=="--help" goto show_help
|
||||
if /i "%~1"=="-h" goto show_help
|
||||
echo [WARN] Unknown argument: %~1
|
||||
shift
|
||||
goto parse_args
|
||||
:done_args
|
||||
|
||||
REM ---- Find UE5 Editor ----
|
||||
set "UE_EDITOR="
|
||||
if defined UE5_ROOT (
|
||||
for %%b in (UnrealEditor.exe UE5Editor.exe) do (
|
||||
if exist "%UE5_ROOT%\Engine\Binaries\Win64\%%b" (
|
||||
set "UE_EDITOR=%UE5_ROOT%\Engine\Binaries\Win64\%%b"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if "%UE_EDITOR%"=="" (
|
||||
for %%p in (
|
||||
"C:\Program Files\Epic Games\UE_5.7"
|
||||
"C:\Program Files (x86)\Epic Games\UE_5.7"
|
||||
"D:\Program Files\Epic Games\UE_5.7"
|
||||
"E:\Program Files\Epic Games\UE_5.7"
|
||||
) do (
|
||||
if not defined UE_EDITOR (
|
||||
for %%b in (UnrealEditor.exe UE5Editor.exe) do (
|
||||
if exist "%%~p\Engine\Binaries\Win64\%%b" (
|
||||
set "UE_EDITOR=%%~p\Engine\Binaries\Win64\%%b"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if "%UE_EDITOR%"=="" (
|
||||
echo.
|
||||
echo [ERROR] UE5 Editor not found!
|
||||
echo Set UE5_ROOT, e.g.: set UE5_ROOT=C:\Program Files\Epic Games\UE_5.7
|
||||
echo.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ---- Path conversion ----
|
||||
set "SCRIPT_PATH=%SCRIPT_DIR%setup_input_assets.py"
|
||||
set "SCRIPT_PATH=!SCRIPT_PATH:\=/!"
|
||||
set "UPROJECT_FWD=!UPROJECT:\=/!"
|
||||
|
||||
REM ---- Build command line ----
|
||||
set "CMD_ARGS=-unattended -NoSound -NullRHI -ExecutePythonScript=!SCRIPT_PATH!"
|
||||
|
||||
REM ---- Pass config via env vars ----
|
||||
set "INPUT_PROJECT_ROOT=!PROJECT_ROOT!"
|
||||
if "!NO_QUIT!"=="1" set "INPUT_NO_QUIT=1"
|
||||
|
||||
REM ---- Print info ----
|
||||
echo.
|
||||
echo ============================================
|
||||
echo ChildHood Input Setup - v0.1
|
||||
echo ============================================
|
||||
echo UE5: !UE_EDITOR!
|
||||
echo Project: !UPROJECT_FWD!
|
||||
echo Script: !SCRIPT_PATH!
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
REM ---- Dry Run ----
|
||||
if "%DRY_RUN%"=="1" (
|
||||
echo [DRY RUN] Command:
|
||||
echo.
|
||||
echo "!UE_EDITOR!" "!UPROJECT_FWD!" !CMD_ARGS!
|
||||
echo.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
REM ---- Execute ----
|
||||
echo [InputSetup] Launching UE5 Editor ...
|
||||
echo.
|
||||
|
||||
"!UE_EDITOR!" "!UPROJECT_FWD!" !CMD_ARGS!
|
||||
|
||||
set "EXIT_CODE=!ERRORLEVEL!"
|
||||
echo.
|
||||
if "!EXIT_CODE!"=="0" (
|
||||
echo [InputSetup] Done. Editor exited.
|
||||
) else (
|
||||
echo [InputSetup] Editor exit code: !EXIT_CODE!
|
||||
)
|
||||
exit /b !EXIT_CODE!
|
||||
|
||||
:show_help
|
||||
echo.
|
||||
echo ChildHood Input Setup - v0.1
|
||||
echo.
|
||||
echo Usage: setup_input.bat [options]
|
||||
echo.
|
||||
echo Options:
|
||||
echo --no-quit Keep editor open after done
|
||||
echo --dry-run Print command without executing
|
||||
echo --help, -h Show help
|
||||
echo.
|
||||
exit /b 0
|
||||
@@ -0,0 +1,225 @@
|
||||
"""UE5 编辑器 Python 脚本:创建 v0.1 角色控制所需的 InputAction 资产并更新 IMC。
|
||||
|
||||
由 setup_input.bat 通过 UE5 的 -ExecutePythonScript 参数调用。
|
||||
在 UE5 编辑器 Python 环境中运行。
|
||||
|
||||
功能:
|
||||
1. 创建 3 个 InputAction 资产:IA_Interact, IA_Cancel, IA_SwitchInteract
|
||||
2. 更新 IMC_Default:添加 E / ESC / Tab 按键映射
|
||||
3. 保存所有修改的资产
|
||||
|
||||
环境变量(由 setup_input.bat 设置):
|
||||
INPUT_PROJECT_ROOT — 项目根目录绝对路径
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---- 检查 unreal 模块 ----
|
||||
try:
|
||||
import unreal
|
||||
except ImportError:
|
||||
print("[InputSetup] FATAL: unreal 模块不可用,必须在 UE5 编辑器环境中运行")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- 项目根目录 ----
|
||||
_project_root_str = os.environ.get("INPUT_PROJECT_ROOT", "")
|
||||
if _project_root_str:
|
||||
PROJECT_ROOT = Path(_project_root_str)
|
||||
else:
|
||||
print("[InputSetup] FATAL: INPUT_PROJECT_ROOT 环境变量未设置")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- 资产路径配置 ----
|
||||
IA_PACKAGE = "/Game/Input/Actions"
|
||||
IMC_PATH = "/Game/Input/IMC_Default"
|
||||
|
||||
# 新增 InputAction 定义
|
||||
NEW_INPUT_ACTIONS = {
|
||||
"IA_Interact": {
|
||||
"description": "E 键交互输入动作",
|
||||
"key": unreal.Key.E,
|
||||
"triggers": [unreal.InputTriggerDown()],
|
||||
},
|
||||
"IA_Cancel": {
|
||||
"description": "ESC 取消输入动作",
|
||||
"key": unreal.Key.ESCAPE,
|
||||
"triggers": [unreal.InputTriggerDown()],
|
||||
},
|
||||
"IA_SwitchInteract": {
|
||||
"description": "Tab 切换交互目标输入动作",
|
||||
"key": unreal.Key.TAB,
|
||||
"triggers": [unreal.InputTriggerDown()],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def asset_exists(asset_path: str) -> bool:
|
||||
"""检查资产是否已存在"""
|
||||
return unreal.EditorAssetLibrary.does_asset_exist(asset_path)
|
||||
|
||||
|
||||
def create_input_action(name: str, description: str) -> unreal.InputAction:
|
||||
"""创建 InputAction 资产"""
|
||||
asset_path = f"{IA_PACKAGE}/{name}"
|
||||
|
||||
if asset_exists(asset_path):
|
||||
print(f"[InputSetup] 资产已存在,跳过创建: {asset_path}")
|
||||
return unreal.load_asset(asset_path)
|
||||
|
||||
# 确保目录存在
|
||||
unreal.EditorAssetLibrary.make_directory(IA_PACKAGE)
|
||||
|
||||
# 使用 AssetTools 创建
|
||||
asset_tools = unreal.get_editor_subsystem(unreal.EditorAssetSubsystem)
|
||||
if asset_tools:
|
||||
# 尝试通过 EditorAssetSubsystem 创建
|
||||
factory = unreal.InputActionFactory()
|
||||
new_asset = asset_tools.create_asset(name, IA_PACKAGE, unreal.InputAction, factory)
|
||||
if new_asset:
|
||||
print(f"[InputSetup] 创建成功: {asset_path} — {description}")
|
||||
return new_asset
|
||||
|
||||
# 备用方案:使用 Python BPLib 或直接构造
|
||||
print(f"[InputSetup] 尝试备用方案创建: {name}")
|
||||
factory = unreal.InputActionFactory()
|
||||
new_asset = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
name, IA_PACKAGE[5:], # 去掉 /Game/ 前缀
|
||||
unreal.InputAction,
|
||||
factory
|
||||
)
|
||||
if new_asset:
|
||||
print(f"[InputSetup] 备用方案创建成功: {asset_path}")
|
||||
return new_asset
|
||||
|
||||
print(f"[InputSetup] ERROR: 无法创建 {name}")
|
||||
return None
|
||||
|
||||
|
||||
def add_key_mapping_to_imc(imc: unreal.InputMappingContext, ia: unreal.InputAction, key: unreal.Key):
|
||||
"""向 IMC 添加按键映射"""
|
||||
mapping = unreal.InputMapping()
|
||||
mapping.set_editor_property("action", ia)
|
||||
|
||||
key_mapping = unreal.KeyMapping()
|
||||
key_mapping.set_editor_property("key", key)
|
||||
key_mapping.set_editor_property("scale", 1.0)
|
||||
|
||||
# 设置触发器
|
||||
# key_mapping 的 triggers 由 IMC 内部管理
|
||||
|
||||
# 使用 IMC 的 API 添加映射
|
||||
try:
|
||||
# UE5.7: 使用 mappings 属性
|
||||
existing_mappings = imc.get_editor_property("mappings")
|
||||
new_mapping = unreal.InputMapping()
|
||||
new_mapping.set_editor_property("action", ia)
|
||||
|
||||
km = unreal.KeyMapping()
|
||||
km.set_editor_property("key", key)
|
||||
km.set_editor_property("scale", 1.0)
|
||||
|
||||
# 设置 triggers
|
||||
triggers = [unreal.InputTriggerDown()]
|
||||
km.set_editor_property("triggers", triggers)
|
||||
new_mapping.set_editor_property("key_mappings", [km])
|
||||
|
||||
existing_mappings.append(new_mapping)
|
||||
imc.set_editor_property("mappings", existing_mappings)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[InputSetup] 添加映射失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print("[InputSetup] v0.1 角色控制 Input 资产创建脚本")
|
||||
print(f"[InputSetup] 项目: {PROJECT_ROOT}")
|
||||
print("=" * 50)
|
||||
|
||||
# ---- Step 1: 创建 InputAction 资产 ----
|
||||
print("\n[InputSetup] Step 1: 创建 InputAction 资产")
|
||||
created_ias = {}
|
||||
for name, config in NEW_INPUT_ACTIONS.items():
|
||||
ia = create_input_action(name, config["description"])
|
||||
if ia:
|
||||
created_ias[name] = ia
|
||||
else:
|
||||
print(f"[InputSetup] WARNING: {name} 创建失败,后续 IMC 映射将跳过此 IA")
|
||||
|
||||
if not created_ias:
|
||||
print("[InputSetup] ERROR: 没有成功创建任何 IA,终止")
|
||||
return False
|
||||
|
||||
# ---- Step 2: 保存新创建的 IA 资产 ----
|
||||
print("\n[InputSetup] Step 2: 保存新创建的资产")
|
||||
for name, ia in created_ias.items():
|
||||
asset_path = f"{IA_PACKAGE}/{name}"
|
||||
unreal.EditorAssetLibrary.save_asset(asset_path)
|
||||
print(f"[InputSetup] 已保存: {asset_path}")
|
||||
|
||||
# ---- Step 3: 更新 IMC_Default ----
|
||||
print(f"\n[InputSetup] Step 3: 更新 IMC: {IMC_PATH}")
|
||||
|
||||
if not asset_exists(IMC_PATH):
|
||||
print(f"[InputSetup] ERROR: IMC 不存在: {IMC_PATH}")
|
||||
return False
|
||||
|
||||
imc = unreal.load_asset(IMC_PATH)
|
||||
if not imc:
|
||||
print(f"[InputSetup] ERROR: 无法加载 IMC: {IMC_PATH}")
|
||||
return False
|
||||
|
||||
# 读取当前映射数量
|
||||
try:
|
||||
current_mappings = imc.get_editor_property("mappings")
|
||||
print(f"[InputSetup] 当前 IMC 映射数: {len(current_mappings)}")
|
||||
except Exception as e:
|
||||
print(f"[InputSetup] WARNING: 无法读取映射: {e}")
|
||||
current_mappings = []
|
||||
|
||||
# 添加新映射
|
||||
for name, config in NEW_INPUT_ACTIONS.items():
|
||||
if name not in created_ias:
|
||||
continue
|
||||
ia = created_ias[name]
|
||||
key = config["key"]
|
||||
success = add_key_mapping_to_imc(imc, ia, key)
|
||||
if success:
|
||||
print(f"[InputSetup] 已添加映射: {name} → {key}")
|
||||
else:
|
||||
print(f"[InputSetup] WARNING: 映射添加失败: {name}")
|
||||
|
||||
# ---- Step 4: 保存 IMC ----
|
||||
print("\n[InputSetup] Step 4: 保存 IMC")
|
||||
unreal.EditorAssetLibrary.save_asset(IMC_PATH)
|
||||
print(f"[InputSetup] 已保存: {IMC_PATH}")
|
||||
|
||||
# ---- Step 5: 验证 ----
|
||||
print("\n[InputSetup] Step 5: 验证")
|
||||
try:
|
||||
updated_mappings = imc.get_editor_property("mappings")
|
||||
print(f"[InputSetup] 更新后 IMC 映射数: {len(updated_mappings)}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 验证 IA 资产
|
||||
for name in NEW_INPUT_ACTIONS:
|
||||
asset_path = f"{IA_PACKAGE}/{name}"
|
||||
exists = asset_exists(asset_path)
|
||||
print(f"[InputSetup] {name}: {'✅ 存在' if exists else '❌ 不存在'}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("[InputSetup] 完成!请在 UE5 编辑器中验证。")
|
||||
print("[InputSetup] 注意:需要在 BP_ChildHoodCharacter 中将新增 IA 属性指向对应资产")
|
||||
print("=" * 50)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
+130
-56
@@ -3,17 +3,66 @@
|
||||
> 在场景概念与关卡执行案之间,提供可视化布局沟通工具。
|
||||
> 用颜色画笔绘制世界布局 → AI 解读 → 双方确认 → 产出执行案。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 双击 `sketch.html` 在浏览器中打开(推荐 Chrome / Edge)
|
||||
2. 左侧设置场景名称和世界尺寸(米)
|
||||
3. 选择画笔,在画布上绘制
|
||||
4. **双击** 已绘制区域 → 弹出命名对话框:
|
||||
### 方式一:本地服务器(推荐)
|
||||
|
||||
保存/加载直接读写 `output/` 目录,无需手动下载。
|
||||
|
||||
1. 双击 `start.bat`
|
||||
2. 浏览器自动打开 `http://localhost:8765/sketch.html`
|
||||
3. 开始绘制,点"保存"直接存入 `output/`
|
||||
|
||||
> 需要 Python 3。关闭终端窗口即可停止服务器。
|
||||
|
||||
### 方式二:直接打开 HTML
|
||||
|
||||
双击 `sketch.html` 在浏览器中打开(推荐 Chrome / Edge)。
|
||||
|
||||
- 保存时通过浏览器下载 ZIP 文件
|
||||
- 加载时通过浏览器文件选择器选择 ZIP
|
||||
|
||||
> **注意**:直接打开 HTML 时加载独立的 `.json` 文件**不会恢复画布位图**——位图数据存储在 ZIP 包内的 PNG 文件中,必须加载 `.zip` 文件才能完整恢复。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
world-sketcher/
|
||||
├── start.bat ← 启动脚本(双击运行)
|
||||
├── server.py ← 本地保存/加载服务器
|
||||
├── sketch.html ← 主工具(单文件,浏览器运行)
|
||||
├── README.md
|
||||
└── output/ ← 保存/加载目录
|
||||
├── 迈皋桥-scene.zip ← 完整场景包(JSON + 所有 PNG)
|
||||
├── 迈皋桥-scene.json ← 结构化数据(不含位图)
|
||||
├── 迈皋桥-L1-drawing.png # L1 画布像素数据
|
||||
├── 迈皋桥-L1-overview-preview.png # L1 合并预览
|
||||
└── 迈皋桥-L2-*-drawing.png # L2 各区域画布数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 基本操作
|
||||
|
||||
1. 左侧设置**场景名称**和**世界尺寸**(米)
|
||||
2. 选择画笔,在画布上绘制
|
||||
3. **双击** 已绘制区域 → 弹出命名对话框:
|
||||
- 输入名称(如"家"、"小卖部")
|
||||
- 设置**高度**(建筑/区域/不可通行类型时显示,默认 3m)
|
||||
- 勾选**连通到**其他已命名区域(建立区域间连接关系)
|
||||
- **L1 区域**:设置**地形标高**(米,可为负值表示洼地)
|
||||
- **L2 建筑**:设置**建筑高度**(米)
|
||||
- 勾选**连通到**其他已命名区域(建立连接关系)
|
||||
- 勾选「可进入」→ 创建下级详图(L1 → L2)
|
||||
5. 保存 ZIP(含 PNG + JSON)
|
||||
4. 保存 → 存入 `output/`(服务器模式)或浏览器下载(离线模式)
|
||||
|
||||
### 画布标签
|
||||
|
||||
- **L1 层**:显示区域名称 + 地形标高(如 `棚户区 (标高2m)`)
|
||||
- **L2 层**:显示建筑名称 + 建筑高度(如 `棚户1 (3m)`)
|
||||
- 标高为 0 时不显示高度
|
||||
|
||||
---
|
||||
|
||||
@@ -30,36 +79,30 @@
|
||||
- L1 画布上,双击已命名的区域并勾选「可进入」
|
||||
- 左侧元素列表中点击 **"进入 →"**
|
||||
- 面包屑导航点击 **"场景总览"** 返回上级
|
||||
- 返回 L1 时,L2 的绘制内容会自动合成回 L1 对应区域
|
||||
|
||||
---
|
||||
|
||||
## 画笔图例
|
||||
|
||||
| 画笔 | 颜色 | 用途 | 对应 UE 材质 | 默认高度 |
|
||||
|------|------|------|-------------|---------|
|
||||
| 道路 Road | 灰 `#909090` | 道路、马路、小路 | MI_DevGray | — |
|
||||
| 建筑 Building | 橙 `#E07020` | 建筑物 | MI_DevOrange | 3m |
|
||||
| 水域 Water | 天蓝 `#40A0D0` | 河流、水塘、水体 | MI_DevSkyBlue | — |
|
||||
| 植被 Greenery | 绿 `#40B040` | 树木、草地、绿化带 | MI_DevGreen | 2m |
|
||||
| 不可通行 Blocked | 棕 `#A07040` | 山体、围墙、不可达区域 | MI_DevBrown | 3m |
|
||||
| 区域 Zone | 红 `#D04040` | 区域边界、地块划分 | MI_DevRed | 3m |
|
||||
| 橡皮擦 Eraser | — | 擦除 | — | — |
|
||||
|
||||
**L1 层**主要用:区域、道路、水域
|
||||
**L2 层**主要用:建筑、道路、植被、水域、不可通行
|
||||
| 画笔 | 颜色 | 用途 | 对应 UE 材质 | 可用层级 | 高度含义 |
|
||||
|------|------|------|-------------|---------|---------|
|
||||
| 道路 Road | 灰 `#909090` | 道路、马路、小路 | MI_DevGray | L1/L2 | — |
|
||||
| 建筑 Building | 橙 `#E07020` | 建筑物 | MI_DevOrange | L2 | 建筑高度 |
|
||||
| 水域 Water | 天蓝 `#40A0D0` | 河流、水塘、水体 | MI_DevSkyBlue | L1/L2 | — |
|
||||
| 植被 Greenery | 绿 `#40B040` | 树木、草地、绿化带 | MI_DevGreen | L2 | 2m |
|
||||
| 不可通行 Blocked | 棕 `#A07040` | 山体、围墙、不可达区域 | MI_DevBrown | L1/L2 | 3m |
|
||||
| 区域 Zone | 红 `#D04040` | 区域边界、地块划分 | MI_DevRed | L1 | 地形标高 |
|
||||
| 橡皮擦 Eraser | — | 擦除 | — | L1/L2 | — |
|
||||
|
||||
---
|
||||
|
||||
## 绘图工具
|
||||
|
||||
| 工具 | 快捷键 | 说明 |
|
||||
|------|--------|------|
|
||||
| 画笔 | P | 自由绘制,按住鼠标拖动 |
|
||||
| 矩形 | R | 绘制矩形区域,拖动定义两角 |
|
||||
| 橡皮擦 | E | 擦除画布内容 |
|
||||
## 快捷键
|
||||
|
||||
| 操作 | 快捷键 |
|
||||
|------|--------|
|
||||
| 画笔工具 | P |
|
||||
| 橡皮擦工具 | E |
|
||||
| 撤销 | Ctrl+Z |
|
||||
| 重做 | Ctrl+Y |
|
||||
| 网格开关 | G |
|
||||
@@ -74,57 +117,87 @@
|
||||
- **单位**:厘米(与 UE5 一致,1 uu = 1 cm)
|
||||
- 画布顶部 = 世界最大 Y,画布底部 = 世界 Y=0
|
||||
|
||||
状态栏实时显示鼠标位置的世界坐标。
|
||||
状态栏实时显示鼠标位置的像素坐标和世界坐标。
|
||||
|
||||
---
|
||||
|
||||
## 输出文件
|
||||
|
||||
保存全部时生成:
|
||||
|
||||
```
|
||||
<场景名>-L1-overview.png # L1 画布截图
|
||||
<场景名>-scene.json # 完整场景数据(所有层级)
|
||||
```
|
||||
## 输出格式
|
||||
|
||||
### scene.json 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"scene_name": "城郊小街",
|
||||
"scene_name": "迈皋桥",
|
||||
"version": "v02",
|
||||
"created": "2026-06-09T16:46:32",
|
||||
"origin": "SW corner, X+ East, Y+ North",
|
||||
"brush_legend": { "色值": { "type": "类型", "mi_material": "MI_Dev*" } },
|
||||
"brush_legend": {
|
||||
"#D04040": { "type": "zone", "label": "区域 Zone", "mi_material": "MI_DevRed" }
|
||||
},
|
||||
"levels": {
|
||||
"L1": {
|
||||
"world_size_m": [500, 400],
|
||||
"canvas_size_px": [1000, 800],
|
||||
"scale_m_per_px": 0.5,
|
||||
"elements": [
|
||||
{ "id": "el_001", "type": "zone", "name": "家", "height_m": 3,
|
||||
"connections": ["el_003"], "has_detail": false },
|
||||
{ "id": "el_002", "type": "zone", "name": "小卖部", "height_m": 3,
|
||||
"connections": ["el_003"], "has_detail": false },
|
||||
{ "id": "el_003", "type": "zone", "name": "主街道", "height_m": 3,
|
||||
"connections": ["el_001", "el_002", "el_004"], "has_detail": true, "detail_ref": "L2-el_003" }
|
||||
{
|
||||
"id": "el_001", "type": "zone", "name": "棚户区",
|
||||
"height_m": 2, "connections": ["el_002"],
|
||||
"has_detail": true, "detail_ref": "L2-el_001"
|
||||
}
|
||||
],
|
||||
"regions": [
|
||||
{ "id": "r_001", "type": "zone", "name": "家",
|
||||
"bounds_world_m": [5.0, 5.0, 12.5, 15.0], "area_px": 15000 }
|
||||
]
|
||||
{
|
||||
"id": "r_001", "type": "zone", "name": "棚户区",
|
||||
"bounds_world_m": [5.0, 5.0, 120.0, 95.0],
|
||||
"area_px": 15000
|
||||
}
|
||||
],
|
||||
"image_file": "迈皋桥-L1-drawing.png",
|
||||
"l1_base_file": null
|
||||
},
|
||||
"L2-el_003": {
|
||||
"L2-el_001": {
|
||||
"world_size_m": [80, 60],
|
||||
"elements": [
|
||||
{ "id": "el_yyy", "type": "building", "name": "家", "height_m": 3, "connections": [], "has_detail": false }
|
||||
{ "id": "el_010", "type": "building", "name": "棚户1", "height_m": 3 }
|
||||
],
|
||||
"image_file": "迈皋桥-L2-el_001-drawing.png",
|
||||
"l1_base_file": "迈皋桥-L2-el_001-l1base.png",
|
||||
"parent_level": "L1",
|
||||
"parent_element": "el_003"
|
||||
"parent_element": "el_001"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 高度字段语义
|
||||
|
||||
| 层级 | 元素类型 | `height_m` 含义 | 用途 |
|
||||
|------|---------|----------------|------|
|
||||
| L1 | zone(区域) | **地形标高** | 地面海拔相对高度,0=基准面,负值=洼地 |
|
||||
| L2 | building(建筑) | **建筑高度** | 建筑物垂直高度 |
|
||||
| L1/L2 | blocked(不可通行) | **障碍高度** | 围墙/山体等垂直尺度 |
|
||||
|
||||
---
|
||||
|
||||
## 本地服务器
|
||||
|
||||
`server.py` 提供三个 HTTP 端点,供 `sketch.html` 直接读写 `output/` 目录:
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/list` | GET | 列出 `output/` 下所有 `.json` / `.zip` 文件 |
|
||||
| `/load/<filename>` | GET | 从 `output/` 读取指定文件 |
|
||||
| `/save` | POST (multipart) | 接收文件并保存到 `output/` |
|
||||
|
||||
服务器运行时,sketch.html 自动检测并使用服务器模式;未运行时自动回退到浏览器下载/文件选择。
|
||||
|
||||
手动启动:
|
||||
```bash
|
||||
python server.py # 默认端口 8765
|
||||
python server.py 9000 # 自定义端口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流集成
|
||||
@@ -145,20 +218,21 @@ AI 布局解读文档 (场景名-layout-vXX.md)
|
||||
|
||||
### AI 解读流程
|
||||
|
||||
1. 用户在工具中画好布局,命名区域,设置高度和连通关系,保存 ZIP
|
||||
2. AI 读取 JSON 布局数据(elements + regions + connections + height_m)
|
||||
1. 用户在工具中画好布局,命名区域,设置标高/高度和连通关系,保存
|
||||
2. AI 读取 JSON 中的 elements + regions + connections + height_m
|
||||
3. AI 交叉参考场景设计案的氛围描述,将 JSON 数据与场景概念建立映射
|
||||
4. AI 产出结构化布局解读 Markdown(区域表、连通关系、高度、待确认问题)
|
||||
4. AI 产出结构化布局解读 Markdown(区域表、连通关系、标高、待确认问题)
|
||||
5. 用户确认后,策划基于解读文档产出关卡执行案
|
||||
|
||||
> **说明**:PNG 文件保存在 ZIP 中用于画布的保存/加载,AI 分析以 JSON 结构化数据为准,不依赖 PNG 像素分析。
|
||||
> PNG 文件用于画布的保存/加载和人工预览。AI 分析以 JSON 结构化数据为准,不依赖 PNG 像素分析。
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
|
||||
- **强制使用预定义画笔**:无自由拾色器,保证每个像素都映射到已知地形类型
|
||||
- **结构化数据优先**:高度和连通关系由用户直接输入,AI 无需从图像推断
|
||||
- **预定义画笔**:无自由拾色器,保证每个像素都映射到已知地形类型
|
||||
- **结构化数据优先**:标高、高度和连通关系由用户直接输入,AI 无需从图像推断
|
||||
- **层次化设计**:L1 定大局,L2 定细节,L3 预留
|
||||
- **人机共读**:PNG 供人视觉确认,JSON 供 AI 和程序化解析
|
||||
- **与 Level Builder 一致**:画笔颜色直接映射 MI_Dev* 标识色材质
|
||||
- **材质一致**:画笔颜色直接映射 UE5 的 MI_Dev* 标识色材质
|
||||
- **离线优先**:单 HTML 文件即可使用,服务器仅为便利增强
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
World Sketcher 本地保存服务器
|
||||
用法: python server.py [port]
|
||||
然后浏览器打开 http://localhost:<port>/sketch.html
|
||||
"""
|
||||
|
||||
import http.server, json, os, sys, urllib.parse
|
||||
from datetime import datetime
|
||||
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
|
||||
SERVE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
OUTPUT_DIR = os.path.join(SERVE_DIR, "output")
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=SERVE_DIR, **kwargs)
|
||||
|
||||
# ── POST /save ── 保存文件到 output/
|
||||
def do_POST(self):
|
||||
if self.path != "/save":
|
||||
self.send_error(404); return
|
||||
|
||||
ctype = self.headers.get("Content-Type", "")
|
||||
if ctype.startswith("multipart/form-data"):
|
||||
boundary = ctype.split("boundary=")[-1].encode()
|
||||
data = self.rfile.read(int(self.headers["Content-Length"]))
|
||||
|
||||
# 简易 multipart 解析
|
||||
parts = data.split(b"--" + boundary)
|
||||
saved = []
|
||||
for part in parts:
|
||||
if not part or part.strip() in (b"", b"--\r\n", b"--"):
|
||||
continue
|
||||
header_end = part.find(b"\r\n\r\n")
|
||||
if header_end < 0:
|
||||
continue
|
||||
header = part[:header_end].decode("utf-8", errors="replace")
|
||||
body = part[header_end + 4:]
|
||||
if body.endswith(b"\r\n"):
|
||||
body = body[:-2]
|
||||
|
||||
# 提取文件名
|
||||
fname = None
|
||||
for seg in header.split(";"):
|
||||
seg = seg.strip()
|
||||
if seg.startswith("filename="):
|
||||
fname = seg.split("=", 1)[1].strip('"')
|
||||
break
|
||||
if not fname:
|
||||
continue
|
||||
|
||||
# 安全:只取文件名,禁止路径遍历
|
||||
fname = os.path.basename(fname)
|
||||
fpath = os.path.join(OUTPUT_DIR, fname)
|
||||
with open(fpath, "wb") as f:
|
||||
f.write(body)
|
||||
saved.append(fname)
|
||||
|
||||
resp = json.dumps({"ok": True, "saved": saved}, ensure_ascii=False)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(resp.encode("utf-8"))
|
||||
print(f" [{datetime.now():%H:%M:%S}] Saved: {', '.join(saved)}")
|
||||
else:
|
||||
self.send_error(400, "Expected multipart/form-data")
|
||||
|
||||
# ── GET 路由 ──
|
||||
def do_GET(self):
|
||||
# /list → 列出 output/ 下可用文件
|
||||
if self.path == "/list":
|
||||
files = sorted(
|
||||
f for f in os.listdir(OUTPUT_DIR)
|
||||
if f.endswith((".json", ".zip"))
|
||||
)
|
||||
resp = json.dumps({"files": files}, ensure_ascii=False)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(resp.encode("utf-8"))
|
||||
return
|
||||
|
||||
# /load/<file> → 从 output/ 读取文件
|
||||
if self.path.startswith("/load/"):
|
||||
fname = os.path.basename(urllib.parse.unquote(self.path[6:], encoding="utf-8"))
|
||||
fpath = os.path.join(OUTPUT_DIR, fname)
|
||||
if not os.path.isfile(fpath):
|
||||
self.send_error(404); return
|
||||
self.send_response(200)
|
||||
if fname.endswith(".zip"):
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
elif fname.endswith(".json"):
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
with open(fpath, "rb") as f:
|
||||
self.wfile.write(f.read())
|
||||
return
|
||||
|
||||
# 其他 → 静态文件(sketch.html 等)
|
||||
super().do_GET()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# 只显示非静态资源的请求日志
|
||||
if "/save" in str(args) or "/list" in str(args) or "/load/" in str(args):
|
||||
super().log_message(format, *args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with http.server.HTTPServer(("", PORT), Handler) as httpd:
|
||||
url = f"http://localhost:{PORT}/sketch.html"
|
||||
print(f"World Sketcher server running")
|
||||
print(f" URL: {url}")
|
||||
print(f" Output: {OUTPUT_DIR}")
|
||||
print(f" Press Ctrl+C to stop.\n")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped.")
|
||||
@@ -147,7 +147,7 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
|
||||
<button class="tbtn" id="btn-clear">清空</button>
|
||||
</div>
|
||||
<div class="tool-group">
|
||||
<button class="tbtn" id="btn-save-all">保存 ZIP</button>
|
||||
<button class="tbtn" id="btn-save-all">保存</button>
|
||||
<button class="tbtn" id="btn-load">加载</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,8 +174,8 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
|
||||
<input id="name-input" type="text" placeholder="输入名称(如"家"、"小卖部")">
|
||||
<label><input type="checkbox" id="name-enterable"> 可进入(有下级详图)</label>
|
||||
<div id="name-height-row" style="display:none;">
|
||||
<label style="color:var(--text-dim);">高度</label>
|
||||
<input id="name-height" type="number" min="0" step="0.5" value="3">
|
||||
<label id="name-height-label" style="color:var(--text-dim);">高度</label>
|
||||
<input id="name-height" type="number" step="0.5" value="3">
|
||||
<span style="color:var(--text-dim);">m</span>
|
||||
</div>
|
||||
<div id="name-connections-row" style="display:none;">
|
||||
@@ -191,6 +191,17 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
|
||||
|
||||
<input type="file" id="file-input" accept=".json,.zip" style="display:none">
|
||||
|
||||
<!-- 从 output/ 加载的文件选择弹窗 -->
|
||||
<div id="output-modal" style="display:none;position:fixed;z-index:100;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);">
|
||||
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d2d;border:1px solid #666;border-radius:8px;padding:16px;min-width:320px;max-height:60vh;display:flex;flex-direction:column;">
|
||||
<h4 style="margin-bottom:10px;font-size:13px;color:#ddd;">从 output/ 加载</h4>
|
||||
<div id="output-file-list" style="flex:1;overflow-y:auto;max-height:300px;"></div>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:10px;">
|
||||
<button class="tbtn" id="output-modal-close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
@@ -256,6 +267,8 @@ const App = {
|
||||
this.initKeyboard();
|
||||
this.initLevel("L1", 500, 400, this.defaultL1.canvasW, this.defaultL1.canvasH);
|
||||
this.switchToLevel("L1");
|
||||
// 缓存服务器可用性(首次加载时检测)
|
||||
this._serverOk = this.serverAvailable();
|
||||
},
|
||||
|
||||
initLevel(id, wW, wH, cW, cH) {
|
||||
@@ -303,6 +316,7 @@ const App = {
|
||||
this.renderBrushPalette();
|
||||
this.updateBreadcrumb();
|
||||
this.updateElementsList();
|
||||
this.drawElementLabels();
|
||||
this.updateScaleDisplay();
|
||||
this.updateStatus();
|
||||
},
|
||||
@@ -356,7 +370,10 @@ const App = {
|
||||
document.getElementById("btn-redo").addEventListener("click", () => this.redo());
|
||||
document.getElementById("btn-clear").addEventListener("click", () => this.clearCanvas());
|
||||
document.getElementById("btn-save-all").addEventListener("click", () => this.saveAll());
|
||||
document.getElementById("btn-load").addEventListener("click", () => document.getElementById("file-input").click());
|
||||
document.getElementById("btn-load").addEventListener("click", () => this.smartLoad());
|
||||
document.getElementById("output-modal-close").addEventListener("click", () => {
|
||||
document.getElementById("output-modal").style.display = "none";
|
||||
});
|
||||
},
|
||||
|
||||
/* ---- Canvas 事件 ---- */
|
||||
@@ -619,7 +636,7 @@ const App = {
|
||||
|
||||
this.undoStack = []; this.redoStack = []; this.updateUndoButtons();
|
||||
this.wW.value = l2d.worldW; this.wH.value = l2d.worldH;
|
||||
this.drawGrid(); this.renderBrushPalette(); this.updateBreadcrumb(); this.updateElementsList(); this.updateScaleDisplay(); this.updateStatus();
|
||||
this.drawGrid(); this.renderBrushPalette(); this.updateBreadcrumb(); this.updateElementsList(); this.drawElementLabels(); this.updateScaleDisplay(); this.updateStatus();
|
||||
},
|
||||
|
||||
compositeL2BackToL1() {
|
||||
@@ -685,9 +702,19 @@ const App = {
|
||||
|
||||
// 高度输入:building / blocked / zone 类型显示
|
||||
const showHeight = ["building", "blocked", "zone"].includes(brushType.id);
|
||||
const heightLabel = document.getElementById("name-height-label");
|
||||
heightRow.style.display = showHeight ? "flex" : "none";
|
||||
if (showHeight) {
|
||||
heightInp.value = existing && existing.height_m != null ? existing.height_m : this.defaultHeightForType(brushType.id);
|
||||
// L1 zone = 地形标高(允许负值);L2 building = 建筑高度
|
||||
if (this.currentLevel === "L1" && brushType.id === "zone") {
|
||||
heightLabel.textContent = "地形标高";
|
||||
heightInp.min = "-10";
|
||||
heightInp.value = existing && existing.height_m != null ? existing.height_m : 0;
|
||||
} else {
|
||||
heightLabel.textContent = "建筑高度";
|
||||
heightInp.min = "0";
|
||||
heightInp.value = existing && existing.height_m != null ? existing.height_m : this.defaultHeightForType(brushType.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 连通多选:列出同层级其他已命名元素
|
||||
@@ -791,14 +818,26 @@ const App = {
|
||||
drawElementLabels() {
|
||||
const ctx = this.uc.getContext("2d"), d = this.levelData[this.currentLevel];
|
||||
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
|
||||
const isL1 = this.currentLevel === "L1";
|
||||
d.elements.forEach(el => {
|
||||
// L1 只显示区域(zone)名,L2 只显示建筑(building)名
|
||||
if (isL1 && el.type !== "zone") return;
|
||||
if (!isL1 && el.type !== "building") return;
|
||||
|
||||
ctx.font = "bold 12px sans-serif";
|
||||
const tw = ctx.measureText(el.name).width + 8, th = 18;
|
||||
// L1: height_m = 地形标高;L2: height_m = 建筑高度
|
||||
let label = el.name;
|
||||
if (el.height_m != null && el.height_m !== 0) {
|
||||
label = isL1
|
||||
? `${el.name} (标高${el.height_m}m)`
|
||||
: `${el.name} (${el.height_m}m)`;
|
||||
}
|
||||
const tw = ctx.measureText(label).width + 8, th = 18;
|
||||
const tx = el.px - tw/2, ty = el.py - 28;
|
||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
||||
ctx.fillRect(tx, ty, tw, th);
|
||||
ctx.fillStyle = "#fff"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||||
ctx.fillText(el.name, el.px, ty + th/2);
|
||||
ctx.fillText(label, el.px, ty + th/2);
|
||||
if (el.hasDetail) {
|
||||
ctx.fillStyle = "rgba(74,158,255,0.8)";
|
||||
ctx.beginPath(); ctx.arc(el.px + tw/2 - 2, ty + 2, 5, 0, Math.PI*2); ctx.fill();
|
||||
@@ -929,8 +968,31 @@ const App = {
|
||||
zip.file(`${name}-scene.json`, JSON.stringify(sceneData, null, 2));
|
||||
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
this.downloadBlob(blob, `${name}-scene.zip`);
|
||||
this.showSaveFeedback("ZIP 已保存 ✓");
|
||||
|
||||
// 优先 POST 到本地服务器,失败则回退浏览器下载
|
||||
const savedToServer = await this.saveToServer(`${name}-scene.zip`, blob);
|
||||
if (!savedToServer) {
|
||||
this.downloadBlob(blob, `${name}-scene.zip`);
|
||||
}
|
||||
this.showSaveFeedback(savedToServer ? `已保存到 output/ ✓` : "ZIP 已下载 ✓");
|
||||
},
|
||||
|
||||
/** 尝试通过本地服务器保存到 output/ 目录 */
|
||||
async saveToServer(filename, blob) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, filename);
|
||||
const resp = await fetch("/save", { method: "POST", body: fd });
|
||||
return resp.ok;
|
||||
} catch { return false; }
|
||||
},
|
||||
|
||||
/** 服务器是否可用 */
|
||||
async serverAvailable() {
|
||||
try {
|
||||
const resp = await fetch("/list", { method: "GET" });
|
||||
return resp.ok;
|
||||
} catch { return false; }
|
||||
},
|
||||
|
||||
showSaveFeedback(msg) {
|
||||
@@ -961,6 +1023,69 @@ const App = {
|
||||
}
|
||||
},
|
||||
|
||||
/** 弹出 output/ 文件选择列表 */
|
||||
async showOutputModal() {
|
||||
try {
|
||||
const resp = await fetch("/list");
|
||||
const { files } = await resp.json();
|
||||
const modal = document.getElementById("output-modal");
|
||||
const list = document.getElementById("output-file-list");
|
||||
list.innerHTML = "";
|
||||
if (files.length === 0) {
|
||||
list.innerHTML = '<div style="color:#999;font-size:12px;padding:8px;">output/ 目录为空</div>';
|
||||
} else {
|
||||
files.forEach(f => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "padding:6px 8px;cursor:pointer;border-radius:3px;font-size:12px;display:flex;align-items:center;gap:8px;";
|
||||
row.addEventListener("mouseenter", () => row.style.background = "#3a3a3a");
|
||||
row.addEventListener("mouseleave", () => row.style.background = "");
|
||||
const icon = f.endsWith(".zip") ? "📦" : "📄";
|
||||
row.innerHTML = `<span>${icon}</span><span style="flex:1;color:#ddd;">${f}</span>`;
|
||||
row.addEventListener("click", async () => {
|
||||
modal.style.display = "none";
|
||||
await this.loadFromServer(f);
|
||||
});
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
modal.style.display = "block";
|
||||
} catch {
|
||||
alert("无法连接本地服务器,请确认 server.py 正在运行");
|
||||
}
|
||||
},
|
||||
|
||||
/** 从服务器 output/ 加载文件 */
|
||||
async loadFromServer(filename) {
|
||||
try {
|
||||
const resp = await fetch("/load/" + encodeURIComponent(filename));
|
||||
if (!resp.ok) throw new Error("文件不存在");
|
||||
if (filename.endsWith(".zip")) {
|
||||
const ab = await resp.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(ab);
|
||||
const jsonFile = Object.keys(zip.files).find(n => n.endsWith("-scene.json"));
|
||||
if (!jsonFile) { alert("ZIP 中未找到 scene.json"); return; }
|
||||
const txt = await zip.file(jsonFile).async("string");
|
||||
await this.restoreScene(JSON.parse(txt), zip);
|
||||
} else {
|
||||
const txt = await resp.text();
|
||||
await this.restoreScene(JSON.parse(txt), null);
|
||||
}
|
||||
this.showSaveFeedback(`已加载 ${filename} ✓`);
|
||||
} catch (err) {
|
||||
alert("加载失败:" + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
/** 智能加载:服务器在则从 output/ 读,否则回退浏览器文件选择 */
|
||||
async smartLoad() {
|
||||
const ok = await this._serverOk;
|
||||
if (ok) {
|
||||
this.showOutputModal();
|
||||
} else {
|
||||
document.getElementById("file-input").click();
|
||||
}
|
||||
},
|
||||
|
||||
/** 从 PNG Blob 恢复 ImageData */
|
||||
blobToImageData(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
title World Sketcher
|
||||
|
||||
where python >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Python not found. Install Python 3 first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set PORT=8765
|
||||
if not "%~1"=="" set PORT=%~1
|
||||
|
||||
echo.
|
||||
echo World Sketcher
|
||||
echo Port: %PORT%
|
||||
echo Save dir: output\
|
||||
echo Close this window to stop.
|
||||
echo.
|
||||
|
||||
start "" cmd /c "timeout /t 2 /nobreak >nul & start http://localhost:%PORT%/sketch.html"
|
||||
|
||||
python "%~dp0server.py" %PORT%
|
||||
Reference in New Issue
Block a user