feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms
Resolver returns ScriptActivationInfo(ScriptId, PartTransforms) — one dat lookup per spawn yields both pieces of info. The C.1.5a ServerGuid==0 guard is relaxed: activator now keys by ServerGuid when nonzero, else entity.Id, so dat-hydrated entities (EnvCell statics, exterior stabs) flow through the same code path as server-spawned ones. PartTransforms pushed into ParticleHookSink before scheduling Play, closing the activator side of #56. GameWindow resolver lambda upgraded: now constructs ScriptActivationInfo from setup.DefaultScript.DataId + SetupPartTransforms.Compute(setup), swallowing dat-lookup throws the same way C.1.5a did. Tests: 4 existing tests updated for new ScriptActivationInfo signature; 3 new tests cover entity.Id keying for dat-hydrated entities, end-to-end part-transform pipeline (resolver → sink → particle world position), and OnRemove with an arbitrary caller-picked key. 77 Vfx+Meshing+Activator tests green. GpuWorldState fire-site wiring (Task 4) lands next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
11521f4418
commit
5ca5827abe
3 changed files with 216 additions and 53 deletions
|
|
@ -1614,26 +1614,32 @@ public sealed class GameWindow : IDisposable
|
|||
_textureCache!, SequencerFactory, _wbMeshAdapter!);
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
|
||||
// Phase C.1.5a: construct EntityScriptActivator so server-spawned static
|
||||
// entities (portals first) fire Setup.DefaultScript through the
|
||||
// PhysicsScriptRunner on enter-world. _scriptRunner and _particleSink
|
||||
// are initialised earlier in OnLoad (line ~1083); both are non-null
|
||||
// here. The resolver lambda captures _dats and swallows dat-lookup
|
||||
// throws — see C.1.5a spec §6 (error handling) for rationale.
|
||||
uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e)
|
||||
// Phase C.1.5a/b: construct EntityScriptActivator so static entities
|
||||
// (server-spawned AND dat-hydrated) fire Setup.DefaultScript through
|
||||
// the PhysicsScriptRunner on enter-world. C.1.5b adds per-part
|
||||
// transforms via SetupPartTransforms.Compute so multi-emitter scripts
|
||||
// distribute across mesh parts (closes #56). _scriptRunner and
|
||||
// _particleSink are initialised earlier in OnLoad (line ~1083); both
|
||||
// are non-null here. The resolver lambda captures _dats and swallows
|
||||
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
|
||||
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var setup = capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
return setup?.DefaultScript.DataId ?? 0u;
|
||||
if (setup is null) return null;
|
||||
uint scriptId = setup.DefaultScript.DataId;
|
||||
if (scriptId == 0) return null;
|
||||
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
|
||||
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0u;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
|
||||
_scriptRunner!, _particleSink!, ResolveDefaultScript);
|
||||
_scriptRunner!, _particleSink!, ResolveActivation);
|
||||
_entityScriptActivator = entityScriptActivator;
|
||||
|
||||
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
|
||||
|
|
|
|||
|
|
@ -1,93 +1,133 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// What the activator's resolver returns when an entity's Setup carries
|
||||
/// a <c>DefaultScript</c>. Bundles the script id with the per-part
|
||||
/// transforms baked from <c>Setup.PlacementFrames</c> so a single dat
|
||||
/// lookup yields both pieces of state. The activator pushes the part
|
||||
/// transforms into <see cref="ParticleHookSink.SetEntityPartTransforms"/>
|
||||
/// before calling <see cref="PhysicsScriptRunner.Play"/>, which closes
|
||||
/// the part-anchor pipeline introduced for issue #56.
|
||||
/// </summary>
|
||||
public sealed record ScriptActivationInfo(
|
||||
uint ScriptId,
|
||||
IReadOnlyList<Matrix4x4> PartTransforms);
|
||||
|
||||
/// <summary>
|
||||
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
|
||||
/// when a server-spawned <see cref="WorldEntity"/> enters the world, so static
|
||||
/// objects (portals, chimneys, fireplaces, building details) emit their
|
||||
/// retail-faithful persistent particle effects automatically. Stops the
|
||||
/// scripts and live emitters when the entity despawns.
|
||||
/// when a <see cref="WorldEntity"/> enters the world, so static objects
|
||||
/// (portals, chimneys, fireplaces, EnvCell decorations, building details)
|
||||
/// emit their retail-faithful persistent particle effects automatically.
|
||||
/// Stops the scripts and live emitters when the entity despawns.
|
||||
///
|
||||
/// <para>
|
||||
/// Handles both server-spawned entities (<c>ServerGuid != 0</c>, keyed by
|
||||
/// ServerGuid) and dat-hydrated entities (<c>ServerGuid == 0</c>, keyed by
|
||||
/// <c>entity.Id</c>). The C.1.5a guard that early-returned for
|
||||
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
|
||||
/// (which have no server guid because they come from the dat file, not
|
||||
/// the network) also fire their DefaultScript.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
|
||||
/// adapter handles meshes + animation state, the activator handles scripts +
|
||||
/// particles. Both are render-thread-only.
|
||||
/// adapter handles meshes + animation state, the activator handles scripts
|
||||
/// + particles. Both are render-thread-only. The activator is invoked from
|
||||
/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock,
|
||||
/// AddEntitiesToExistingLandblock, plus the matching remove paths).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
|
||||
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1
|
||||
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner;
|
||||
/// this class adds the missing fire-on-spawn call site.
|
||||
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan
|
||||
/// §C.1 and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the
|
||||
/// runner; this class adds the missing fire-on-spawn call site.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EntityScriptActivator
|
||||
{
|
||||
private readonly PhysicsScriptRunner _scriptRunner;
|
||||
private readonly ParticleHookSink _particleSink;
|
||||
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
|
||||
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
|
||||
|
||||
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
|
||||
/// (scriptId, entityId) instance table and schedules hooks at their
|
||||
/// <c>StartTime</c> offsets.</param>
|
||||
/// <param name="particleSink">Already-shipped hook sink from C.1. The
|
||||
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/>
|
||||
/// to drop any per-entity emitter handles on despawn.</param>
|
||||
/// <param name="defaultScriptResolver">Returns
|
||||
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>,
|
||||
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits
|
||||
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled
|
||||
/// stub.</param>
|
||||
/// activator pushes per-entity rotation + part transforms here, and
|
||||
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
|
||||
/// per-entity emitter handles on despawn.</param>
|
||||
/// <param name="resolver">Returns
|
||||
/// <see cref="ScriptActivationInfo"/> with the entity's
|
||||
/// <c>Setup.DefaultScript.DataId</c> and per-part transforms (via
|
||||
/// <c>SetupPartTransforms.Compute</c>), or <c>null</c> on dat miss /
|
||||
/// throw / missing DefaultScript. Production lambda hits
|
||||
/// <c>DatCollection</c>; tests pass a hand-rolled stub.</param>
|
||||
public EntityScriptActivator(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
ParticleHookSink particleSink,
|
||||
Func<WorldEntity, uint> defaultScriptResolver)
|
||||
Func<WorldEntity, ScriptActivationInfo?> resolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scriptRunner);
|
||||
ArgumentNullException.ThrowIfNull(particleSink);
|
||||
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
_scriptRunner = scriptRunner;
|
||||
_particleSink = particleSink;
|
||||
_defaultScriptResolver = defaultScriptResolver;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
|
||||
/// the script runner. No-op if the entity has no DefaultScript
|
||||
/// (resolver returns 0) or if the entity has no server guid
|
||||
/// (atlas-tier entities are out of scope for this activator).
|
||||
/// the script runner. Keys by <c>entity.ServerGuid</c> when non-zero,
|
||||
/// otherwise by <c>entity.Id</c> (the latter handles dat-hydrated
|
||||
/// EnvCell statics + exterior stabs whose <c>entity.Id</c> lives in
|
||||
/// the <c>0x40xxxxxx</c> range — collision-free with server guids).
|
||||
/// No-op if the entity has no DefaultScript (resolver returns null
|
||||
/// or zero-script).
|
||||
/// </summary>
|
||||
public void OnCreate(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (entity.ServerGuid == 0) return;
|
||||
uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
|
||||
if (key == 0) return; // malformed entity
|
||||
|
||||
uint scriptId = _defaultScriptResolver(entity);
|
||||
if (scriptId == 0) return;
|
||||
var info = _resolver(entity);
|
||||
if (info is null || info.ScriptId == 0) return;
|
||||
|
||||
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
|
||||
// (in entity-local frame) transforms correctly to world space when the
|
||||
// hook fires. Without this, the sink falls through to Quaternion.Identity
|
||||
// and the offset gets applied in world axes — visual symptom for portals:
|
||||
// swirl oriented along world XYZ instead of the portal's facing, partially
|
||||
// buried because the local-Z lift becomes a world-axis offset.
|
||||
_particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation);
|
||||
// hook fires. C.1.5a fix: without this, the sink falls through to
|
||||
// Quaternion.Identity and the offset gets applied in world axes —
|
||||
// visual symptom for portals: swirl oriented along world XYZ instead
|
||||
// of the portal's facing, partially buried.
|
||||
_particleSink.SetEntityRotation(key, entity.Rotation);
|
||||
|
||||
_scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position);
|
||||
// C.1.5b #56: seed the sink's per-entity part transforms so
|
||||
// CreateParticleHook.PartIndex routes the hook offset through the
|
||||
// right mesh part's resting transform. Without this, every emitter
|
||||
// in a multi-part Setup collapses to the entity root.
|
||||
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
|
||||
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop every script instance the runner is tracking for this entity, and
|
||||
/// kill every live emitter the sink has attributed to it. Idempotent for
|
||||
/// unknown guids (both calls no-op).
|
||||
/// Stop every script instance the runner is tracking for this key, and
|
||||
/// kill every live emitter the sink has attributed to it. Caller picks
|
||||
/// the key (the matching ServerGuid for live entities, or
|
||||
/// <c>entity.Id</c> for dat-hydrated entities — mirror whatever was
|
||||
/// used at <see cref="OnCreate"/>). Idempotent for unknown keys.
|
||||
/// </summary>
|
||||
public void OnRemove(uint serverGuid)
|
||||
public void OnRemove(uint key)
|
||||
{
|
||||
if (serverGuid == 0) return;
|
||||
_scriptRunner.StopAllForEntity(serverGuid);
|
||||
_particleSink.StopAllForEntity(serverGuid, fadeOut: false);
|
||||
if (key == 0) return;
|
||||
_scriptRunner.StopAllForEntity(key);
|
||||
_particleSink.StopAllForEntity(key, fadeOut: false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue