Editor/EffigyEditor/EffigyPlayerScene.cs
using Editor;
using System;
using System.IO;
namespace Marionette.EditorTools;
/// <summary>
/// The test scene File → Make Player writes: a floor, a light, and a player wearing the model that
/// was just compiled. Press Play and walk around in it.
///
/// WHY A WHOLE SCENE AND NOT A PREVIEW. A playermodel is the one export whose correctness only
/// shows up under the thing that drives it. The viewport can show the bind pose and Rig Control can
/// pose it by hand, and neither says whether citizen's walk cycle lands the feet on the floor,
/// whether the hips ride at the right height, or whether a limb came out fitted inside out. Ten
/// seconds of walking answers all three, and building the scene by hand is twenty minutes of
/// guid-matching that nobody does twice.
///
/// WHY THE JSON IS A STRING IN HERE. It was templated from a scene the editor itself saved, so the
/// spelling is the engine's own rather than a guess — but that scene is a working file that is not
/// in the repo, so depending on it at runtime would make this work on exactly one machine. The text
/// is the dependency instead.
///
/// WHAT THE NUMBERS MEAN, since a scene file explains nothing about itself:
/// - The floor is `models/dev/box.vmdl` scaled 40×40×1 and sunk to z −25 with a 50-unit box
/// collider, which puts its TOP at z 0. Everything else is measured from a floor at zero.
/// - The player stands at z 10 and falls the rest of the way. Its collider bottom is at the
/// object's own origin, so the model's feet are at the object's origin too — which is why the
/// playermodel export refuses to apply a pivot.
/// - The Body child sits at LOCAL 0,0,0. It was at −10 while the export still snapped the mesh
/// onto citizen, to hide the height that snap introduced; a fitted model needs no correction
/// and the offset became a ten-inch sink into the floor.
/// - `CreateBoneObjects` is false. A citizen skeleton is 95 bones and the editor writes one
/// GameObject per bone into the saved scene when this is on — it turned a 6 KB file into 115 KB
/// of things nobody reads, and parented the camera to a rib.
/// </summary>
internal static class EffigyPlayerScene
{
/// <summary>
/// Write <c>Assets/scenes/{name}_walk.scene</c> for a compiled model, and return its absolute
/// path — or null if the scenes folder could not be resolved.
///
/// OVERWRITES. Running Make Player twice on the same model should leave one scene rather than
/// `gearhead_walk` beside `gearhead_walk1`, and there is nothing in here anybody edits by hand
/// that would be worth preserving. Anything you DO add to it is worth saving under another name.
/// </summary>
public static string Write( string modelAssetPath, string name )
{
if ( string.IsNullOrWhiteSpace( modelAssetPath ) || string.IsNullOrWhiteSpace( name ) )
return null;
var folder = EffigyAssetFolder.ResolveAssetFolder( "scenes" );
if ( folder is null )
return null;
Directory.CreateDirectory( folder );
var path = Path.Combine( folder, $"{name}_walk.scene" );
File.WriteAllText( path, SceneJson( modelAssetPath, $"{name} walk" ) );
EffigyAssetFolder.Register( folder );
return path;
}
/// <summary>
/// Fresh guids every time.
///
/// NOT CONSTANTS, even though constants would be simpler and would still load. Two scenes
/// written from the same template would then hold the same object and component ids, and the
/// engine treats a guid as an identity — open both and one is liable to be treated as the other.
/// The three references the PlayerController holds (its renderer, its rigidbody, its collider
/// object) are threaded through from the same variables, so they stay consistent with whatever
/// this run generated.
/// </summary>
static string NewGuid() => Guid.NewGuid().ToString();
static string SceneJson( string model, string title )
{
var scene = NewGuid();
var info = NewGuid();
var infoComponent = NewGuid();
var sun = NewGuid();
var sunComponent = NewGuid();
var floor = NewGuid();
var floorRenderer = NewGuid();
var floorCollider = NewGuid();
var player = NewGuid();
var controller = NewGuid();
var rigidbody = NewGuid();
var moveMode = NewGuid();
var colliders = NewGuid();
var capsule = NewGuid();
var box = NewGuid();
var body = NewGuid();
var renderer = NewGuid();
var camera = NewGuid();
var cameraComponent = NewGuid();
// $$ so a single brace is literal JSON and {{name}} is the interpolation — otherwise every
// brace in the document would have to be doubled and the template would stop being readable
// as the file it produces.
return $$"""
{
"__guid": "{{scene}}",
"GameObjects": [
{
"__guid": "{{info}}",
"__version": 2,
"Flags": 0,
"Name": "Scene Information",
"Position": "0,0,0",
"Rotation": "0,0,0,1",
"Scale": "1,1,1",
"Tags": "",
"Enabled": true,
"NetworkMode": 2,
"Components": [
{
"__type": "Sandbox.SceneInformation",
"__guid": "{{infoComponent}}",
"__enabled": true,
"Description": "",
"SceneTags": "",
"Title": "{{title}}"
}
],
"Children": []
},
{
"__guid": "{{sun}}",
"__version": 2,
"Flags": 0,
"Name": "Sun",
"Position": "0,0,512",
"Rotation": "-0.2,0.35,0.45,0.79",
"Scale": "1,1,1",
"Tags": "light_directional,light",
"Enabled": true,
"NetworkMode": 2,
"Components": [
{
"__type": "Sandbox.DirectionalLight",
"__guid": "{{sunComponent}}",
"__enabled": true,
"FogMode": "Enabled",
"FogStrength": 1,
"LightColor": "0.94,0.94,0.9,1",
"Shadows": true,
"SkyColor": "0.2,0.24,0.29,1"
}
],
"Children": []
},
{
"__guid": "{{floor}}",
"__version": 2,
"Flags": 0,
"Name": "Floor",
"Position": "0,0,-25",
"Rotation": "0,0,0,1",
"Scale": "40,40,1",
"Tags": "",
"Enabled": true,
"NetworkMode": 2,
"Components": [
{
"__type": "Sandbox.ModelRenderer",
"__guid": "{{floorRenderer}}",
"__enabled": true,
"BodyGroups": 18446744073709551615,
"Model": "models/dev/box.vmdl",
"RenderType": "On",
"Tint": "1,1,1,1"
},
{
"__type": "Sandbox.BoxCollider",
"__guid": "{{floorCollider}}",
"__enabled": true,
"Center": "0,0,0",
"IsTrigger": false,
"Scale": "50,50,50",
"Static": false
}
],
"Children": []
},
{
"__guid": "{{player}}",
"__version": 2,
"Flags": 0,
"Name": "Player",
"Position": "0,0,10",
"Rotation": "0,0,0,1",
"Scale": "1,1,1",
"Tags": "",
"Enabled": true,
"NetworkMode": 2,
"Components": [
{
"__type": "Sandbox.PlayerController",
"__guid": "{{controller}}",
"__enabled": true,
"AimStrengthBody": 1,
"AimStrengthEyes": 1,
"AimStrengthHead": 1,
"AirFriction": 0.1,
"AltMoveButton": "run",
"Body": {
"_type": "component",
"component_id": "{{rigidbody}}",
"go": "{{player}}",
"component_type": "Rigidbody"
},
"BodyHeight": 72,
"BodyMass": 500,
"BodyRadius": 16,
"CameraOffset": "256,0,12",
"ColliderObject": {
"_type": "gameobject",
"go": "{{colliders}}"
},
"DuckedHeight": 36,
"DuckedSpeed": 70,
"EnableFootstepSounds": true,
"EnablePressing": true,
"EyeDistanceFromTop": 8,
"HideBodyInFirstPerson": true,
"JumpSpeed": 300,
"LookSensitivity": 1,
"PitchClamp": 90,
"ReachLength": 130,
"Renderer": {
"_type": "component",
"component_id": "{{renderer}}",
"go": "{{body}}",
"component_type": "SkinnedModelRenderer"
},
"RotateWithGround": true,
"RotationAngleLimit": 45,
"RotationSpeed": 1,
"RunSpeed": 320,
"ThirdPerson": true,
"ToggleCameraModeButton": "view",
"UseAnimatorControls": true,
"UseButton": "use",
"UseCameraControls": true,
"UseFovFromPreferences": true,
"UseInputControls": true,
"UseLookControls": true,
"WalkSpeed": 110
},
{
"__type": "Sandbox.Rigidbody",
"__guid": "{{rigidbody}}",
"__enabled": true,
"Flags": 1,
"AngularDamping": 1,
"Gravity": true,
"GravityScale": 1,
"LinearDamping": 0.1,
"Locking": {
"X": false,
"Y": false,
"Z": false,
"Pitch": true,
"Yaw": true,
"Roll": true
},
"MassCenterOverride": "0,0,36",
"MassOverride": 500,
"MotionEnabled": true,
"OverrideMassCenter": true,
"RigidbodyFlags": "DisableCollisionSounds",
"SleepThreshold": 2,
"StartAsleep": false
},
{
"__type": "Sandbox.Movement.MoveModeWalk",
"__guid": "{{moveMode}}",
"__enabled": true,
"GroundAngle": 45,
"StepDownHeight": 18,
"StepUpHeight": 18
}
],
"Children": [
{
"__guid": "{{colliders}}",
"__version": 2,
"Flags": 1,
"Name": "Colliders",
"Position": "0,0,0",
"Rotation": "0,0,0,1",
"Scale": "1,1,1",
"Tags": "",
"Enabled": true,
"NetworkMode": 2,
"Components": [
{
"__type": "Sandbox.CapsuleCollider",
"__guid": "{{capsule}}",
"__enabled": true,
"Flags": 1,
"End": "0,0,36",
"Friction": 0,
"IsTrigger": false,
"Radius": 11.313708,
"Start": "0,0,60.6862907",
"Static": false
},
{
"__type": "Sandbox.BoxCollider",
"__guid": "{{box}}",
"__enabled": true,
"Flags": 1,
"Center": "0,0,18",
"Friction": 0,
"IsTrigger": false,
"Scale": "16,16,36",
"Static": false
}
],
"Children": []
},
{
"__guid": "{{body}}",
"__version": 2,
"Flags": 0,
"Name": "Body",
"Position": "0,0,0",
"Rotation": "0,0,0,1",
"Scale": "1,1,1",
"Tags": "",
"Enabled": true,
"NetworkMode": 2,
"Components": [
{
"__type": "Sandbox.SkinnedModelRenderer",
"__guid": "{{renderer}}",
"__enabled": true,
"BodyGroups": 18446744073709551615,
"CreateAttachments": false,
"CreateBoneObjects": false,
"Model": "{{model}}",
"Morphs": {},
"PlaybackRate": 1,
"RenderType": "On",
"Tint": "1,1,1,1",
"UseAnimGraph": true
}
],
"Children": []
},
{
"__guid": "{{camera}}",
"__version": 2,
"Flags": 0,
"Name": "Camera",
"Position": "0,0,64",
"Rotation": "0,0,0,1",
"Scale": "1,1,1",
"Tags": "maincamera",
"Enabled": true,
"NetworkMode": 2,
"Components": [
{
"__type": "Sandbox.CameraComponent",
"__guid": "{{cameraComponent}}",
"__enabled": true,
"BackgroundColor": "0.33333,0.46275,0.52157,1",
"ClearFlags": "All",
"FieldOfView": 60,
"FovAxis": "Horizontal",
"IsMainCamera": true,
"Orthographic": false,
"Priority": 1,
"ZFar": 10000,
"ZNear": 10
}
],
"Children": []
}
]
}
],
"SceneProperties": {
"NetworkInterpolation": true,
"PhysicsMode": "Physics3D",
"TimeScale": 1,
"WantsSystemScene": true,
"Metadata": {
"Title": "{{title}}"
},
"NavMesh": {
"Enabled": false
}
},
"ResourceVersion": 3,
"Title": "{{title}}",
"Description": null,
"__references": [],
"__version": 3
}
""";
}
}