Land Explorer in HomeCamp with boot loading, journal UI, and CDN publish tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][ValidateSet('Check','Plan','Prepare','Publish','Rollback')][string]$Action,
|
||||
[Parameter(Mandatory=$true)][string]$RequestBase64,
|
||||
[string]$StateRoot = ''
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
|
||||
$request = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($RequestBase64)) | ConvertFrom-Json
|
||||
|
||||
function Hash([string]$Path) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
function Under([string]$Root, [string]$Relative) {
|
||||
$full = [IO.Path]::GetFullPath((Join-Path $Root $Relative))
|
||||
$prefix = $Root.TrimEnd('\','/') + [IO.Path]::DirectorySeparatorChar
|
||||
if (-not $full.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Path escapes release root: $Relative" }
|
||||
return $full
|
||||
}
|
||||
function Resolve-WebRoot([string]$Path) {
|
||||
if (-not [IO.Path]::IsPathRooted($Path)) { throw 'Website root must be an absolute path' }
|
||||
$resolved = (Resolve-Path -LiteralPath $Path).Path.TrimEnd('\','/')
|
||||
if ($resolved.Length -le 3 -or -not (Test-Path -LiteralPath $resolved -PathType Container)) { throw 'Invalid website root' }
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $resolved 'cdn/version.json')) -and
|
||||
-not (Get-ChildItem -LiteralPath $resolved -Filter '*.webgl.data.unityweb.bin.txt' -File | Select-Object -First 1)) {
|
||||
throw 'Target is not an existing WebGL CDN website (no version or startup data)'
|
||||
}
|
||||
return $resolved
|
||||
}
|
||||
function Validate-Files($Files) {
|
||||
$seen = @{}
|
||||
foreach ($item in @($Files)) {
|
||||
$name = [string]$item.path
|
||||
if ($name -ne 'cdn/version.json' -and $name -notmatch '^yoo/(Art|DefaultPackage)/[A-Za-z0-9_.-]+$' -and
|
||||
$name -cnotmatch '^[a-f0-9]{16}\.webgl\.data\.unityweb\.bin\.txt$') { throw "Unsupported release path: $name" }
|
||||
if ($name.Split('/') -contains '..' -or $seen.ContainsKey($name)) { throw "Unsafe or duplicate path: $name" }
|
||||
if ($item.sha256 -notmatch '^[a-f0-9]{64}$' -or [long]$item.size -lt 0) { throw "Invalid file metadata: $name" }
|
||||
$seen[$name] = $item
|
||||
}
|
||||
if (-not $seen.ContainsKey('cdn/version.json')) { throw 'Release has no global version.json' }
|
||||
return $seen
|
||||
}
|
||||
function Atomic-Copy([string]$Source, [string]$Target) {
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $Target) -Force | Out-Null
|
||||
$temporary = "$Target.upload-$([Guid]::NewGuid().ToString('N')).tmp"
|
||||
try {
|
||||
[IO.File]::Copy($Source, $temporary, $false)
|
||||
if (Test-Path -LiteralPath $Target) { [IO.File]::Replace($temporary, $Target, [System.Management.Automation.Language.NullString]::Value) }
|
||||
else { [IO.File]::Move($temporary, $Target) }
|
||||
} finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } }
|
||||
}
|
||||
|
||||
try {
|
||||
$root = Resolve-WebRoot $request.root
|
||||
$current = Join-Path $root 'cdn/version.json'
|
||||
$base = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'AIGameCdnPublish'
|
||||
if ($StateRoot) { $base = [IO.Path]::GetFullPath($StateRoot) }
|
||||
if ($Action -eq 'Check') {
|
||||
@{ connected=$true; user=[Environment]::UserName; root=$root; currentVersionSha256=(Hash $current) } | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}
|
||||
if ($request.releaseId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid release ID' }
|
||||
$folder = Join-Path $base $request.releaseId
|
||||
$archivePath = Join-Path $folder 'release.zip'
|
||||
if ($Action -eq 'Plan') {
|
||||
$files = Validate-Files $request.files
|
||||
$changed = @($request.files | Where-Object {
|
||||
$target = Under $root $_.path
|
||||
-not (Test-Path -LiteralPath $target -PathType Leaf) -or
|
||||
(Get-Item -LiteralPath $target).Length -ne [long]$_.size -or (Hash $target) -ne $_.sha256
|
||||
} | ForEach-Object { $_.path })
|
||||
@{ changed=$changed; currentVersionSha256=(Hash $current); root=$root } | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}
|
||||
New-Item -ItemType Directory -Path $base -Force | Out-Null
|
||||
if ($Action -eq 'Prepare') {
|
||||
$null = Validate-Files $request.files
|
||||
if (Test-Path -LiteralPath $folder) { throw 'Release staging directory already exists' }
|
||||
New-Item -ItemType Directory -Path $folder | Out-Null
|
||||
@{ archive=$archivePath } | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}
|
||||
# The lock is outside the public website and covers both publishing and rollback.
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $rootId = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($root.ToLowerInvariant()))).Replace('-','') }
|
||||
finally { $sha.Dispose() }
|
||||
$lockPath = Join-Path $base "$rootId.lock"
|
||||
$lock = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
|
||||
try {
|
||||
if ($Action -eq 'Rollback') {
|
||||
$receipt = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $folder 'receipt.json') | ConvertFrom-Json
|
||||
if ($receipt.root -ne $root -or (Hash $current) -ne $receipt.versionSha256) { throw 'A different version is live; rollback refused' }
|
||||
$previous = Join-Path $folder 'previous.version.json'
|
||||
if (-not (Test-Path -LiteralPath $previous)) { throw 'This release has no previous version to restore' }
|
||||
$version = Get-Content -Raw -Encoding UTF8 -LiteralPath $previous | ConvertFrom-Json
|
||||
foreach ($package in @($version.packages)) {
|
||||
if ($package -notin @('Art','DefaultPackage')) { throw 'Unexpected previous package' }
|
||||
$versionId = if ($package -eq 'Art') { $version.resVersion } else { $version.packVersion }
|
||||
if ($versionId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid previous manifest version' }
|
||||
$index = Under $root "yoo/$package/${package}_$versionId.bytes"
|
||||
if (-not (Test-Path -LiteralPath $index) -or (Get-FileHash -LiteralPath $index -Algorithm MD5).Hash.ToLowerInvariant() -ne $versionId) {
|
||||
throw "Previous manifest missing or corrupt: $package"
|
||||
}
|
||||
}
|
||||
Atomic-Copy $previous $current
|
||||
@{ rolledBack=$true; releaseId=$request.releaseId; version=$version } | ConvertTo-Json -Depth 20 -Compress
|
||||
exit 0
|
||||
}
|
||||
$files = Validate-Files $request.files
|
||||
if ((Hash $current) -ne $request.expectedVersionSha256) { throw 'Live version changed since planning; make a fresh plan' }
|
||||
if (Test-Path -LiteralPath (Join-Path $folder 'receipt.json')) { throw 'Release was already committed; inspect its receipt' }
|
||||
$unpacked = Join-Path $folder 'files'
|
||||
if (Test-Path -LiteralPath $unpacked) { throw 'Release already extracted; start a fresh release' }
|
||||
New-Item -ItemType Directory -Path $unpacked | Out-Null
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($archivePath)
|
||||
$seen = @{}
|
||||
try {
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$name = $entry.FullName
|
||||
if (-not $files.ContainsKey($name) -or $seen.ContainsKey($name) -or $entry.Length -ne [long]$files[$name].size) { throw "Unexpected ZIP entry: $name" }
|
||||
$destination = Under $unpacked $name
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null
|
||||
$inputStream = $entry.Open()
|
||||
$outputStream = [IO.File]::Open($destination, [IO.FileMode]::CreateNew)
|
||||
try { $inputStream.CopyTo($outputStream) } finally { $inputStream.Dispose(); $outputStream.Dispose() }
|
||||
if ((Hash $destination) -ne $files[$name].sha256) { throw "Uploaded SHA256 mismatch: $name" }
|
||||
$seen[$name] = $true
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
# Validate the entire release, including unchanged server files, before any website writes.
|
||||
foreach ($item in @($request.files)) {
|
||||
$source = if ($seen.ContainsKey($item.path)) { Under $unpacked $item.path } else { Under $root $item.path }
|
||||
if (-not (Test-Path -LiteralPath $source -PathType Leaf) -or (Get-Item -LiteralPath $source).Length -ne [long]$item.size -or (Hash $source) -ne $item.sha256) {
|
||||
throw "Missing or corrupt release file: $($item.path)"
|
||||
}
|
||||
}
|
||||
$versionSource = if ($seen.ContainsKey('cdn/version.json')) { Under $unpacked 'cdn/version.json' } else { $current }
|
||||
$version = Get-Content -Raw -Encoding UTF8 -LiteralPath $versionSource | ConvertFrom-Json
|
||||
if ($version.cdnRoot -ne $request.version.cdnRoot -or $version.packVersion -ne $request.version.packVersion -or
|
||||
$version.resVersion -ne $request.version.resVersion -or (@($version.packages) -join ',') -ne (@($request.version.packages) -join ',')) {
|
||||
throw 'Global version payload does not match the request'
|
||||
}
|
||||
foreach ($package in @($version.packages)) {
|
||||
if ($package -notin @('Art','DefaultPackage')) { throw 'Unexpected package name' }
|
||||
$versionId = if ($package -eq 'Art') { $version.resVersion } else { $version.packVersion }
|
||||
if ($versionId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid package version' }
|
||||
$name = "yoo/$package/${package}_$versionId.bytes"
|
||||
if (-not $files.ContainsKey($name)) { throw "Manifest absent from release: $name" }
|
||||
$source = if ($seen.ContainsKey($name)) { Under $unpacked $name } else { Under $root $name }
|
||||
if ((Get-FileHash -LiteralPath $source -Algorithm MD5).Hash.ToLowerInvariant() -ne $versionId) { throw "Manifest MD5 mismatch: $package" }
|
||||
}
|
||||
if (Test-Path -LiteralPath $current) { [IO.File]::Copy($current, (Join-Path $folder 'previous.version.json'), $false) }
|
||||
foreach ($name in @($seen.Keys | Where-Object { $_ -ne 'cdn/version.json' } | Sort-Object)) {
|
||||
Atomic-Copy (Under $unpacked $name) (Under $root $name)
|
||||
}
|
||||
# Commit marker is always last. All older content-addressed resources remain available.
|
||||
if ($seen.ContainsKey('cdn/version.json')) { Atomic-Copy (Under $unpacked 'cdn/version.json') $current }
|
||||
$receipt = @{ published=$true; releaseId=$request.releaseId; root=$root; version=$version;
|
||||
changedFiles=$seen.Count; versionSha256=(Hash $current); publishedUtc=[DateTime]::UtcNow.ToString('o') }
|
||||
$receiptJson = $receipt | ConvertTo-Json -Depth 20 -Compress
|
||||
[IO.File]::WriteAllText((Join-Path $folder 'receipt.json'), $receiptJson, (New-Object Text.UTF8Encoding($false)))
|
||||
Write-Output $receiptJson
|
||||
} finally { $lock.Dispose() }
|
||||
} catch {
|
||||
[Console]::Error.WriteLine($_.Exception.Message)
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Shared visual direction for the SDK cover and the local Unity loading prefab.
|
||||
export default {
|
||||
totalLaunchTime: 7000,
|
||||
animationDuration: 100,
|
||||
designWidth: 1080,
|
||||
designHeight: 1920,
|
||||
scaleMode: 'NO_BORDER',
|
||||
textConfig: {
|
||||
firstStartText: '首次进入需要下载资源,请稍候',
|
||||
downloadingText: ['正在下载资源…'],
|
||||
compilingText: '正在准备冒险…',
|
||||
initText: '正在布置营地…',
|
||||
completeText: '准备就绪,即将出发',
|
||||
textDuration: 1500,
|
||||
style: { bottom: 225, height: 50, width: 920, lineHeight: 50, color: '#FFF8E8', fontSize: 32 },
|
||||
},
|
||||
barConfig: {
|
||||
style: { width: 780, height: 30, padding: 3, bottom: 160, backgroundColor: '#7ED321' },
|
||||
},
|
||||
iconConfig: { visible: false, style: { width: 64, height: 23, bottom: 20 } },
|
||||
materialConfig: { backgroundImage: 'images/startup-cover.png', backgroundVideo: '', iconImage: 'images/unity_logo.png' },
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"host": "159.75.128.109",
|
||||
"user": "Administrator",
|
||||
"port": 22,
|
||||
"identity_file": "Tools/.cdn-deploy/id_ed25519",
|
||||
"remote_root": "C:\\XWorld\\CDN\\Friends\\webgl",
|
||||
"public_base": "https://www.xworld.ren/game/Friends/webgl"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
import bpy
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=r"D:/UD/AI/XModel/LLM3DGraphics/mixamo/Male/Idle.fbx")
|
||||
print("Actions in Idle.fbx:", [a.name for a in bpy.data.actions])
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=r"D:/UD/AI/XModel/LLM3DGraphics/mixamo/Male/Walking.fbx")
|
||||
print("Actions in Walking.fbx:", [a.name for a in bpy.data.actions])
|
||||
@@ -0,0 +1,73 @@
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:8008"
|
||||
JOB_ID = "25f0f3ce2d124e748bc3e8415e7289c7"
|
||||
OUTPUT_DIR = Path("Tools/output/explorer_3d")
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
def wait_job(timeout=600, interval=2):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
r = session.get(f"{BASE}/api/jobs/{JOB_ID}", timeout=30)
|
||||
r.raise_for_status()
|
||||
job = r.json()
|
||||
status = job.get("status")
|
||||
stage = job.get("stage", "")
|
||||
progress = job.get("progress", 0)
|
||||
print(f"[{status}] stage={stage} progress={progress}%", flush=True)
|
||||
if status in ("completed", "failed"):
|
||||
if status != "completed" or job.get("error"):
|
||||
raise RuntimeError(f"Animation failed: {job.get('error') or job}")
|
||||
return job
|
||||
time.sleep(interval)
|
||||
raise TimeoutError("Animation timeout")
|
||||
|
||||
def download(url, out_path):
|
||||
r = session.get(f"{BASE}{url}", timeout=300)
|
||||
r.raise_for_status()
|
||||
out = Path(out_path)
|
||||
if r.content[:4] == b"PK\x03\x04":
|
||||
out = out.with_suffix(".zip")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(r.content)
|
||||
print(f"Downloaded: {out} ({len(r.content)} bytes)")
|
||||
return out
|
||||
|
||||
# 1. Walk Animation
|
||||
print("\n=== Generating Walk Animation ===")
|
||||
r = session.post(f"{BASE}/api/jobs/{JOB_ID}/animate", data={
|
||||
"motion_prompt": "A person walks forward at a steady pace.",
|
||||
"seed": 42,
|
||||
"cfg_scale": 5,
|
||||
"in_place": "true"
|
||||
})
|
||||
print("Walk trigger:", r.status_code, r.text[:200])
|
||||
walk_job = wait_job()
|
||||
walk_dir = OUTPUT_DIR / "walk"
|
||||
walk_dir.mkdir(parents=True, exist_ok=True)
|
||||
for k, u in walk_job.get("downloads", {}).items():
|
||||
if "anim" in k:
|
||||
download(u, walk_dir / (k + (".fbx" if "fbx" in k else ".glb")))
|
||||
|
||||
# 2. Idle Animation
|
||||
print("\n=== Generating Idle Animation ===")
|
||||
r = session.post(f"{BASE}/api/jobs/{JOB_ID}/animate", data={
|
||||
"motion_prompt": "A person stands calmly in an idle breathing pose.",
|
||||
"seed": 100,
|
||||
"cfg_scale": 5,
|
||||
"in_place": "true"
|
||||
})
|
||||
print("Idle trigger:", r.status_code, r.text[:200])
|
||||
idle_job = wait_job()
|
||||
idle_dir = OUTPUT_DIR / "idle"
|
||||
idle_dir.mkdir(parents=True, exist_ok=True)
|
||||
for k, u in idle_job.get("downloads", {}).items():
|
||||
if "anim" in k:
|
||||
download(u, idle_dir / (k + (".fbx" if "fbx" in k else ".glb")))
|
||||
|
||||
print("\nBoth animations generated and downloaded successfully!")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
fbx_in = r"Tools/output/explorer_3d/mixamo_extracted/model.fbx"
|
||||
fbx_out = r"AIClient/Assets/Bundles/Characters/Human/Explorer/Explorer.fbx"
|
||||
|
||||
bpy.ops.import_scene.fbx(filepath=fbx_in)
|
||||
|
||||
# Find armature and mesh
|
||||
armature = None
|
||||
mesh_objs = []
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
armature = obj
|
||||
elif obj.type == 'MESH':
|
||||
mesh_objs.append(obj)
|
||||
|
||||
print("Imported armature:", armature.name if armature else "None")
|
||||
print("Imported meshes:", [m.name for m in mesh_objs])
|
||||
|
||||
# Calculate bounds of meshes
|
||||
min_y = float('inf')
|
||||
for obj in mesh_objs:
|
||||
for v in obj.data.vertices:
|
||||
world_co = obj.matrix_world @ v.co
|
||||
if world_co.z < min_y: # In Blender Z is up
|
||||
min_y = world_co.z
|
||||
|
||||
print("Mesh lowest Z in Blender:", min_y)
|
||||
|
||||
# Export FBX with Y-up, -Z forward in blender convention (which converts to +Z in Unity)
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=fbx_out,
|
||||
use_selection=False,
|
||||
global_scale=1.0,
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options='FBX_SCALE_NONE',
|
||||
bake_space_transform=False,
|
||||
object_types={'ARMATURE', 'MESH'},
|
||||
use_mesh_modifiers=True,
|
||||
mesh_smooth_type='FACE',
|
||||
add_leaf_bones=False,
|
||||
primary_bone_axis='Y',
|
||||
secondary_bone_axis='X',
|
||||
axis_forward='-Z',
|
||||
axis_up='Y'
|
||||
)
|
||||
print("Exported normalized Explorer.fbx")
|
||||
@@ -0,0 +1,98 @@
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ext_dir = Path("Tools/output/explorer_3d/mixamo_extracted")
|
||||
target_dir = Path("AIClient/Assets/Bundles/Characters/Human/Explorer")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. Synthesize _base.png and _mix.png
|
||||
print("Synthesizing PBR textures...")
|
||||
diffuse = Image.open(ext_dir / "diffuse.png").convert("RGBA")
|
||||
diffuse.save(target_dir / "Explorer_base.png")
|
||||
|
||||
metallic = Image.open(ext_dir / "metallic.png").convert("L")
|
||||
roughness = Image.open(ext_dir / "roughness.png").convert("L")
|
||||
ao = Image.open(ext_dir / "ao.png").convert("L")
|
||||
|
||||
mix = Image.merge("RGBA", (
|
||||
metallic,
|
||||
roughness,
|
||||
ao,
|
||||
Image.new("L", diffuse.size, 255)
|
||||
))
|
||||
mix.save(target_dir / "Explorer_mix.png")
|
||||
print("Saved Explorer_base.png and Explorer_mix.png")
|
||||
|
||||
# 2. Blender script to inspect and ensure bottom centered, Y-up, +Z front
|
||||
blender_script = """
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
fbx_in = r"Tools/output/explorer_3d/mixamo_extracted/model.fbx"
|
||||
fbx_out = r"AIClient/Assets/Bundles/Characters/Human/Explorer/Explorer.fbx"
|
||||
|
||||
bpy.ops.import_scene.fbx(filepath=fbx_in)
|
||||
|
||||
# Find armature and mesh
|
||||
armature = None
|
||||
mesh_objs = []
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
armature = obj
|
||||
elif obj.type == 'MESH':
|
||||
mesh_objs.append(obj)
|
||||
|
||||
print("Imported armature:", armature.name if armature else "None")
|
||||
print("Imported meshes:", [m.name for m in mesh_objs])
|
||||
|
||||
# Calculate bounds of meshes
|
||||
min_y = float('inf')
|
||||
for obj in mesh_objs:
|
||||
for v in obj.data.vertices:
|
||||
world_co = obj.matrix_world @ v.co
|
||||
if world_co.z < min_y: # In Blender Z is up
|
||||
min_y = world_co.z
|
||||
|
||||
print("Mesh lowest Z in Blender:", min_y)
|
||||
|
||||
# Export FBX with Y-up, -Z forward in blender convention (which converts to +Z in Unity)
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=fbx_out,
|
||||
use_selection=False,
|
||||
global_scale=1.0,
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options='FBX_SCALE_NONE',
|
||||
bake_space_transform=False,
|
||||
object_types={'ARMATURE', 'MESH'},
|
||||
use_mesh_modifiers=True,
|
||||
mesh_smooth_type='FACE',
|
||||
add_leaf_bones=False,
|
||||
primary_bone_axis='Y',
|
||||
secondary_bone_axis='X',
|
||||
axis_forward='-Z',
|
||||
axis_up='Y'
|
||||
)
|
||||
print("Exported normalized Explorer.fbx")
|
||||
"""
|
||||
|
||||
blender_script_path = Path("Tools/normalize_explorer_fbx.py")
|
||||
blender_script_path.write_text(blender_script, encoding="utf-8")
|
||||
|
||||
blender_exe = r"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe"
|
||||
print("Running Blender FBX normalization...")
|
||||
res = subprocess.run([blender_exe, "--background", "--python", str(blender_script_path)], capture_output=True, text=True)
|
||||
print(res.stdout[-600:])
|
||||
if res.returncode != 0:
|
||||
print(res.stderr)
|
||||
raise RuntimeError("Blender export failed")
|
||||
|
||||
# 3. Copy motion files
|
||||
import shutil
|
||||
mixamo_source = Path(r"D:\UD\AI\XModel\LLM3DGraphics\mixamo\Male")
|
||||
shutil.copy(mixamo_source / "Idle.fbx", target_dir / "Explorer@Idle.fbx")
|
||||
shutil.copy(mixamo_source / "Walking.fbx", target_dir / "Explorer@Walk.fbx")
|
||||
print("Copied Explorer@Idle.fbx and Explorer@Walk.fbx to target directory.")
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish the staged WebGL Art and hot-update packages over Windows OpenSSH.
|
||||
|
||||
No password handling, remote deletion, or shell-built SCP commands. The remote
|
||||
transaction verifies every file before exposing cdn/version.json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_CONFIG = REPO / "Tools/cdn_publish.local.json"
|
||||
OUTPUT = REPO / "Tools/output/cdn_publish"
|
||||
REMOTE_SCRIPT = REPO / "Tools/Publish-CdnRelease.ps1"
|
||||
HASH32 = re.compile(r"[a-f0-9]{32}\Z")
|
||||
RESOURCE_PATH = re.compile(r"yoo/(Art|DefaultPackage)/[A-Za-z0-9_.-]+\Z")
|
||||
STARTUP_PATH = re.compile(r"[a-f0-9]{16}\.webgl\.data\.unityweb\.bin\.txt\Z")
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
|
||||
|
||||
def write_json(path: Path, value: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def digest(path: Path, algorithm="sha256") -> str:
|
||||
result = hashlib.new(algorithm)
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
result.update(block)
|
||||
return result.hexdigest()
|
||||
|
||||
|
||||
def validate_path(path: str) -> None:
|
||||
if path != "cdn/version.json" and not RESOURCE_PATH.fullmatch(path) and not STARTUP_PATH.fullmatch(path):
|
||||
raise ValueError(f"Unsupported release path: {path}")
|
||||
if any(part in (".", "..") for part in path.split("/")):
|
||||
raise ValueError(f"Unsafe release path: {path}")
|
||||
|
||||
|
||||
def make_plan(stage: Path, destination: Path, startup_export: Path | None = None) -> dict:
|
||||
version_file = stage / "version.prod.json"
|
||||
version = read_json(version_file)
|
||||
if version.get("cdnRoot") != "https://www.xworld.ren/game/Friends/webgl/yoo/DefaultPackage":
|
||||
raise ValueError("Staged production version has an unexpected CDN root")
|
||||
packages = version.get("packages")
|
||||
if not isinstance(packages, list) or "DefaultPackage" not in packages or len(packages) != len(set(packages)):
|
||||
raise ValueError("Invalid package list")
|
||||
app = version.get("appVersionMax", "")
|
||||
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", app):
|
||||
raise ValueError("Invalid appVersionMax")
|
||||
files = []
|
||||
|
||||
def add(source: Path, relative: str):
|
||||
validate_path(relative)
|
||||
if not source.is_file():
|
||||
raise ValueError(f"Missing staged file: {source}")
|
||||
files.append({"path": relative, "source": str(source.resolve()),
|
||||
"size": source.stat().st_size, "sha256": digest(source)})
|
||||
|
||||
for package in packages:
|
||||
field = {"Art": "resVersion", "DefaultPackage": "packVersion"}.get(package)
|
||||
if field is None or not HASH32.fullmatch(version.get(field, "")):
|
||||
raise ValueError(f"Invalid package/version: {package}")
|
||||
package_version = version[field]
|
||||
directory = stage / app / package
|
||||
index = directory / f"{package}_{package_version}.bytes"
|
||||
if not index.is_file() or digest(index, "md5") != package_version:
|
||||
raise ValueError(f"Manifest MD5 mismatch or missing index: {index}")
|
||||
marker = directory / f"{package}.version"
|
||||
if marker.read_text(encoding="utf-8-sig").strip() != package_version:
|
||||
raise ValueError(f"Package version marker mismatch: {marker}")
|
||||
checksum = directory / f"{package}_{package_version}.hash"
|
||||
expected_crc = zlib.crc32(index.read_bytes()).to_bytes(4, "little").hex()
|
||||
if checksum.read_text(encoding="utf-8-sig").strip().lower() != expected_crc:
|
||||
raise ValueError(f"Package hash marker mismatch: {checksum}")
|
||||
selected = [marker, index, checksum]
|
||||
selected.extend(sorted(p for p in directory.iterdir() if p.suffix in (".bundle", ".rawfile")))
|
||||
if len(selected) <= 3:
|
||||
raise ValueError(f"No payload files in {directory}")
|
||||
for source in selected:
|
||||
add(source, f"yoo/{package}/{source.name}")
|
||||
if startup_export is not None:
|
||||
game = (startup_export / "minigame/game.js").read_text(encoding="utf-8-sig")
|
||||
hashes = re.findall(r"\bDATA_FILE_MD5\s*:\s*['\"]([a-f0-9]{16})['\"]", game)
|
||||
sizes = re.findall(r"\bDATA_FILE_SIZE\s*:\s*['\"]([0-9]+)['\"]", game)
|
||||
roots = re.findall(r"\bDATA_CDN\s*:\s*['\"]([^'\"]+)['\"]", game)
|
||||
if len(hashes) != 1 or len(sizes) != 1 or roots != ["https://www.xworld.ren/game/Friends/webgl/"]:
|
||||
raise ValueError("Cannot identify the exported startup data/CDN")
|
||||
name = hashes[0] + ".webgl.data.unityweb.bin.txt"
|
||||
source = startup_export / "webgl" / name
|
||||
# WX UnityUtil.BuildFileMd5 uses the middle 16 characters of MD5.
|
||||
if not source.is_file() or source.stat().st_size != int(sizes[0]) or digest(source, "md5")[8:24] != hashes[0]:
|
||||
raise ValueError("Exported startup data size or MD5 mismatch")
|
||||
add(source, name)
|
||||
add(version_file, "cdn/version.json")
|
||||
plan = {"schema": 1, "version": version, "files": files,
|
||||
"totalBytes": sum(f["size"] for f in files)}
|
||||
write_json(destination, plan)
|
||||
return plan
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict:
|
||||
config = read_json(path)
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+", config.get("host", "")) or config["host"].startswith("-"):
|
||||
raise ValueError("Invalid SSH host")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+", config.get("user", "")):
|
||||
raise ValueError("Invalid SSH user")
|
||||
if not isinstance(config.get("port"), int) or not 1 <= config["port"] <= 65535:
|
||||
raise ValueError("Invalid SSH port")
|
||||
key = Path(config["identity_file"]).expanduser()
|
||||
config["identity_file"] = str(key if key.is_absolute() else REPO / key)
|
||||
if not config.get("remote_root") or not config.get("public_base", "").startswith("https://"):
|
||||
raise ValueError("Missing remote_root or HTTPS public_base")
|
||||
return config
|
||||
|
||||
|
||||
def ssh_options(config: dict, scp=False) -> list[str]:
|
||||
return ["-P" if scp else "-p", str(config["port"]),
|
||||
"-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "IdentitiesOnly=yes", "-i", config["identity_file"]]
|
||||
|
||||
|
||||
def remote(config: dict, action: str, request: dict) -> dict:
|
||||
payload = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
|
||||
script = REMOTE_SCRIPT.read_text(encoding="utf-8-sig")
|
||||
code = f"& {{\n{script}\n}} -Action {action} -RequestBase64 '{payload}'"
|
||||
# Feed the (potentially large) request over stdin; keep the Windows CMD command under its limit.
|
||||
# Windows OpenSSH does not reliably deliver stdin EOF for larger redirected
|
||||
# payloads. An explicit final line lets the server finish without waiting for it.
|
||||
bootstrap = ("$lines=[Collections.Generic.List[string]]::new(); "
|
||||
"while($null -ne ($line=[Console]::In.ReadLine())){if($line -eq '.'){break};$lines.Add($line)}; "
|
||||
"$data=[Convert]::FromBase64String(($lines -join '')); "
|
||||
"$stream=[IO.MemoryStream]::new($data); "
|
||||
"$zip=[IO.Compression.GZipStream]::new($stream,[IO.Compression.CompressionMode]::Decompress); "
|
||||
"$reader=[IO.StreamReader]::new($zip,[Text.Encoding]::UTF8); "
|
||||
"& ([ScriptBlock]::Create($reader.ReadToEnd()))")
|
||||
bootstrap64 = base64.b64encode(bootstrap.encode("utf-16le")).decode()
|
||||
wire = base64.encodebytes(gzip.compress(code.encode("utf-8"))).decode("ascii") + ".\n"
|
||||
try:
|
||||
result = subprocess.run(["ssh", *ssh_options(config), f"{config['user']}@{config['host']}",
|
||||
"powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive",
|
||||
"-EncodedCommand", bootstrap64], input=wire, text=True,
|
||||
encoding="utf-8", errors="replace", capture_output=True,
|
||||
timeout=900 if action in ("Publish", "Rollback") else 120)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise RuntimeError(f"SSH {action} timed out; inspect the server state before retrying a publication") from error
|
||||
if result.returncode:
|
||||
raise RuntimeError((result.stderr or result.stdout).strip())
|
||||
try:
|
||||
return json.loads(result.stdout.strip())
|
||||
except json.JSONDecodeError as error:
|
||||
raise RuntimeError(f"Unexpected SSH response: {result.stdout[:500]}") from error
|
||||
|
||||
|
||||
def request_for(config: dict, plan: dict, release: str) -> dict:
|
||||
return {"root": config["remote_root"], "releaseId": release,
|
||||
"version": plan["version"],
|
||||
"files": [{k: f[k] for k in ("path", "size", "sha256")} for f in plan["files"]]}
|
||||
|
||||
|
||||
def make_archive(plan: dict, changed: list[str], path: Path) -> None:
|
||||
wanted = set(changed)
|
||||
by_path = {f["path"]: f for f in plan["files"]}
|
||||
if not wanted.issubset(by_path):
|
||||
raise ValueError("Server requested a path outside the release")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
|
||||
for name in sorted(wanted):
|
||||
entry = by_path[name]
|
||||
source = Path(entry["source"])
|
||||
if source.stat().st_size != entry["size"] or digest(source) != entry["sha256"]:
|
||||
raise ValueError(f"Staged file changed since planning: {name}")
|
||||
archive.write(source, name)
|
||||
|
||||
|
||||
def verify_public(config: dict, version: dict, release: str, plan: dict | None = None) -> None:
|
||||
root = config["public_base"].rstrip("/")
|
||||
def get(relative):
|
||||
req = urllib.request.Request(f"{root}/{relative}?publish={release}", headers={"Cache-Control": "no-cache"})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return response.read()
|
||||
actual = json.loads(get("cdn/version.json").decode("utf-8-sig"))
|
||||
if actual != version:
|
||||
raise RuntimeError("Public version.json does not match the published release")
|
||||
for package in version["packages"]:
|
||||
expected = version["resVersion" if package == "Art" else "packVersion"]
|
||||
data = get(f"yoo/{package}/{package}_{expected}.bytes")
|
||||
if hashlib.md5(data).hexdigest() != expected:
|
||||
raise RuntimeError(f"Public {package} manifest MD5 mismatch")
|
||||
for entry in (plan or {}).get("files", []):
|
||||
if STARTUP_PATH.fullmatch(entry["path"]):
|
||||
data = get(entry["path"])
|
||||
if len(data) != entry["size"] or hashlib.sha256(data).hexdigest() != entry["sha256"]:
|
||||
raise RuntimeError("Public startup data SHA256 mismatch")
|
||||
|
||||
|
||||
def init_key(config_path: Path) -> None:
|
||||
if not config_path.exists():
|
||||
shutil.copyfile(REPO / "Tools/cdn_publish.example.json", config_path)
|
||||
config = load_config(config_path)
|
||||
key = Path(config["identity_file"])
|
||||
key.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not key.exists():
|
||||
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "aigame-cdn-deploy", "-f", str(key)], check=True)
|
||||
if os.name == "nt":
|
||||
path64 = base64.b64encode(str(key).encode()).decode()
|
||||
code = "$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + path64 + "')); " + \
|
||||
"$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; " + \
|
||||
"$acl=New-Object Security.AccessControl.FileSecurity; $acl.SetOwner($sid); " + \
|
||||
"$acl.SetAccessRuleProtection($true,$false); " + \
|
||||
"$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow'))); " + \
|
||||
"$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier('S-1-5-18')),'FullControl','Allow'))); " + \
|
||||
"Set-Acl -LiteralPath $p -AclObject $acl -ErrorAction Stop"
|
||||
subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand",
|
||||
base64.b64encode(code.encode("utf-16le")).decode()], check=True)
|
||||
if not key.with_suffix(".pub").is_file():
|
||||
raise ValueError("Existing private key has no matching .pub file")
|
||||
public_key = key.with_suffix(".pub").read_text(encoding="ascii").strip()
|
||||
if not re.fullmatch(r"ssh-ed25519 [A-Za-z0-9+/=]+(?: .*)?", public_key):
|
||||
raise ValueError("Unexpected public-key format")
|
||||
setup = REPO / "Tools/.cdn-deploy/Install-PublicKey-OnServer.ps1"
|
||||
setup.write_text("""# Run this file in an elevated PowerShell ON THE SERVER.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$path = Join-Path $env:ProgramData 'ssh/administrators_authorized_keys'
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $path) -Force | Out-Null
|
||||
$key = 'restrict """ + public_key + """'
|
||||
$existing = if (Test-Path -LiteralPath $path) { [IO.File]::ReadAllText($path) } else { '' }
|
||||
if (-not $existing.Contains($key)) {
|
||||
if ($existing -and -not $existing.EndsWith("`n")) { $existing += "`r`n" }
|
||||
[IO.File]::WriteAllText($path, $existing + $key + "`r`n", (New-Object Text.UTF8Encoding($false)))
|
||||
}
|
||||
& icacls.exe $path /inheritance:r /grant:r '*S-1-5-32-544:F' '*S-1-5-18:F'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to set authorized_keys permissions' }
|
||||
Write-Output 'AIGame CDN public key installed.'
|
||||
""", encoding="utf-8-sig")
|
||||
print(f"Dedicated key ready: {key}\nServer setup script (PUBLIC KEY ONLY): {setup}")
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=("init-key", "plan", "check", "upload", "rollback"))
|
||||
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
||||
parser.add_argument("--stage", type=Path, default=REPO / "Tools/cdn_root/wechat")
|
||||
parser.add_argument("--compare", action="store_true", help="Compare staged files with the server without publishing")
|
||||
parser.add_argument("--startup-export", type=Path, help="Also publish startup data selected by EXPORT/minigame/game.js")
|
||||
parser.add_argument("--release", help="Release ID to roll back")
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "init-key":
|
||||
init_key(args.config)
|
||||
return 0
|
||||
config = load_config(args.config)
|
||||
if args.command == "check":
|
||||
print(json.dumps(remote(config, "Check", {"root": config["remote_root"]}), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.command == "rollback":
|
||||
if not args.release or not re.fullmatch(r"[a-f0-9]{32}", args.release):
|
||||
raise ValueError("rollback requires --release with a 32-character release ID")
|
||||
result = remote(config, "Rollback", {"root": config["remote_root"], "releaseId": args.release})
|
||||
write_json(OUTPUT / f"rollback-{args.release}.json", result)
|
||||
verify_public(config, result["version"], uuid.uuid4().hex)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||
plan = make_plan(args.stage, OUTPUT / "plan.json", args.startup_export)
|
||||
print(f"Staged: {len(plan['files'])} files, {plan['totalBytes'] / 1048576:.2f} MiB; pack={plan['version']['packVersion']}; art={plan['version'].get('resVersion', '')}")
|
||||
if args.command == "plan" and not args.compare:
|
||||
print(f"File list: {OUTPUT / 'plan.json'}")
|
||||
return 0
|
||||
release = uuid.uuid4().hex
|
||||
request = request_for(config, plan, release)
|
||||
comparison = remote(config, "Plan", request)
|
||||
changed = comparison["changed"]
|
||||
write_json(OUTPUT / "comparison.json", comparison)
|
||||
total = sum(f["size"] for f in plan["files"] if f["path"] in changed)
|
||||
print(f"Remote difference: {len(changed)} files, {total / 1048576:.2f} MiB")
|
||||
for name in changed:
|
||||
print(f" {name}")
|
||||
if args.command == "plan":
|
||||
return 0
|
||||
request["expectedVersionSha256"] = comparison["currentVersionSha256"]
|
||||
if not changed:
|
||||
verify_public(config, plan["version"], release, plan)
|
||||
print("Already published; public version and package manifests verified.")
|
||||
return 0
|
||||
archive = OUTPUT / f"{release}.zip"
|
||||
make_archive(plan, changed, archive)
|
||||
prepared = remote(config, "Prepare", request)
|
||||
remote_zip = prepared["archive"].replace("\\", "/")
|
||||
if not re.fullmatch(r"[A-Za-z]:/[A-Za-z0-9_./ -]+", remote_zip):
|
||||
raise ValueError("Unexpected remote staging path")
|
||||
subprocess.run(["scp", *ssh_options(config, scp=True), str(archive),
|
||||
f"{config['user']}@{config['host']}:{remote_zip}"], check=True, timeout=1800)
|
||||
result = remote(config, "Publish", request)
|
||||
# Persist the server commit before checking public HTTP. A failed HTTP check must never replay a commit.
|
||||
receipt = OUTPUT / f"release-{release}.json"
|
||||
write_json(receipt, result)
|
||||
print(f"Published release={release}; receipt={receipt}")
|
||||
try:
|
||||
verify_public(config, plan["version"], release, plan)
|
||||
except Exception as error:
|
||||
raise RuntimeError(f"SERVER ALREADY PUBLISHED. Public HTTP verification failed: {error}. Receipt: {receipt}") from error
|
||||
print("Verified public version.json, package manifest MD5s and any selected startup data SHA256.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Author the task journal, seven-day rewards and mailbox via JSON -> Prefab."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / 'Doc/UIPrefabCreater'
|
||||
INK = '#29465FFF'
|
||||
MUTED = '#647B8BFF'
|
||||
|
||||
|
||||
class Page:
|
||||
def __init__(self, key, title, subtitle, icon, height):
|
||||
self.name = 'UI_Prototype' + key
|
||||
self.data = dict(schemaVersion=1, prefabName=self.name,
|
||||
prefabPath=f'Assets/Bundles/UI/Prefab/{self.name}.prefab',
|
||||
description='Normal 层营地冒险手册:安全区、可滚动内容、奖励优先;由 JSON 生成,沿用服务端奖励规则。',
|
||||
canvas={'canvasScaler': {'referenceResolution': [1080, 1920], 'matchWidthOrHeight': 0}},
|
||||
assets={'sprites': {}}, nodes=[], bindings=[], events=[])
|
||||
self.node(self.name, None, self.stretch(), [dict(type='Canvas', sortingOrder=100),
|
||||
dict(type='CanvasScaler', referenceResolution=[1080, 1920], matchWidthOrHeight=0), dict(type='GraphicRaycaster')])
|
||||
self.data['assets']['sprites']['journal_paper_v1'] = 'Assets/Bundles/UI/Bg/journal_paper_v1.png'
|
||||
self.node('Background', self.name, self.stretch(), [
|
||||
dict(type='Image', sprite='journal_paper_v1', imageType='Simple', color='#FFFFFFFF', raycastTarget=True),
|
||||
dict(type='MonoBehaviour', typeName='AOT.UI.BootArtworkCover')])
|
||||
self.node('SafeArea', self.name, self.stretch(), [dict(type='MonoBehaviour', typeName='AOT.UI.UiSafeArea')])
|
||||
self.node('Header', 'SafeArea', dict(anchorMin=[0, 1], anchorMax=[1, 1], pivot=[.5, 1], anchoredPosition=[0, 0], sizeDelta=[0, 164]))
|
||||
self.button('Back', 'Header', 36, 26, 104, 104, '', icon='btn_back')
|
||||
self.text('Title', 'Header', 166, 22, 690, 68, title, 50)
|
||||
self.text('Subtitle', 'Header', 170, 94, 690, 42, subtitle, 26, MUTED)
|
||||
self.icon('HeaderEmblem', 'Header', 910, 24, 110, 110, icon)
|
||||
self.node('Scroll_Page', 'SafeArea', self.stretch([0, 22], [0, -170]),
|
||||
[dict(type='ScrollRect', horizontal=False, vertical=True, viewport='Viewport_Page', content='Content_Page')])
|
||||
self.node('Viewport_Page', 'Scroll_Page', self.stretch(),
|
||||
[dict(type='Image', color='#FFFFFF00', raycastTarget=True), dict(type='RectMask2D')])
|
||||
self.node('Content_Page', 'Viewport_Page', dict(anchorMin=[0, 1], anchorMax=[1, 1], pivot=[.5, 1], anchoredPosition=[0, 0], sizeDelta=[0, height]))
|
||||
|
||||
@staticmethod
|
||||
def stretch(lo=None, hi=None):
|
||||
return dict(anchorMin=[0, 0], anchorMax=[1, 1], offsetMin=lo or [0, 0], offsetMax=hi or [0, 0])
|
||||
|
||||
@staticmethod
|
||||
def rect(x, y, w, h):
|
||||
return dict(anchorMin=[0, 1], anchorMax=[0, 1], pivot=[0, 1], anchoredPosition=[x, -y], sizeDelta=[w, h])
|
||||
|
||||
def node(self, name, parent, rect, components=None, active=True):
|
||||
self.data['nodes'].append(dict(name=name, parent=parent, active=active, rect=rect, components=components or []))
|
||||
return name
|
||||
|
||||
def sprite(self, name):
|
||||
folder = 'Journal' if name.startswith('journal_') else 'Home' if name.startswith('home_') else 'Prototype' if name.startswith('proto_') else 'Common'
|
||||
self.data['assets']['sprites'][name] = f'Assets/Bundles/UI/Texture/{folder}/{name}.png'
|
||||
return name
|
||||
|
||||
def image(self, name, parent, x, y, w, h, sprite, color='#FFFFFFFF', simple=False, raycast=False):
|
||||
return self.node(name, parent, self.rect(x, y, w, h), [dict(type='Image', sprite=self.sprite(sprite),
|
||||
imageType='Simple' if simple else 'Sliced', color=color, preserveAspect=simple, raycastTarget=raycast)])
|
||||
|
||||
def icon(self, name, parent, x, y, w, h, sprite):
|
||||
return self.image(name, parent, x, y, w, h, sprite, simple=True)
|
||||
|
||||
def card(self, name, y, h, sprite='journal_card', color='#FFFFFFFF', x=40, w=1000, parent='Content_Page'):
|
||||
return self.image(name, parent, x, y, w, h, sprite, color)
|
||||
|
||||
def text(self, name, parent, x, y, w, h, text, size=32, color=INK, align='Left'):
|
||||
node = 'Txt_' + name
|
||||
self.node(node, parent, self.rect(x, y, w, h), [dict(type='TextMeshProUGUI', text=text,
|
||||
fontSize=size, color=color, alignment={'MidlineLeft':'Left','MidlineRight':'Right','TopLeft':'Left'}.get(align,align), raycastTarget=False)])
|
||||
if parent in ['TaskSummary','MailWallet']:
|
||||
self.data['nodes'][-1]['components'].append(dict(type='Shadow',effectColor='#10203360',effectDistance=[0,-1]))
|
||||
self.data['bindings'].append(node)
|
||||
return node
|
||||
|
||||
def button(self, name, parent, x, y, w, h, text, primary=False, icon=None, active=True):
|
||||
sprite = icon or ('journal_primary_normal' if primary else 'journal_secondary_normal')
|
||||
node = self.image('Btn_' + name, parent, x, y, w, h, sprite, simple=icon is not None, raycast=True)
|
||||
obj = self.data['nodes'][-1]
|
||||
obj['active'] = active
|
||||
obj['components'].append(dict(type='Button', transition='ColorTint' if icon else 'SpriteSwap',
|
||||
spriteHighlighted=self.sprite(sprite), spritePressed=self.sprite(sprite if icon else sprite.replace('normal', 'pressed')),
|
||||
spriteDisabled=self.sprite('journal_disabled'), onClick='Prototype.' + name))
|
||||
if not icon:
|
||||
label = self.text(name, node, 12, 8, w-24, h-16, text, 32, '#5D431FFF' if primary else INK, 'Center')
|
||||
self.data['bindings'].append(node)
|
||||
self.data['events'].append(dict(name='Prototype.' + name, node=node))
|
||||
return node
|
||||
|
||||
def progress(self, name, parent, x, y, w, h=22):
|
||||
self.image(name + 'Track', parent, x, y, w, h, 'journal_progress_bg')
|
||||
self.image(name + 'Fill', parent, x, y, w, h, 'journal_progress_fill')
|
||||
|
||||
def save(self):
|
||||
(OUT / (self.name + '.json')).write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
def tasks():
|
||||
p = Page('Tasks', '冒险手册', '每天一小步,营地更热闹', 'home_ico_tasks', 1718)
|
||||
p.card('TaskSummary', 12, 250, 'journal_hero')
|
||||
p.text('TodayCaption', 'TaskSummary', 36, 28, 580, 44, '今日旅程', 30, '#F4CE88FF')
|
||||
p.text('TaskSummary', 'TaskSummary', 36, 82, 660, 70, '0 / 3 份奖励已领取', 46, '#FFFFFFFF')
|
||||
p.text('TaskReset', 'TaskSummary', 38, 170, 660, 42, '每日 00:00 更新', 26, '#C2D5DFFF')
|
||||
p.icon('TodayReward', 'TaskSummary', 778, 32, 176, 176, 'home_ico_chest')
|
||||
for i in range(3):
|
||||
p.image('DailyMilestone' + str(i), 'TaskSummary', 38+i*216, 222, 198, 12, 'journal_progress_fill', '#ACC5D6FF')
|
||||
p.text('DailyHeading', 'Content_Page', 52, 290, 500, 54, '每日委托', 36)
|
||||
p.text('DailyHint', 'Content_Page', 600, 296, 422, 42, '完成即可领取奖励', 26, MUTED, 'MidlineRight')
|
||||
icons = ['home_ico_pinball', 'home_ico_battle', 'home_ico_pets']
|
||||
titles = ['完成3次弹珠发射', '完成1场冒险', '与伙伴互动1次']
|
||||
for i in range(3):
|
||||
card = 'TaskCard' + str(i+1)
|
||||
p.card(card, 360+i*236, 216)
|
||||
p.icon('DailyIcon'+str(i), card, 24, 40, 112, 112, icons[i])
|
||||
p.text('DailyName'+str(i), card, 158, 24, 560, 50, titles[i], 36)
|
||||
p.text('DailyInfo'+str(i), card, 160, 80, 540, 40, '完成一次结算即可计入', 26, MUTED)
|
||||
p.progress('DailyProgress'+str(i), card, 160, 145, 350, 20)
|
||||
p.text('DailyCount'+str(i), card, 532, 128, 170, 50, '0 / 3', 30, MUTED, 'MidlineRight')
|
||||
p.text('DailyReward'+str(i), card, 734, 26, 236, 54, '弹珠 ×5', 34, '#99641FFF', 'Center')
|
||||
p.button('ClaimDaily'+str(i), card, 738, 102, 224, 92, '领取奖励', True, active=False)
|
||||
p.button('GoDaily'+str(i), card, 738, 102, 224, 92, '去完成')
|
||||
p.text('DailyDone'+str(i), card, 738, 118, 224, 60, '已领取', 32, '#698479FF', 'Center')
|
||||
p.data['nodes'][-1]['active'] = False
|
||||
p.text('GrowthHeading', 'Content_Page', 52, 1090, 800, 54, '成长足迹', 36)
|
||||
p.card('TaskCard0', 1162, 200, color='#E5EDF2FF')
|
||||
p.icon('GoalIcon', 'TaskCard0', 24, 40, 112, 112, 'home_ico_home')
|
||||
p.text('GoalName', 'TaskCard0', 158, 22, 550, 50, '营地初守', 36)
|
||||
p.text('GoalInfo', 'TaskCard0', 160, 82, 540, 74, '首次通关 1-1\n成长奖励永久保留', 26, MUTED)
|
||||
p.text('GoalReward', 'TaskCard0', 734, 22, 236, 54, '弹珠 ×5', 34, '#99641FFF', 'Center')
|
||||
p.button('ClaimGoal', 'TaskCard0', 738, 98, 224, 88, '领取奖励', True, active=False)
|
||||
p.button('GoGoal', 'TaskCard0', 738, 98, 224, 88, '去冒险')
|
||||
p.text('GoalDone', 'TaskCard0', 738, 114, 224, 60, '已领取', 32, '#698479FF', 'Center')
|
||||
p.data['nodes'][-1]['active'] = False
|
||||
p.card('SigninEntry', 1396, 156, 'journal_gold_card')
|
||||
p.icon('SigninEntryIcon', 'SigninEntry', 24, 20, 116, 116, 'home_ico_calendar')
|
||||
p.text('SigninEntryTitle', 'SigninEntry', 164, 28, 520, 52, '七日同行礼', 36)
|
||||
p.text('SigninEntryHint', 'SigninEntry', 166, 90, 520, 40, '累计登录,每一天都有收获', 26, MUTED)
|
||||
p.button('ToSignin', 'SigninEntry', 738, 34, 224, 88, '查看奖励')
|
||||
p.text('TaskRules', 'Content_Page', 64, 1590, 952, 84, '每日奖励请在重置前领取\n成长奖励仅可领取一次', 25, MUTED, 'Center')
|
||||
p.save()
|
||||
|
||||
|
||||
def signin():
|
||||
p = Page('Signin', '七日同行礼', '每一次回到营地,都值得期待', 'home_ico_calendar', 1700)
|
||||
p.card('SigninSummary', 12, 258, 'journal_gold_card')
|
||||
p.text('SigninCaption', 'SigninSummary', 36, 28, 650, 44, '把小小的收获,攒成大大的惊喜', 30)
|
||||
p.text('SigninSummary', 'SigninSummary', 36, 82, 690, 70, '已相伴 1 天', 48)
|
||||
p.text('SigninHint', 'SigninSummary', 38, 176, 650, 42, '漏登不重置 · 已解锁奖励可补领', 26, MUTED)
|
||||
p.icon('SigninHeroGift', 'SigninSummary', 786, 42, 176, 176, 'home_ico_gift')
|
||||
p.text('SigninSection', 'Content_Page', 52, 302, 800, 54, '你的七日礼物', 36)
|
||||
for i in range(7):
|
||||
special = i == 6
|
||||
card = 'SigninCard' + str(i)
|
||||
x, y, w, h = (40, 1138, 1000, 300) if special else (40+(i%3)*340, 382+(i//3)*366, 320, 342)
|
||||
p.card(card, y, h, 'journal_gold_card' if special else 'journal_card', x=x, w=w)
|
||||
p.text('SigninDay'+str(i), card, 28, 18, w-56, 48, '第 7 天 · 同行大礼' if special else f'第 {i+1} 天', 34, INK, 'MidlineLeft' if special else 'Center')
|
||||
p.icon('SigninIcon'+str(i), card, 44 if special else 104, 90 if special else 72, 170 if special else 112, 170 if special else 112, 'home_ico_chest' if special else 'home_ico_pinball')
|
||||
p.text('SigninReward'+str(i), card, 260 if special else 18, 96 if special else 194, 420 if special else 284, 50, '弹珠 ×'+str([10,5,5,10,5,10,20][i]), 40 if special else 32, '#99641FFF', 'MidlineLeft' if special else 'Center')
|
||||
if special: p.text('FinalGiftHint', card, 260, 174, 420, 48, '累计登录七天即可解锁', 26, MUTED)
|
||||
p.button('Signin'+str(i), card, 718 if special else 28, 174 if special else 252, 246 if special else 264, 88 if special else 78, '领取', True)
|
||||
# Locked/claimed days are labels, never disabled fake actions.
|
||||
p.text('SigninState'+str(i), card, 718 if special else 28, 174 if special else 270, 246 if special else 264, 88 if special else 60, '等待解锁', 28, MUTED, 'Center')
|
||||
p.button('ToTasks', 'Content_Page', 260, 1484, 560, 96, '去完成今日委托')
|
||||
p.text('SigninRules', 'Content_Page', 64, 1604, 952, 60, '每日首次登录计入天数 · 每份奖励仅可领取一次', 25, MUTED, 'Center')
|
||||
p.save()
|
||||
|
||||
|
||||
def mail():
|
||||
p = Page('Mail', '营地信箱', '来自小镇的问候与补给', 'home_ico_mail', 1840)
|
||||
p.card('MailWallet', 12, 172, 'journal_hero')
|
||||
p.icon('MailHero', 'MailWallet', 24, 24, 120, 120, 'home_ico_mail')
|
||||
p.text('MailWallet', 'MailWallet', 170, 26, 780, 62, '营地来信', 42, '#FFFFFFFF')
|
||||
p.text('MailSummaryHint', 'MailWallet', 172, 100, 780, 42, '点击来信,查看消息与随信礼物', 26, '#C2D5DFFF')
|
||||
p.text('InboxHeading', 'Content_Page', 52, 210, 500, 54, '收到的来信', 36)
|
||||
p.node('MailStack', 'Content_Page', p.rect(40, 284, 1000, 0), [
|
||||
dict(type='VerticalLayoutGroup', spacing=24, childControlWidth=True, childControlHeight=True,
|
||||
childForceExpandWidth=True, childForceExpandHeight=False),
|
||||
dict(type='ContentSizeFitter', verticalFit='PreferredSize')])
|
||||
for i in range(5):
|
||||
b=p.button('Mail'+str(i), 'Content_Page', 40, 284+i*126, 1000, 114, '')
|
||||
n=next(n for n in p.data['nodes'] if n['name']==b)
|
||||
n['components'][0]['sprite']=p.sprite('journal_card')
|
||||
n['components'][1].update(spriteHighlighted=p.sprite('journal_card'),spritePressed=p.sprite('journal_secondary_pressed'),spriteDisabled=p.sprite('journal_card'))
|
||||
# The generic label is intentionally replaced by title and status.
|
||||
p.data['nodes']=[n for n in p.data['nodes'] if n['name']!='Txt_Mail'+str(i)]
|
||||
p.data['bindings'].remove('Txt_Mail'+str(i))
|
||||
p.icon('MailIcon'+str(i), b, 20, 17, 80, 80, 'home_ico_mail')
|
||||
p.text('MailTitle'+str(i), b, 128, 12, 800, 48, '营地来信', 34)
|
||||
p.text('MailStatus'+str(i), b, 130, 66, 800, 36, '未读 · 附件待领取', 25, MUTED)
|
||||
p.image('MailUnread'+str(i), b, 950, 28, 16, 16, 'journal_progress_fill')
|
||||
p.text('MailEmpty', 'Content_Page', 100, 440, 880, 180, '信箱暂时空空的\n下一份问候,正在路上', 36, MUTED, 'Center')
|
||||
p.data['nodes'][-1]['active']=False
|
||||
p.node('MailPager', 'MailStack', p.rect(0,0,1000,76), [dict(type='LayoutElement', preferredHeight=76)])
|
||||
p.button('MailPrev', 'Content_Page', 48, 930, 184, 76, '上一页')
|
||||
p.text('MailPage', 'Content_Page', 260, 938, 560, 60, '1 / 1 页', 28, MUTED, 'Center')
|
||||
p.button('MailNext', 'Content_Page', 848, 930, 184, 76, '下一页')
|
||||
p.card('MailDetail', 1040, 502)
|
||||
p.text('MailDetailTitle', 'MailDetail', 32, 26, 936, 60, '请选择一封邮件', 38)
|
||||
p.text('MailDetailStatus', 'MailDetail', 34, 96, 932, 42, '查看来自营地的消息', 26, MUTED)
|
||||
p.node('MailBodyScroll', 'MailDetail', p.rect(34, 156, 932, 164), [dict(type='ScrollRect',horizontal=False,vertical=True,viewport='MailBodyViewport',content='Txt_MailBody')])
|
||||
p.node('MailBodyViewport', 'MailBodyScroll', p.stretch(), [dict(type='Image',color='#FFFFFF00',raycastTarget=True),dict(type='RectMask2D')])
|
||||
p.text('MailBody','MailBodyViewport',0,0,920,164,'营地来信和奖励补给会显示在这里。',30,INK,'TopLeft')
|
||||
p.data['nodes'][-1]['components'].append(dict(type='ContentSizeFitter',verticalFit='PreferredSize'))
|
||||
p.text('MailAttachment','MailDetail',34,338,932,58,'',30,'#99641FFF')
|
||||
p.button('ClaimMail','MailDetail',590,414,374,70,'领取附件',True)
|
||||
p.button('ClaimAllMail','Content_Page',240,1580,600,104,'领取全部附件',True)
|
||||
p.text('MailRules','Content_Page',64,1720,952,80,'附件领取后直接到账\n一键最多领取 20 封,请在到期前领取',25,MUTED,'Center')
|
||||
stack_heights = {'Btn_Mail'+str(i):114 for i in range(5)}
|
||||
stack_heights.update(Txt_MailEmpty=240,MailDetail=502,Btn_ClaimAllMail=104,Txt_MailRules=80)
|
||||
for n in p.data['nodes']:
|
||||
if n['name'] in stack_heights:
|
||||
n['parent']='MailStack'
|
||||
n['components'].append(dict(type='LayoutElement',preferredHeight=stack_heights[n['name']]))
|
||||
if n['name'] in ['Btn_MailPrev','Txt_MailPage','Btn_MailNext']:
|
||||
n['parent']='MailPager'
|
||||
n['rect']['anchoredPosition'][0]-=40
|
||||
n['rect']['anchoredPosition'][1]+=930
|
||||
p.save()
|
||||
|
||||
|
||||
def challenges():
|
||||
p = Page('Challenges', '属性试炼', '元素相生相克 · 携同调伙伴挑战强敌', 'home_ico_battle', 1830)
|
||||
# 1. Hero Card
|
||||
p.card('ChallengeSummary', 12, 210, 'journal_hero')
|
||||
p.text('HeroCaption', 'ChallengeSummary', 36, 26, 620, 44, '元素试炼 · 专属徽章与伙伴碎片', 30, '#F4CE88FF')
|
||||
p.text('Summary', 'ChallengeSummary', 36, 78, 680, 64, '通过 1-1 后开放。只上阵对应元素,首次通过获得徽章和碎片。', 32, '#FFFFFFFF')
|
||||
p.text('HeroHint', 'ChallengeSummary', 38, 150, 680, 40, '试炼不消耗弹珠 · 首胜获10碎片与专属徽章', 26, '#C2D5DFFF')
|
||||
p.icon('ChallengeHero', 'ChallengeSummary', 778, 24, 160, 160, 'home_ico_battle')
|
||||
|
||||
# 2. Section Heading
|
||||
p.text('TrialHeading', 'Content_Page', 52, 244, 500, 54, '五行试炼', 36)
|
||||
p.text('TrialSub', 'Content_Page', 560, 250, 460, 42, '点击切换试炼项目', 26, MUTED, 'MidlineRight')
|
||||
|
||||
# 3. 5 Challenge Buttons
|
||||
pet_icons = ['proto_pet_1', 'proto_pet_2', 'proto_pet_0', 'proto_pet_3', 'proto_pet_4']
|
||||
default_titles = ['火 · 灼草之试', '草 · 碎岩之试', '岩 · 避雷之试', '电 · 破浪之试', '水 · 灭火之试']
|
||||
for i in range(5):
|
||||
b = p.button('Challenge' + str(i), 'Content_Page', 40, 308 + i * 140, 1000, 124, '')
|
||||
n = next(n for n in p.data['nodes'] if n['name'] == b)
|
||||
n['components'][0]['sprite'] = p.sprite('journal_card')
|
||||
n['components'][1].update(
|
||||
spriteHighlighted=p.sprite('journal_card'),
|
||||
spritePressed=p.sprite('journal_secondary_pressed'),
|
||||
spriteDisabled=p.sprite('journal_disabled'))
|
||||
p.data['nodes'] = [n for n in p.data['nodes'] if n['name'] != 'Txt_Challenge' + str(i)]
|
||||
p.data['bindings'].remove('Txt_Challenge' + str(i))
|
||||
p.icon('ChallengeIcon' + str(i), b, 18, 14, 96, 96, pet_icons[i])
|
||||
p.text('Challenge' + str(i), b, 134, 16, 640, 48, default_titles[i], 34, INK, 'Left')
|
||||
p.text('ChallengeState' + str(i), b, 136, 68, 640, 40, '可挑战', 26, MUTED, 'Left')
|
||||
p.text('ChallengeTag' + str(i), b, 780, 40, 180, 44, '选择 >', 26, '#8FA6B5FF', 'Center')
|
||||
|
||||
# 4. Detail Panel
|
||||
p.text('DetailSectionHeading', 'Content_Page', 52, 1028, 500, 50, '试炼详情与阵容', 36)
|
||||
p.text('DetailSectionHint', 'Content_Page', 560, 1034, 460, 42, '同调元素自动上阵', 26, MUTED, 'MidlineRight')
|
||||
p.card('DetailPanel', 1088, 450, 'journal_gold_card')
|
||||
p.icon('DetailEmblem', 'DetailPanel', 28, 22, 64, 64, 'home_ico_trophy')
|
||||
p.text('DetailTitle', 'DetailPanel', 106, 28, 400, 44, '试炼情报', 34, INK)
|
||||
p.text('DetailTag', 'DetailPanel', 720, 30, 240, 36, '不消耗弹珠', 26, '#99641FFF', 'MidlineRight')
|
||||
p.text('Detail', 'DetailPanel', 34, 90, 932, 220, '选择试炼查看详细情报…', 28, INK, 'TopLeft')
|
||||
p.button('Start', 'DetailPanel', 32, 324, 936, 96, '开始试炼', primary=True)
|
||||
|
||||
# 5. Quick Actions
|
||||
p.text('QuickHeading', 'Content_Page', 52, 1558, 500, 48, '冒险整备', 36)
|
||||
p.text('QuickHint', 'Content_Page', 560, 1564, 460, 40, '缺少对应元素伙伴?', 26, MUTED, 'MidlineRight')
|
||||
p.button('Capture', 'Content_Page', 40, 1618, 485, 92, '结识伙伴')
|
||||
p.button('Fish', 'Content_Page', 555, 1618, 485, 92, '小镇钓鱼')
|
||||
|
||||
# 6. Rules
|
||||
p.text('Rules', 'Content_Page', 64, 1730, 952, 76, '属性试炼不消耗弹珠 · 首胜即发专属徽章与10枚伙伴碎片\n再次挑战仅在刷新个人最短用时纪录时计入排行榜', 25, MUTED, 'Center')
|
||||
p.save()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
tasks()
|
||||
signin()
|
||||
mail()
|
||||
challenges()
|
||||
print('Authored Tasks / Signin / Mail / Challenges JSON. Build and verify with Unity Pipeline.')
|
||||
@@ -0,0 +1,18 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
blender_script = """
|
||||
import bpy
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=r"D:/UD/AI/XModel/LLM3DGraphics/mixamo/Male/Idle.fbx")
|
||||
print("Actions in Idle.fbx:", [a.name for a in bpy.data.actions])
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=r"D:/UD/AI/XModel/LLM3DGraphics/mixamo/Male/Walking.fbx")
|
||||
print("Actions in Walking.fbx:", [a.name for a in bpy.data.actions])
|
||||
"""
|
||||
|
||||
Path("Tools/check_actions.py").write_text(blender_script, encoding="utf-8")
|
||||
res = subprocess.run([r"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe", "--background", "--python", "Tools/check_actions.py"], capture_output=True, text=True)
|
||||
print(res.stdout[-400:])
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
import mimetypes
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:8008"
|
||||
OUTPUT_DIR = Path("Tools/output/explorer_3d")
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
REF_IMAGE = OUTPUT_DIR / "explorer_ref.png"
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
def wait_job(url_path, timeout=3600, interval=3):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
r = session.get(f"{BASE}{url_path}", timeout=30)
|
||||
r.raise_for_status()
|
||||
job = r.json()
|
||||
status = job.get("status")
|
||||
stage = job.get("stage", "")
|
||||
progress = job.get("progress", 0)
|
||||
print(f"[{status}] stage={stage} progress={progress}%", flush=True)
|
||||
if status in ("completed", "failed"):
|
||||
if status != "completed" or job.get("error"):
|
||||
raise RuntimeError(f"Job failed: {job.get('error') or job}")
|
||||
return job
|
||||
time.sleep(interval)
|
||||
raise TimeoutError(f"Job timeout: {url_path}")
|
||||
|
||||
def download(url, out_path):
|
||||
r = session.get(f"{BASE}{url}", timeout=300)
|
||||
r.raise_for_status()
|
||||
out = Path(out_path)
|
||||
if r.content[:4] == b"PK\x03\x04":
|
||||
out = out.with_suffix(".zip")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(r.content)
|
||||
print(f"Downloaded: {out} ({len(r.content)} bytes)")
|
||||
return out
|
||||
|
||||
# 1. Mesh Generation
|
||||
print("=== Step 1: DGX Hunyuan3D Mesh Generation ===")
|
||||
mime = mimetypes.guess_type(REF_IMAGE.name)[0] or "image/png"
|
||||
with REF_IMAGE.open("rb") as f:
|
||||
resp = session.post(f"{BASE}/api/jobs", timeout=120, data={
|
||||
"engine": "dgx",
|
||||
"mode": "image",
|
||||
"character_type": "humanoid",
|
||||
"prompt": "chibi 3D game character boy adventurer in A-pose",
|
||||
"resolution": 256,
|
||||
"source_faces": 50000,
|
||||
"target_faces": 5000,
|
||||
"texture_size": 1024,
|
||||
"output_format": "fbx"
|
||||
}, files={"image": (REF_IMAGE.name, f, mime)})
|
||||
|
||||
if resp.status_code not in (200, 201, 202):
|
||||
raise RuntimeError(f"Create job failed: {resp.status_code} {resp.text}")
|
||||
|
||||
job = resp.json()
|
||||
job_id = job["id"]
|
||||
print(f"Created Mesh Job ID: {job_id}")
|
||||
|
||||
mesh_job = wait_job(f"/api/jobs/{job_id}", timeout=1800)
|
||||
print(f"Mesh completed. Ready for motion: {mesh_job.get('ready_for_motion')}")
|
||||
(OUTPUT_DIR / "mesh_job.json").write_text(json.dumps(mesh_job, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# 2. MIA Humanoid Rigging
|
||||
print("\n=== Step 2: Make-It-Animatable (MIA) Humanoid Rigging ===")
|
||||
rig_resp = session.post(f"{BASE}/api/jobs/{job_id}/rig", timeout=120, data={
|
||||
"character_type": "humanoid",
|
||||
"skeleton_template": "auto"
|
||||
})
|
||||
if rig_resp.status_code not in (200, 201, 202):
|
||||
raise RuntimeError(f"Trigger rig failed: {rig_resp.status_code} {rig_resp.text}")
|
||||
|
||||
print("Rig triggered:", rig_resp.json())
|
||||
|
||||
rig_job = wait_job(f"/api/jobs/{job_id}", timeout=1800)
|
||||
print("Rigging completed!")
|
||||
(OUTPUT_DIR / "rig_job.json").write_text(json.dumps(rig_job, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# Download character assets
|
||||
print("\n=== Downloading Character Assets ===")
|
||||
for key, url in rig_job.get("downloads", {}).items():
|
||||
suffix = ".fbx" if "fbx" in key else ".glb" if "glb" in key else ".bin"
|
||||
download(url, OUTPUT_DIR / f"{key}{suffix}")
|
||||
|
||||
print("Mesh & Rig completed successfully!")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:8008"
|
||||
OUTPUT_DIR = Path("Tools/output/explorer_3d")
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
def wait_job(url_path, timeout=3600, interval=3):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
r = session.get(f"{BASE}{url_path}", timeout=30)
|
||||
r.raise_for_status()
|
||||
job = r.json()
|
||||
status = job.get("status")
|
||||
stage = job.get("stage", "")
|
||||
progress = job.get("progress", 0)
|
||||
print(f"[{status}] stage={stage} progress={progress}%", flush=True)
|
||||
if status in ("completed", "failed"):
|
||||
if status != "completed" or job.get("error"):
|
||||
raise RuntimeError(f"Job failed: {job.get('error') or job}")
|
||||
return job
|
||||
time.sleep(interval)
|
||||
raise TimeoutError(f"Job timeout: {url_path}")
|
||||
|
||||
# Step 0: Check status and generate reference image
|
||||
print("Step 0: Generating Character Concept Image...")
|
||||
prompt = (
|
||||
"chibi 3D game character boy adventurer, young cute explorer, 3.5 heads tall cartoon proportions, "
|
||||
"wearing brown explorer hat with goggles, beige short-sleeve adventurer jacket, cargo shorts, hiking boots, "
|
||||
"small utility pouch on leather belt, standard A-pose with arms angled down 40 degrees, "
|
||||
"legs straight and slightly apart, facing front directly, clean pure white background, full body, isolated, high quality 3D model turnaround"
|
||||
)
|
||||
|
||||
img_batch = session.post(f"{BASE}/api/local-images/jobs", data={
|
||||
"model": "turbo",
|
||||
"profile": "concept",
|
||||
"prompt": prompt,
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"count": 1,
|
||||
"seed": 20261104
|
||||
}, timeout=60).json()
|
||||
|
||||
img_job_id = img_batch["id"]
|
||||
print("Image Batch ID:", img_job_id)
|
||||
|
||||
img_res = wait_job(f"/api/local-images/jobs/{img_job_id}", timeout=300)
|
||||
img_url = img_res["images"][0]["url"]
|
||||
ref_path = OUTPUT_DIR / "explorer_ref.png"
|
||||
img_bytes = session.get(f"{BASE}{img_url}").content
|
||||
ref_path.write_bytes(img_bytes)
|
||||
print(f"Downloaded reference image: {ref_path} ({len(img_bytes)} bytes)")
|
||||
|
||||
# Prepare 3D
|
||||
session.post(f"{BASE}/api/local-images/prepare-3d", timeout=120)
|
||||
print("3D environment prepared.")
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location("publish_cdn", REPO / "Tools/publish_cdn.py")
|
||||
publish = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(publish)
|
||||
|
||||
|
||||
class PublishTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
(REPO / "Temp").mkdir(exist_ok=True)
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="cdn-publish-tests-", dir=REPO / "Temp")
|
||||
self.directory = Path(self.temp.name)
|
||||
self.stage = self.directory / "stage"
|
||||
self.root = self.directory / "CDN website with spaces"
|
||||
self.local = self.directory / "localappdata"
|
||||
self.local.mkdir()
|
||||
self.env = dict(os.environ, LOCALAPPDATA=str(self.local))
|
||||
self.old = self.create_stage(b"old index", b"old bundle")
|
||||
self.root.mkdir()
|
||||
for item in self.old["files"]:
|
||||
target = self.root / item["path"]
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(item["source"], target)
|
||||
self.old_version = (self.root / "cdn/version.json").read_bytes()
|
||||
self.plan = self.create_stage(b"new index", b"new bundle")
|
||||
self.request = publish.request_for({"remote_root": str(self.root)}, self.plan, uuid.uuid4().hex)
|
||||
self.request["expectedVersionSha256"] = hashlib.sha256(self.old_version).hexdigest()
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def create_stage(self, index, bundle):
|
||||
directory = self.stage / "1.0.0/DefaultPackage"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
for file in directory.iterdir():
|
||||
file.unlink()
|
||||
md5 = hashlib.md5(index).hexdigest()
|
||||
(directory / f"DefaultPackage_{md5}.bytes").write_bytes(index)
|
||||
(directory / f"DefaultPackage_{md5}.hash").write_text(zlib.crc32(index).to_bytes(4, "little").hex())
|
||||
(directory / "DefaultPackage.version").write_text(md5)
|
||||
(directory / f"payload_{hashlib.md5(bundle).hexdigest()}.bundle").write_bytes(bundle)
|
||||
version = {"appVersionMin": "1.0.0", "appVersionMax": "1.0.0", "packVersion": md5,
|
||||
"resVersion": "", "cdnRoot": "https://www.xworld.ren/game/Friends/webgl/yoo/DefaultPackage",
|
||||
"packages": ["DefaultPackage"], "notice": "", "forceUpdate": False}
|
||||
publish.write_json(self.stage / "version.prod.json", version)
|
||||
return publish.make_plan(self.stage, self.directory / "plan.json")
|
||||
|
||||
def invoke(self, action, request=None, success=True):
|
||||
if not shutil.which("powershell.exe"):
|
||||
self.skipTest("Windows PowerShell required for remote transaction tests")
|
||||
payload = base64.b64encode(json.dumps(request or self.request).encode()).decode()
|
||||
result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-File", str(publish.REMOTE_SCRIPT),
|
||||
"-Action", action, "-RequestBase64", payload, "-StateRoot", str(self.local / "AIGameCdnPublish")], env=self.env,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30)
|
||||
if success:
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
return json.loads(result.stdout)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
return result.stderr
|
||||
|
||||
def prepare(self):
|
||||
comparison = self.invoke("Plan")
|
||||
prepared = self.invoke("Prepare")
|
||||
publish.make_archive(self.plan, comparison["changed"], Path(prepared["archive"]))
|
||||
return Path(prepared["archive"])
|
||||
|
||||
def test_local_manifest_crc_and_md5(self):
|
||||
directory = self.stage / "1.0.0/DefaultPackage"
|
||||
next(directory.glob("*.bytes")).write_bytes(b"corrupt")
|
||||
with self.assertRaisesRegex(ValueError, "MD5"):
|
||||
publish.make_plan(self.stage, self.directory / "bad-plan.json")
|
||||
|
||||
def test_local_crc_failure(self):
|
||||
next((self.stage / "1.0.0/DefaultPackage").glob("*.hash")).write_text("00000000")
|
||||
with self.assertRaisesRegex(ValueError, "hash marker"):
|
||||
publish.make_plan(self.stage, self.directory / "bad-plan.json")
|
||||
|
||||
def test_archive_rejects_changed_local_payload(self):
|
||||
entry = next(f for f in self.plan["files"] if f["path"].endswith(".bundle"))
|
||||
Path(entry["source"]).write_bytes(b"changed after planning")
|
||||
with self.assertRaisesRegex(ValueError, "changed since planning"):
|
||||
publish.make_archive(self.plan, [entry["path"]], self.directory / "bad.zip")
|
||||
|
||||
def test_reject_traversal_and_unplanned_file(self):
|
||||
for path in ("../version.json", "yoo/Art/..", "yoo/Art/../../evil", "cdn/secret.txt"):
|
||||
with self.assertRaises(ValueError):
|
||||
publish.validate_path(path)
|
||||
with self.assertRaises(ValueError):
|
||||
publish.make_archive(self.plan, ["unknown"], self.directory / "bad.zip")
|
||||
|
||||
def test_plan_is_read_only(self):
|
||||
result = self.invoke("Plan")
|
||||
self.assertIn("cdn/version.json", result["changed"])
|
||||
self.assertFalse((self.local / "AIGameCdnPublish").exists())
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def startup_export(self):
|
||||
export = self.directory / "export"
|
||||
(export / "minigame").mkdir(parents=True)
|
||||
(export / "webgl").mkdir()
|
||||
data = b"new Unity shell startup data"
|
||||
tag = hashlib.md5(data).hexdigest()[8:24]
|
||||
name = tag + ".webgl.data.unityweb.bin.txt"
|
||||
(export / "webgl" / name).write_bytes(data)
|
||||
(export / "minigame/game.js").write_text(
|
||||
f"const config={{DATA_FILE_MD5:'{tag}',DATA_FILE_SIZE:'{len(data)}',"
|
||||
"DATA_CDN:'https://www.xworld.ren/game/Friends/webgl/'};", encoding="utf-8")
|
||||
return export, name, data
|
||||
|
||||
def test_startup_export_transaction(self):
|
||||
export, name, data = self.startup_export()
|
||||
self.plan = publish.make_plan(self.stage, self.directory / "startup-plan.json", export)
|
||||
self.request = publish.request_for({"remote_root": str(self.root)}, self.plan, uuid.uuid4().hex)
|
||||
self.request["expectedVersionSha256"] = hashlib.sha256(self.old_version).hexdigest()
|
||||
self.prepare()
|
||||
self.assertTrue(self.invoke("Publish")["published"])
|
||||
self.assertEqual((self.root / name).read_bytes(), data)
|
||||
self.assertTrue(self.invoke("Rollback")["rolledBack"])
|
||||
self.assertEqual((self.root / name).read_bytes(), data)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_startup_export_rejects_corruption(self):
|
||||
export, name, data = self.startup_export()
|
||||
(export / "webgl" / name).write_bytes(b"x" * len(data))
|
||||
with self.assertRaisesRegex(ValueError, "size or MD5"):
|
||||
publish.make_plan(self.stage, self.directory / "bad-startup.json", export)
|
||||
|
||||
def test_startup_export_rejects_unknown_and_unsafe_files(self):
|
||||
for name in ("webgl.data", "game.js", "../1234567890abcdef.webgl.data.unityweb.bin.txt"):
|
||||
with self.assertRaises(ValueError):
|
||||
publish.validate_path(name)
|
||||
request = dict(self.request)
|
||||
request["files"] = [*self.request["files"], {"path": name,"size": 1,"sha256": "0" * 64}]
|
||||
self.invoke("Plan", request=request, success=False)
|
||||
|
||||
def test_publish_noop_and_rollback(self):
|
||||
self.prepare()
|
||||
receipt = self.invoke("Publish")
|
||||
self.assertTrue(receipt["published"])
|
||||
self.assertEqual(json.loads((self.root / "cdn/version.json").read_bytes()), self.plan["version"])
|
||||
self.assertEqual(self.invoke("Plan")["changed"], [])
|
||||
old_index = self.old["version"]["packVersion"]
|
||||
self.assertTrue((self.root / f"yoo/DefaultPackage/DefaultPackage_{old_index}.bytes").exists())
|
||||
self.assertTrue(self.invoke("Rollback")["rolledBack"])
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_corrupt_upload_keeps_live_version(self):
|
||||
archive = self.prepare()
|
||||
with zipfile.ZipFile(archive, "w") as stream:
|
||||
item = self.plan["files"][0]
|
||||
stream.writestr(item["path"], b"x" * item["size"])
|
||||
self.invoke("Publish", success=False)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_incomplete_upload_keeps_live_version(self):
|
||||
archive = self.prepare()
|
||||
with zipfile.ZipFile(archive, "w"):
|
||||
pass
|
||||
self.invoke("Publish", success=False)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_stale_version_keeps_live_version(self):
|
||||
self.prepare()
|
||||
self.request["expectedVersionSha256"] = "0" * 64
|
||||
self.invoke("Publish", success=False)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_zip_traversal_keeps_live_version(self):
|
||||
archive = self.prepare()
|
||||
with zipfile.ZipFile(archive, "w") as stream:
|
||||
stream.writestr("../evil", b"bad")
|
||||
self.invoke("Publish", success=False)
|
||||
self.assertFalse((archive.parent / "evil").exists())
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_rollback_refuses_later_release(self):
|
||||
self.prepare()
|
||||
self.invoke("Publish")
|
||||
(self.root / "cdn/version.json").write_bytes(b"later release")
|
||||
self.invoke("Rollback", success=False)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), b"later release")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user