feat(content): author thirty stages, live hints, progression and Blender themes

This commit is contained in:
NINI
2026-10-05 13:05:27 +09:00
parent 60f8a55bb9
commit 64f4f19ae9
304 changed files with 20710 additions and 419 deletions
+159
View File
@@ -0,0 +1,159 @@
param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554')
$ErrorActionPreference='Stop'
$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path
$adb=Join-Path $env:LOCALAPPDATA 'Android/Sdk/platform-tools/adb.exe'
$package='com.magnetismheroes.game'; $activity="$package/com.unity3d.player.UnityPlayerGameActivity"
$remote="/sdcard/Android/data/$package/files/progress.json"
$checks=[Collections.Generic.List[object]]::new(); $commands=[Collections.Generic.List[object]]::new(); $recorders=[Collections.Generic.List[object]]::new()
$result=[ordered]@{milestone='Content';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);serial=$Serial;apkSha256=(Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash;status='failed'}
$networkBefore=$null
function Adb([string[]]$Arguments) {
$output=& $adb -s $Serial @Arguments 2>&1; $code=$LASTEXITCODE
$commands.Add(@{command=('adb -s '+$Serial+' '+($Arguments -join ' '));exitCode=$code;output=($output -join "`n")})
if ($code -ne 0) { throw "ADB exit $code : $($Arguments -join ' ')" }; return ($output -join "`n").Trim()
}
function Check([string]$Id,[bool]$Passed,[string]$Actual) {
$checks.Add(@{id=$Id;passed=$Passed;actual=$Actual}); if (!$Passed) { throw "Content check failed: $Id ($Actual)" }
}
function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500) {
& (Join-Path $PSScriptRoot 'observe.ps1') -Step $Name -AdbArguments $Arguments -Serial $Serial -EvidenceDir $EvidenceDir -DelayMilliseconds $Delay
}
function Save([string]$Name) { Adb @('pull',$remote,(Join-Path $EvidenceDir "$Name.json")) | Out-Null; return Get-Content -LiteralPath (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json }
function Same($First,$Second) { return ($First | ConvertTo-Json -Depth 12 -Compress) -eq ($Second | ConvertTo-Json -Depth 12 -Compress) }
function BallPoint($Board,[string]$Id) {
$ball=@($Board.balls | Where-Object id -eq $Id)[0]; $camera=$Board.camera
$rotation=[Numerics.Quaternion]::new($camera.rotation.x,$camera.rotation.y,$camera.rotation.z,$camera.rotation.w)
$origin=[Numerics.Vector3]::new($camera.target.x,$camera.target.y,$camera.target.z)+[Numerics.Vector3]::Transform([Numerics.Vector3]::new(0,0,-$camera.distance),$rotation)
$local=[Numerics.Vector3]::Transform([Numerics.Vector3]::new($ball.position.x,$ball.position.y,$ball.position.z)-$origin,[Numerics.Quaternion]::Inverse($rotation))
$scale=540/([Math]::Tan(23*[Math]::PI/180)*$local.Z)
return @([int][Math]::Round(1200+$local.X*$scale),[int][Math]::Round(540-$local.Y*$scale))
}
function TapBall([string]$Name,$Board,[string]$Id) { $point=BallPoint $Board $Id; Step $Name @('shell','input','tap',"$($point[0])","$($point[1])") }
function StagePoint([int]$Id) { $index=$Id-1; return @([int][Math]::Round((.12+($index%6)*.152)*2400),[int][Math]::Round((1-(.76-[Math]::Floor($index/6)*.123))*1080)) }
function SelectStage([string]$Name,[int]$Id) {
Step "$Name-selection" @('shell','input','tap','1200','616')
$point=StagePoint $Id; Step "$Name-stage" @('shell','input','tap',"$($point[0])","$($point[1])")
}
function Restart([string]$Name) {
Adb @('shell','input','keyevent','3') | Out-Null; Adb @('shell','am','force-stop',$package) | Out-Null
Step "$Name-home" @('shell','am','start','-W','-n',$activity) 6500
}
function Fixture([int]$Stage) {
Adb @('shell','am','force-stop',$package) | Out-Null
$fixture=[ordered]@{schemaVersion=1;completedStageIds=@(1..($Stage-1));seenTutorialIds=@();puzzleStates=@();latestFreePlay=$null}
$path=Join-Path $EvidenceDir "fixture-$Stage.json"; $fixture | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path -Encoding utf8
Adb @('shell','mkdir','-p',"/sdcard/Android/data/$package/files") | Out-Null; Adb @('push',$path,$remote) | Out-Null
Adb @('shell','rm','-f',($remote+'.bak')) | Out-Null
Step "fixture-$Stage-home" @('shell','am','start','-W','-n',$activity) 6500
Step "fixture-$Stage-selection" @('shell','input','tap','1200','616')
}
function StartAudio([string]$Name,[int]$Seconds) {
$args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',(Join-Path $EvidenceDir "$Name.wav"),'-Seconds',"$Seconds")
$process=Start-Process pwsh -ArgumentList ($args | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru
$recorders.Add(@{name=$Name;process=$process}); Start-Sleep -Seconds 2
}
function CompareAudio([string]$Name,[string[]]$Ids) {
$recorder=@($recorders | Where-Object name -eq $Name)[0].process
Check "audio-finished-$Name" ($recorder.WaitForExit(30000)) 'PCM capture finished'; $recorder.Refresh(); Check "audio-exit-$Name" ($recorder.ExitCode -eq 0) "$($recorder.ExitCode)"
foreach ($id in $Ids) {
$reference=Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$id.wav"
$comparison=Join-Path $EvidenceDir "$Name-$id-match.json"
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $reference -CapturedPath (Join-Path $EvidenceDir "$Name.wav") -OutputPath $comparison
$exit=$LASTEXITCODE; $match=Get-Content -LiteralPath $comparison -Raw | ConvertFrom-Json
Check "pcm-$Name-$id" ($exit -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95 -and $match.rmsPcm -gt 32) ($match | ConvertTo-Json -Compress)
}
}
try {
Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit
$build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
Check 'build-provenance' ($build.status -eq 'passed' -and $build.exitCode -eq 0 -and !$build.sourceDirty -and !$build.sourceDirtyEnd -and $build.sourceCommit -eq $result.sourceCommit -and $build.sourceCommitEnd -eq $result.sourceCommit -and $build.apkSha256 -eq $result.apkSha256) $result.apkSha256
Adb @('wait-for-device') | Out-Null; Check 'boot-completed' ((Adb @('shell','getprop','sys.boot_completed')) -eq '1') 'Emulator boot completed'
$result.device=@{model=(Adb @('shell','getprop','ro.product.model'));api=(Adb @('shell','getprop','ro.build.version.sdk'));abi=(Adb @('shell','getprop','ro.product.cpu.abilist'));bridge=(Adb @('shell','getprop','ro.dalvik.vm.native.bridge'))}
Adb @('install','-r',$ApkPath) | Out-Null; Adb @('logcat','-c') | Out-Null; Adb @('shell','pm','clear',$package) | Out-Null
$networkBefore=@{airplane=(Adb @('shell','settings','get','global','airplane_mode_on'));wifi=(Adb @('shell','settings','get','global','wifi_on'));data=(Adb @('shell','settings','get','global','mobile_data'))}; $result.networkBefore=$networkBefore
Adb @('shell','cmd','connectivity','airplane-mode','enable') | Out-Null; Adb @('shell','svc','wifi','disable') | Out-Null; Adb @('shell','svc','data','disable') | Out-Null
Check 'offline-real-network' ((Adb @('shell','settings','get','global','airplane_mode_on')) -eq '1' -and (Adb @('shell','settings','get','global','wifi_on')) -eq '0' -and (Adb @('shell','settings','get','global','mobile_data')) -eq '0' -and (Adb @('shell','dumpsys','connectivity')) -match 'Active default network: none') 'airplane1 wifi0 data0 no active default network'
Step 'fresh-home' @('shell','am','start','-W','-n',$activity) 6500; Step 'fresh-30-selection' @('shell','input','tap','1200','616')
$two=StagePoint 2; Step 'fresh-locked-two' @('shell','input','tap',"$($two[0])","$($two[1])")
$locked=Save 'fresh-locked'; Check 'fresh-only-one' ($locked.completedStageIds.Count -eq 0 -and $locked.puzzleStates.Count -eq 0) 'Locked Stage2 tap opens no puzzle'
StartAudio 'audio-one-offline' 20
$one=StagePoint 1; Step 'one-initial' @('shell','input','tap',"$($one[0])","$($one[1])") 2500
$initial=Save 'one-initial'; Step 'one-help-find' @('shell','input','tap','312','713') 4500
Check 'one-help-readonly' (Same $initial (Save 'one-help-after')) 'Help highlights one real current ball without changing save'
$point=BallPoint $initial.puzzleStates[0] $initial.puzzleStates[0].balls[0].id
Step 'one-direct-drag' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+80)","$($point[1])",'650') 4000
$collected=Save 'one-collected'; Check 'one-direct-required' ($collected.puzzleStates[0].collectedBallIds.Count -eq 1 -and $collected.puzzleStates[0].countedBallIds.Count -eq 0) 'Real drag collected, no automatic count'
TapBall 'one-count-success' $collected.puzzleStates[0] $collected.puzzleStates[0].balls[0].id
$free=Save 'one-success'; Check 'one-same-board-free' ($free.completedStageIds -contains 1 -and $free.puzzleStates.Count -eq 0 -and (Same $free.latestFreePlay.balls $collected.puzzleStates[0].balls) -and (Same $free.latestFreePlay.camera $collected.puzzleStates[0].camera)) 'Successful free preserves real positions/camera and atomically removes puzzle'
CompareAudio 'audio-one-offline' @('guide_collect','guide_find','guide_count','count_01')
StartAudio 'audio-two-help' 23
Step 'next-two' @('shell','input','tap','2064','648') 2500; Step 'two-help-find' @('shell','input','tap','312','713') 4500
Step 'two-direct-merge' @('shell','input','swipe','1009','510','1291','510','650') 4000
$twoCollected=Save 'two-collected'; $board=@($twoCollected.puzzleStates | Where-Object stageId -eq 2)[0]
Check 'two-real-collected' ($board.groups.Count -eq 1 -and $board.collectedBallIds.Count -eq 2) 'Real confirmed merge collects both'
TapBall 'two-first-count' $board $board.balls[0].id; Start-Sleep -Seconds 1
$beforeHelp=Save 'two-counted-one'; Step 'two-help-uncounted' @('shell','input','tap','312','713') 3500
Check 'two-help-current-readonly' (Same $beforeHelp (Save 'two-help-current-after')) 'Current uncounted highlight leaves counted ID and save intact'
Step 'two-orbit-camera' @('shell','input','swipe','2150','450','2210','475','650')
$partial=Save 'two-positive-partial'; $partialBoard=@($partial.puzzleStates | Where-Object stageId -eq 2)[0]
Check 'positive-partial-count' ($partialBoard.countedBallIds.Count -eq 1 -and $partialBoard.collectedBallIds.Count -eq 2 -and $partial.completedStageIds -notcontains 2 -and [Math]::Abs($partialBoard.camera.rotation.w-1) -gt .001) 'Actual1/2 count and nonidentity camera saved'
CompareAudio 'audio-two-help' @('guide_find','guide_uncounted','count_01')
Restart 'two-pause-restart'; SelectStage 'two-resumed' 2
$restored=Save 'two-restored'; Check 'positive-partial-restored' (Same $partialBoard @($restored.puzzleStates | Where-Object stageId -eq 2)[0]) 'All real IDs positions groups collected counted camera fields restored offline'
Step 'two-home' @('shell','input','tap','312','108'); SelectStage 'one-replay' 1
Step 'one-replay-home' @('shell','input','tap','312','108'); $replay=Save 'replay-left'
Check 'replay-keeps-record-and-partial' ($replay.completedStageIds -contains 1 -and @($replay.puzzleStates | Where-Object stageId -eq 2)[0].countedBallIds.Count -eq 1) 'Leaving replay keeps completed1 and unfinished2'
Fixture 6; StartAudio 'audio-six-demo' 17
$six=StagePoint 6; Step 'six-ghost-initial' @('shell','input','tap',"$($six[0])","$($six[1])") 1200
$sixBefore=Save 'six-ghost-before'; Check 'six-tutorial-seen' ($sixBefore.seenTutorialIds -contains 'merge') 'First6 records merge seen'
Step 'six-ghost-moved' @() 2200; $sixDuring=Save 'six-ghost-during'; Check 'six-ghost-board-unchanged' (Same $sixBefore $sixDuring) 'Ghost leaves real IDs positions groups camera and completion unchanged'
Step 'six-ghost-ended' @() 5000; Step 'six-help-merge' @('shell','input','tap','312','713') 4000
Check 'six-help-readonly' (Same $sixBefore (Save 'six-help-after')) 'Current merge help does not solve'
CompareAudio 'audio-six-demo' @('demo_merge6','guide_merge')
Restart 'six-reentry'; SelectStage 'six-reentered' 6
$sixAgain=Save 'six-reentry-save'; Check 'six-seen-board-preserved' (Same $sixBefore $sixAgain) 'Reentry preserves seen merge and real unfinished board'
Step 'six-real-solve' @('shell','input','swipe','1009','510','1291','510','650')
$sixSuccess=Save 'six-success'; Check 'six-free-final' ($sixSuccess.completedStageIds -contains 6 -and $sixSuccess.latestFreePlay.groups.Count -eq 1 -and $sixSuccess.latestFreePlay.groups[0].ballIds.Count -eq 2) 'Actual merge solves6 to one2 group'
Step 'next-seven' @('shell','input','tap','2064','648'); $seven=Save 'seven-next'; $sevenBoard=@($seven.puzzleStates | Where-Object stageId -eq 7)[0]
Check 'actual-next-seven' ($sevenBoard.stageId -eq 7) 'Next enters7 without automatic prior transition'
Step 'seven-partial-drag' @('shell','input','swipe','1106','503','1050','503','650')
$sevenPartial=Save 'seven-partial'; Step 'seven-home' @('shell','input','tap','312','108'); SelectStage 'six-replay-again' 6
Step 'six-replay-solve' @('shell','input','swipe','1009','510','1291','510','650'); Step 'next-seven-resume' @('shell','input','tap','2064','648')
$sevenResume=Save 'seven-next-resumed'; Check 'next-prefers-seven-save' (Same @($sevenPartial.puzzleStates | Where-Object stageId -eq 7)[0] @($sevenResume.puzzleStates | Where-Object stageId -eq 7)[0]) 'Next resumes existing7 state'
Fixture 10; StartAudio 'audio-ten-demo-help' 20
$ten=StagePoint 10; Step 'ten-ghost-start' @('shell','input','tap',"$($ten[0])","$($ten[1])") 1200
$tenBefore=Save 'ten-ghost-before'; Check 'ten-tutorial-seen' ($tenBefore.seenTutorialIds -contains 'split') 'First10 records split seen'
Step 'ten-ghost-selected-moved' @() 2000; Check 'ten-ghost-real-board-unchanged' (Same $tenBefore (Save 'ten-ghost-during')) 'Ghost moves selected silhouette only'
Step 'ten-ghost-ended' @() 6500; Step 'ten-current-split-help' @('shell','input','tap','312','713') 6000
Check 'ten-help-current-board-unchanged' (Same $tenBefore (Save 'ten-help-after')) 'Help selects currently valid2→1+1 without execution'
CompareAudio 'audio-ten-demo-help' @('demo_split10','guide_split_2_1')
Restart 'ten-reentry'; SelectStage 'ten-reentered' 10
Check 'ten-seen-and-board-preserved' (Same $tenBefore (Save 'ten-reentry-save')) 'No second first-entry mutation'
Step 'ten-choose-real' @('shell','input','tap','1120','510'); Step 'ten-split-mode' @('shell','input','tap','312','832'); Step 'ten-select-real' @('shell','input','tap','1120','510')
Step 'ten-real-split' @('shell','input','swipe','1120','510','850','510','650'); $tenSuccess=Save 'ten-success'
Check 'ten-final-one-one' ($tenSuccess.completedStageIds -contains 10 -and $tenSuccess.latestFreePlay.groups.Count -eq 2 -and @($tenSuccess.latestFreePlay.groups | Where-Object {$_.ballIds.Count -ne 1}).Count -eq 0) 'Actual select/drag yields1,1 and success'
Step 'next-eleven-cloud' @('shell','input','tap','2064','648'); $eleven=Save 'eleven-next'
Check 'actual-next-eleven' (@($eleven.puzzleStates | Where-Object stageId -eq 11)[0].stageId -eq 11) 'Stage11 opens with its Cloud theme source'
Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'content-runtime.log') -Encoding utf8
Check 'runtime-no-exception' ((Get-Content -LiteralPath (Join-Path $EvidenceDir 'content-runtime.log') -Raw) -notmatch 'NullReferenceException|FATAL EXCEPTION|MissingReferenceException') 'Runtime log has no checked fatal/Unity reference exceptions'
$result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain)
Check 'unchanged-source-end' (!$result.sourceDirtyEnd -and $result.sourceCommitEnd -eq $result.sourceCommit) $result.sourceCommitEnd
$result.status='passed'
} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red }
finally {
if ($networkBefore) {
try {
Adb @('shell','cmd','connectivity','airplane-mode',$(if ($networkBefore.airplane -eq '1') {'enable'} else {'disable'})) | Out-Null
Adb @('shell','svc','wifi',$(if ($networkBefore.wifi -eq '1') {'enable'} else {'disable'})) | Out-Null
Adb @('shell','svc','data',$(if ($networkBefore.data -eq '1') {'enable'} else {'disable'})) | Out-Null
$result.networkAfter=@{airplane=(Adb @('shell','settings','get','global','airplane_mode_on'));wifi=(Adb @('shell','settings','get','global','wifi_on'));data=(Adb @('shell','settings','get','global','mobile_data'))}
Check 'network-restored' (Same $networkBefore $result.networkAfter) ($result.networkAfter | ConvertTo-Json -Compress)
} catch { $result.status='failed'; $result.restoreError=$_.Exception.Message }
}
foreach ($recorder in $recorders) { if (!$recorder.process.HasExited) { $recorder.process.WaitForExit(30000) | Out-Null } }
$result.checks=$checks.ToArray(); $result.commands=$commands.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o')
$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-content-evidence.json') -Encoding utf8
}
if ($result.status -ne 'passed') { exit 1 }
Write-Host "Content Android real-input/offline/save/ghost/help/next checks passed: $($checks.Count) checks."
exit 0
+4
View File
@@ -1,4 +1,8 @@
param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554')
if ($Milestone -eq 'Content') {
& (Join-Path $PSScriptRoot 'exercise-content.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial
exit $LASTEXITCODE
}
if ($Milestone -eq 'Persistence') {
& (Join-Path $PSScriptRoot 'exercise-persistence.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial
exit $LASTEXITCODE
+68
View File
@@ -0,0 +1,68 @@
"""Self-created cartoon scenery; run with Blender 5.2.2 --background --python."""
import bpy
import math
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / 'SourceAssets/Models'
EXPORT = ROOT / 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Models'
SOURCE.mkdir(parents=True, exist_ok=True)
EXPORT.mkdir(parents=True, exist_ok=True)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
def part(shape, location, scale):
if shape == 'box': bpy.ops.mesh.primitive_cube_add(size=1, location=location)
elif shape == 'sphere': bpy.ops.mesh.primitive_uv_sphere_add(segments=20, ring_count=12, radius=1, location=location)
elif shape == 'cylinder': bpy.ops.mesh.primitive_cylinder_add(vertices=24, radius=1, depth=2, location=location)
obj = bpy.context.object
obj.scale = scale
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
if shape == 'box':
bevel = obj.modifiers.new('SoftToyCorners', 'BEVEL'); bevel.width=.08; bevel.segments=3
bpy.ops.object.modifier_apply(modifier=bevel.name)
for face in obj.data.polygons: face.use_smooth = shape != 'box'
return obj
def finish(name, parts):
bpy.ops.object.select_all(action='DESELECT')
for obj in parts: obj.select_set(True)
bpy.context.view_layer.objects.active=parts[0]
bpy.ops.object.join()
obj=bpy.context.object; obj.name=name
bpy.context.scene.cursor.location=(0,0,0)
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
bpy.ops.export_scene.fbx(filepath=str(EXPORT / (name+'.fbx')), use_selection=True,
object_types={'MESH'}, axis_forward='-Z', axis_up='Y', apply_unit_scale=True,
apply_scale_options='FBX_SCALE_ALL', use_mesh_modifiers=True, bake_anim=False,
add_leaf_bones=False, path_mode='AUTO')
obj.hide_set(True)
return obj
finish('Island', [part('cylinder',(0,0,-.18),(3,2.25,.18))])
finish('BlockArch', [part('box',(-.95,0,.85),(.45,.7,1.7)),part('box',(.95,0,.85),(.45,.7,1.7)),part('box',(0,0,1.85),(2.35,.7,.4))])
finish('ToySteps', [part('box',(-.35,0,.18),(.65,1,.36)),part('box',(.35,0,.4),(.65,1,.8))])
finish('Pebbles', [part('sphere',(-.32,0,.12),(.38,.3,.22)),part('sphere',(.32,.12,.16),(.35,.28,.26))])
finish('GardenFlower', [part('cylinder',(0,0,.65),(.1,.1,.65)),part('sphere',(0,0,1.32),(.23,.23,.23))]+[part('sphere',(.35*math.cos(i*math.pi/3),0,1.32+.35*math.sin(i*math.pi/3)),(.25,.15,.25)) for i in range(6)])
finish('GardenMushroom', [part('cylinder',(0,0,.38),(.22,.22,.38)),part('sphere',(0,0,.85),(.65,.65,.25))])
finish('GardenLeaf', [part('sphere',(0,0,.25),(.75,.22,.25)),part('cylinder',(.7,0,.18),(.08,.08,.18))])
finish('CloudPuff', [part('sphere',(x,0,z),(s,.55,s)) for x,z,s in [(-.65,.55,.5),(0,.65,.7),(.65,.5,.5)]])
finish('CloudRainbow', [part('sphere',(1.25*math.cos(i*math.pi/12),0,1.25*math.sin(i*math.pi/12)),(.2,.3,.2)) for i in range(13)])
finish('CloudMoon', [part('sphere',(.35*math.cos(i*math.pi/6),0,.65+.6*math.sin(i*math.pi/6)),(.22,.18,.22)) for i in range(1,12)])
# Closed extruded five-point star, with a base pivot.
vertices=[]
for depth in (-.2,.2):
for i in range(10):
angle=math.pi/2+i*math.pi/5; radius=.8 if i%2==0 else .36
vertices.append((radius*math.cos(angle),depth,.8+radius*math.sin(angle)))
faces=[tuple(range(9,-1,-1)),tuple(range(10,20))]+[(i,(i+1)%10,(i+1)%10+10,i+10) for i in range(10)]
mesh=bpy.data.meshes.new('StarMesh'); mesh.from_pydata(vertices,[],faces); mesh.update()
obj=bpy.data.objects.new('Star',mesh); bpy.context.collection.objects.link(obj)
finish('WorkshopStar',[obj])
finish('WorkshopGear',[part('cylinder',(0,0,.18),(.6,.6,.18))]+[part('box',(.6*math.cos(i*math.pi/4),.6*math.sin(i*math.pi/4),.18),(.35,.35,.3)) for i in range(8)])
finish('WorkshopCrystals',[part('box',(-.3,0,.6),(.38,.38,1.2)),part('box',(.25,.12,.4),(.35,.35,.8))])
for index, obj in enumerate(bpy.context.scene.objects):
obj.hide_set(False)
obj.location=(index%5*5, index//5*5, 0)
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE/'PlaygroundProps.blend'))
print('Saved self-created source and 13 FBX exports with base pivots, meter units, Y-up Unity export.')