Visual verification at the Holtburg Town network portal revealed the swirl was oriented along world axes (NS) instead of the portal's actual facing (EW), and partially buried in the ground because the hook's local-frame Offset.Origin was being applied in world axes too. Root cause: EntityScriptActivator.OnCreate fired _scriptRunner.Play but never called _particleSink.SetEntityRotation. When the runner's CreateParticleHook fires, the sink reads per-entity rotation from _rotationByEntity (defaults to Quaternion.Identity for unknown entities) and uses it to transform the hook's Offset.Origin from entity-local to world space. Without the seed call, the rotation lookup falls through to Identity and the offset goes off along world XYZ. Fix is a single SetEntityRotation call before the Play call. Added a 4th unit test that constructs an entity with a 90 deg yaw and asserts the spawned particle's world position reflects the rotated offset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93 lines
4 KiB
C#
93 lines
4 KiB
C#
using System;
|
|
using AcDream.Core.Vfx;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Rendering.Vfx;
|
|
|
|
/// <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.
|
|
///
|
|
/// <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.
|
|
/// </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.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class EntityScriptActivator
|
|
{
|
|
private readonly PhysicsScriptRunner _scriptRunner;
|
|
private readonly ParticleHookSink _particleSink;
|
|
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
|
|
|
|
/// <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>
|
|
public EntityScriptActivator(
|
|
PhysicsScriptRunner scriptRunner,
|
|
ParticleHookSink particleSink,
|
|
Func<WorldEntity, uint> defaultScriptResolver)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(scriptRunner);
|
|
ArgumentNullException.ThrowIfNull(particleSink);
|
|
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
|
|
_scriptRunner = scriptRunner;
|
|
_particleSink = particleSink;
|
|
_defaultScriptResolver = defaultScriptResolver;
|
|
}
|
|
|
|
/// <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).
|
|
/// </summary>
|
|
public void OnCreate(WorldEntity entity)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entity);
|
|
if (entity.ServerGuid == 0) return;
|
|
|
|
uint scriptId = _defaultScriptResolver(entity);
|
|
if (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);
|
|
|
|
_scriptRunner.Play(scriptId, entity.ServerGuid, 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).
|
|
/// </summary>
|
|
public void OnRemove(uint serverGuid)
|
|
{
|
|
if (serverGuid == 0) return;
|
|
_scriptRunner.StopAllForEntity(serverGuid);
|
|
_particleSink.StopAllForEntity(serverGuid, fadeOut: false);
|
|
}
|
|
}
|