Polishes
This commit is contained in:
@@ -27,6 +27,12 @@ namespace ProjectM.Client
|
||||
|
||||
byte _lastPhase;
|
||||
bool _phaseInit;
|
||||
AudioClip _stingCoreHit;
|
||||
int _lastCore = -1;
|
||||
float _coreStingCooldown;
|
||||
AudioClip _stingBeep, _stingRoar;
|
||||
int _lastCountdownSec = -1;
|
||||
bool _bossRoared;
|
||||
|
||||
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
|
||||
|
||||
@@ -36,6 +42,9 @@ namespace ProjectM.Client
|
||||
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
|
||||
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
|
||||
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm
|
||||
_stingCoreHit = MakeSting(240f, 70f, 0.30f, 0.55f); // harsh falling "Core hit" alarm
|
||||
_stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick
|
||||
_stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl)
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
@@ -76,6 +85,50 @@ namespace ProjectM.Client
|
||||
// Ease the drone intensity toward the phase target (tenser during Defend).
|
||||
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
|
||||
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
|
||||
|
||||
// Core-under-attack alarm: a falling sting on each CoreIntegrity drop (rate-limited) so a base
|
||||
// breach is AUDIBLE even when the fight has the player's eyes elsewhere.
|
||||
_coreStingCooldown -= SystemAPI.Time.DeltaTime;
|
||||
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core))
|
||||
{
|
||||
if (_lastCore >= 0 && core.Current < _lastCore && _coreStingCooldown <= 0f)
|
||||
{
|
||||
_ambient.PlayOneShot(_stingCoreHit, 0.8f * GameVolume.Sfx);
|
||||
_coreStingCooldown = 0.7f;
|
||||
}
|
||||
_lastCore = core.Current;
|
||||
}
|
||||
|
||||
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only.
|
||||
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
|
||||
{
|
||||
int sec = -1;
|
||||
if (runAudio.Lifecycle == RunLifecycle.Launching && runAudio.LaunchTick != 0
|
||||
&& SystemAPI.TryGetSingleton<NetworkTime>(out var antime) && antime.ServerTick.IsValid)
|
||||
{
|
||||
int tl = new NetworkTick(runAudio.LaunchTick).TicksSince(antime.ServerTick);
|
||||
if (tl > 0) sec = tl / 60 + 1;
|
||||
}
|
||||
if (sec > 0 && sec != _lastCountdownSec && sec <= 3)
|
||||
_ambient.PlayOneShot(_stingBeep, 0.5f * GameVolume.Sfx);
|
||||
_lastCountdownSec = sec;
|
||||
|
||||
bool inBossRoom = runAudio.Lifecycle == RunLifecycle.InRoom
|
||||
&& runAudio.CurrentRoomType == RoomTypeId.Boss;
|
||||
if (!inBossRoom)
|
||||
{
|
||||
_bossRoared = false; // re-arm for the next boss room
|
||||
}
|
||||
else if (!_bossRoared)
|
||||
{
|
||||
foreach (var _ in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
|
||||
{
|
||||
_ambient.PlayOneShot(_stingRoar, 0.9f * GameVolume.Sfx);
|
||||
_bossRoared = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void PlaySting(byte phase)
|
||||
|
||||
@@ -62,6 +62,8 @@ namespace ProjectM.Client
|
||||
bool _slashActive;
|
||||
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
||||
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
||||
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
|
||||
bool _coneTickInit;
|
||||
Material _dangerMat;
|
||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||
readonly HashSet<Entity> _dangerSeen = new();
|
||||
@@ -367,6 +369,11 @@ namespace ProjectM.Client
|
||||
bool finisher = step >= comboLen;
|
||||
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||
// Slice-2 deferred reach fix: the SERVER folds per-player StatModifiers into melee range
|
||||
// (class seed +0.8, boons, meta tiers) — the arc must sweep the REAL reach, not the base.
|
||||
if (EntityManager.HasBuffer<StatModifier>(_localPlayer))
|
||||
slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange,
|
||||
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
|
||||
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
||||
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
|
||||
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
|
||||
@@ -397,6 +404,40 @@ namespace ProjectM.Client
|
||||
_swingTickInit = true;
|
||||
}
|
||||
|
||||
// Slice-2 deferred: the Warrior CONE ability had no client VFX (server-only cleave). Edge-detect the
|
||||
// PREDICTED AbilityCooldown (raw uint, like the dash) and reuse the slash-arc sweep with the ability's
|
||||
// own effective geometry — an aimed cone IS an arc.
|
||||
if (_localPlayer != Entity.Null
|
||||
&& EntityManager.HasComponent<AbilityCooldown>(_localPlayer)
|
||||
&& EntityManager.HasComponent<AbilityRef>(_localPlayer)
|
||||
&& EntityManager.HasComponent<EffectiveAbilityStats>(_localPlayer))
|
||||
{
|
||||
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
|
||||
if (_coneTickInit && nextFire != 0 && nextFire != _lastConeFireTick
|
||||
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var coneDb) && coneDb.Value.IsCreated)
|
||||
{
|
||||
ref var coneAdb = ref coneDb.Value.Value;
|
||||
if (coneAdb.TryGetAbility(EntityManager.GetComponentData<AbilityRef>(_localPlayer).Id, out var coneDef)
|
||||
&& coneDef.Archetype == (byte)AbilityArchetype.Cone)
|
||||
{
|
||||
var ceff = EntityManager.GetComponentData<EffectiveAbilityStats>(_localPlayer);
|
||||
Vector3 cface = Vector3.forward;
|
||||
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
|
||||
{
|
||||
var cfd = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
|
||||
if (math.lengthsq(cfd) > 1e-6f) cface = new Vector3(cfd.x, 0f, cfd.y).normalized;
|
||||
}
|
||||
float coneRange = Mathf.Max(0.1f, ceff.Range);
|
||||
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
|
||||
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, false);
|
||||
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
|
||||
PrototypeCameraRig.AddShake(0.06f);
|
||||
}
|
||||
}
|
||||
_lastConeFireTick = nextFire;
|
||||
_coneTickInit = true;
|
||||
}
|
||||
|
||||
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
|
||||
if (_localPlayer != Entity.Null)
|
||||
{
|
||||
@@ -820,7 +861,11 @@ namespace ProjectM.Client
|
||||
{
|
||||
int step = math.max(1, (int)mc.ValueRO.Step);
|
||||
bool finisher = step >= comboLen;
|
||||
rs.Range = finisher ? baseRange * finisherMult : baseRange;
|
||||
float rRange = baseRange; // same reach fix for teammates' arcs (their buffer is OwnerSendType.All)
|
||||
if (EntityManager.HasBuffer<StatModifier>(entity))
|
||||
rRange = math.max(0f, StatMath.Apply(baseRange, StatTarget.MeleeRange,
|
||||
EntityManager.GetBuffer<StatModifier>(entity, true)));
|
||||
rs.Range = finisher ? rRange * finisherMult : rRange;
|
||||
rs.Half = baseHalf;
|
||||
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
|
||||
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// The Engine Core's CRYSTAL answers its replicated <see cref="CoreIntegrity"/> (07-01 backlog: the mesh sat
|
||||
/// static while draining). Client-only observe-only presentation: the cosmetic <c>CoreCrystals</c> /
|
||||
/// <c>CoreMachine</c> GameObjects in Game.unity (classic URP renderers, not entities) are tinted via
|
||||
/// MaterialPropertyBlock — darker + blood-shifted as integrity falls, a white-hot flash on each hit (pairs
|
||||
/// with the AmbientAudioSystem alarm). Property-guarded per the shader-value rule (only materials exposing
|
||||
/// _BaseColor are touched); per-renderer MPBs never mutate the shared material assets (no bleed).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class CoreVisualFeedbackSystem : SystemBase
|
||||
{
|
||||
static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
|
||||
static readonly string[] TargetNames = { "CoreCrystals", "CoreMachine" };
|
||||
const float FlashSeconds = 0.35f;
|
||||
|
||||
readonly List<Renderer> _renderers = new();
|
||||
readonly List<Color> _authoredColors = new();
|
||||
MaterialPropertyBlock _mpb;
|
||||
bool _resolved;
|
||||
int _lastCore = -1;
|
||||
float _flashLeft;
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (!SystemAPI.TryGetSingleton<CoreIntegrity>(out var core) || core.Max <= 0)
|
||||
return;
|
||||
|
||||
// One-time renderer resolve, deferred until Game.unity is actually the active scene (the frontend
|
||||
// path creates this world a frame BEFORE the scene loads — latching early would find nothing).
|
||||
if (!_resolved)
|
||||
{
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
if (!scene.isLoaded || scene.name != "Game") return;
|
||||
Resolve();
|
||||
}
|
||||
if (_renderers.Count == 0) return;
|
||||
|
||||
if (_lastCore >= 0 && core.Current < _lastCore)
|
||||
_flashLeft = FlashSeconds; // hit edge -> white-hot pop (the audio alarm fires beside it)
|
||||
_lastCore = core.Current;
|
||||
_flashLeft -= SystemAPI.Time.DeltaTime;
|
||||
|
||||
float frac = Mathf.Clamp01(core.Current / (float)core.Max);
|
||||
float dim = Mathf.Lerp(0.35f, 1f, frac);
|
||||
float flash = _flashLeft > 0f ? Mathf.Clamp01(_flashLeft / FlashSeconds) : 0f;
|
||||
for (int i = 0; i < _renderers.Count; i++)
|
||||
{
|
||||
var r = _renderers[i];
|
||||
if (r == null) continue;
|
||||
var c0 = _authoredColors[i];
|
||||
// Wounded shift: keep red-ish energy, drain green/blue with integrity (reads as bleeding light).
|
||||
var wounded = new Color(
|
||||
Mathf.Min(1f, c0.r * dim + (1f - frac) * 0.25f),
|
||||
c0.g * dim * (0.45f + 0.55f * frac),
|
||||
c0.b * dim * (0.45f + 0.55f * frac),
|
||||
c0.a);
|
||||
var col = flash > 0f ? Color.Lerp(wounded, Color.white, flash) : wounded;
|
||||
r.GetPropertyBlock(_mpb);
|
||||
_mpb.SetColor(BaseColorId, col);
|
||||
r.SetPropertyBlock(_mpb);
|
||||
}
|
||||
}
|
||||
|
||||
void Resolve()
|
||||
{
|
||||
_resolved = true;
|
||||
_mpb = new MaterialPropertyBlock();
|
||||
foreach (var name in TargetNames)
|
||||
{
|
||||
var go = GameObject.Find(name);
|
||||
if (go == null) continue;
|
||||
foreach (var r in go.GetComponentsInChildren<Renderer>())
|
||||
{
|
||||
var m = r.sharedMaterial;
|
||||
if (m == null || !m.HasProperty(BaseColorId)) continue;
|
||||
_renderers.Add(r);
|
||||
_authoredColors.Add(m.GetColor(BaseColorId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b2a19145125ac4e98411cab7f9b569
|
||||
@@ -72,9 +72,9 @@ namespace ProjectM.Client
|
||||
// observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks
|
||||
// enqueue through the client send-systems' statics. Built lazily on first show.
|
||||
VisualElement _boonModal, _boonCardRow;
|
||||
VisualElement _routePanel, _routeBtnRow;
|
||||
VisualElement _routePanel;
|
||||
Label _routeTitle;
|
||||
byte _boonShownFor; // last (Option0^Option1^Option2 ^ room) signature the modal was built for
|
||||
int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for
|
||||
bool _boonModalBuilt, _routePanelBuilt;
|
||||
// Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether;
|
||||
// row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything).
|
||||
@@ -82,6 +82,28 @@ namespace ProjectM.Client
|
||||
Label _metaShopTitle;
|
||||
bool _metaShopBuilt;
|
||||
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
|
||||
// Demo polish: the clickable READY panel (Staging/Launching) + the drawn branching route map
|
||||
// (RouteSelect) — the map is regenerated client-side from RunInfo.RunSeed for DISPLAY only; the
|
||||
// clickable next-layer nodes bind to the authoritative RouteOpt* bytes (never the regen).
|
||||
VisualElement _readyPanel, _readyPipRow;
|
||||
Button _readyBtn;
|
||||
Label _readyTitle;
|
||||
bool _readyPanelBuilt;
|
||||
int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature
|
||||
VisualElement _routeMapHost; // node circles + Painter2D edges
|
||||
int _routeMapSig; // (seed, room, col, options) signature the map was drawn for
|
||||
readonly List<int> _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed
|
||||
uint _routeVisitedSeed;
|
||||
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
|
||||
VisualElement _bossPanel, _bossFill;
|
||||
Label _bossText;
|
||||
bool _bossBarBuilt;
|
||||
VisualElement _depthPanel;
|
||||
int _depthShownFor;
|
||||
bool _depthBuilt;
|
||||
VisualElement _outcomeFlash; // one-shot gold/red full-screen flash when the outcome banner first lands
|
||||
float _outcomeFlashLeft;
|
||||
byte _outcomeFlashedFor;
|
||||
|
||||
|
||||
readonly List<VisualElement> _pips = new();
|
||||
@@ -97,6 +119,8 @@ namespace ProjectM.Client
|
||||
|
||||
// overlays
|
||||
VisualElement _vignette, _downed;
|
||||
Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch)
|
||||
float _downedSince = -1f;
|
||||
float _prevHp, _flash;
|
||||
bool _haveHp;
|
||||
// personal inventory panel (read-only; toggled with I)
|
||||
@@ -194,25 +218,21 @@ namespace ProjectM.Client
|
||||
switch (runInfo.Lifecycle)
|
||||
{
|
||||
case RunLifecycle.Staging:
|
||||
{
|
||||
// Client counts the party's replicated ready flags (send-to-all) for the N/M readout.
|
||||
int total = 0, readyCount = 0;
|
||||
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
|
||||
{
|
||||
total++;
|
||||
if (pr.ValueRO.Value != 0) readyCount++;
|
||||
}
|
||||
_locationText.text = "READY UP [T] - " + readyCount + "/" + Mathf.Max(total, 1)
|
||||
+ " ready - launch a run to advance the Engine";
|
||||
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
|
||||
_locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
|
||||
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
|
||||
break;
|
||||
}
|
||||
case RunLifecycle.Launching:
|
||||
{
|
||||
uint nowTick = SystemAPI.TryGetSingleton<NetworkTime>(out var ntime) && ntime.ServerTick.IsValid
|
||||
? ntime.ServerTick.TickIndexForValidTick : 0u;
|
||||
int secs = runInfo.LaunchTick != 0 && nowTick != 0
|
||||
? Mathf.Max(0, (int)((runInfo.LaunchTick - nowTick) / 60u) + 1) : 0;
|
||||
// Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick
|
||||
// near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math.
|
||||
int secs = 0;
|
||||
if (runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton<NetworkTime>(out var ntime)
|
||||
&& ntime.ServerTick.IsValid)
|
||||
{
|
||||
int ticksLeft = new NetworkTick(runInfo.LaunchTick).TicksSince(ntime.ServerTick);
|
||||
if (ticksLeft > 0) secs = ticksLeft / 60 + 1;
|
||||
}
|
||||
_locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort";
|
||||
_locationText.style.color = new Color(1f, 0.9f, 0.4f);
|
||||
break;
|
||||
@@ -272,9 +292,72 @@ namespace ProjectM.Client
|
||||
BlobAssetReference<BoonCatalogBlob> boonPool = default;
|
||||
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
|
||||
boonPool = bcat.Value;
|
||||
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1, boonPool);
|
||||
// Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside
|
||||
// the reward window.
|
||||
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1
|
||||
&& haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool);
|
||||
UpdateRoutePanel(haveRun ? runInfo : default);
|
||||
|
||||
// Client-local path trace for the route map (nodeIds visited this run; display-only).
|
||||
if (haveRun && runInfo.RunSeed != _routeVisitedSeed)
|
||||
{
|
||||
_routeVisited.Clear();
|
||||
_routeVisitedSeed = runInfo.RunSeed;
|
||||
}
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom)
|
||||
{
|
||||
int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol);
|
||||
if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode);
|
||||
}
|
||||
|
||||
// The clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns
|
||||
// the screen then). Counts are the replicated send-to-all PlayerReady flags.
|
||||
int rTotal = 0, rReady = 0;
|
||||
bool localReady = false;
|
||||
int launchSecs = 0;
|
||||
bool terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc)
|
||||
&& readyOc.Value != RunOutcomeId.InProgress;
|
||||
bool readyShow = haveRun && !terminal
|
||||
&& (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
|
||||
if (readyShow)
|
||||
{
|
||||
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
|
||||
{
|
||||
rTotal++;
|
||||
if (pr.ValueRO.Value != 0) rReady++;
|
||||
}
|
||||
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
localReady = pr.ValueRO.Value != 0;
|
||||
if (runInfo.Lifecycle == RunLifecycle.Launching && runInfo.LaunchTick != 0
|
||||
&& SystemAPI.TryGetSingleton<NetworkTime>(out var lnt) && lnt.ServerTick.IsValid)
|
||||
{
|
||||
int tl = new NetworkTick(runInfo.LaunchTick).TicksSince(lnt.ServerTick);
|
||||
if (tl > 0) launchSecs = tl / 60 + 1;
|
||||
}
|
||||
}
|
||||
UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs);
|
||||
|
||||
// Boss presence bar — client heuristic: a Boss room spawns exactly ONE enemy (the boss), so any live
|
||||
// EnemyTag Health in a Boss-type room is it. Replicated state only; no netcode surface.
|
||||
bool bossAlive = false;
|
||||
float bossHp = 0f, bossMax = 0f;
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
|
||||
{
|
||||
foreach (var bhq in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
|
||||
{
|
||||
if (bhq.ValueRO.Max > bossMax)
|
||||
{
|
||||
bossMax = bhq.ValueRO.Max;
|
||||
bossHp = bhq.ValueRO.Current;
|
||||
bossAlive = bhq.ValueRO.Current > 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateBossBar(bossAlive, bossHp, bossMax);
|
||||
|
||||
// Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates).
|
||||
UpdateRunDepth(haveRun ? runInfo : default, haveRun);
|
||||
|
||||
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
|
||||
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
||||
{
|
||||
@@ -388,6 +471,14 @@ namespace ProjectM.Client
|
||||
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
|
||||
{
|
||||
bool win = runOutcome.Value == RunOutcomeId.Victory;
|
||||
if (_outcomeFlashedFor != runOutcome.Value)
|
||||
{
|
||||
// One-shot landing flourish: full-screen color flash + camera kick — the beat gets a payoff.
|
||||
_outcomeFlashedFor = runOutcome.Value;
|
||||
_outcomeFlashLeft = win ? 0.9f : 0.7f;
|
||||
PrototypeCameraRig.PunchFov(win ? 5f : 3f, win ? 420f : 260f);
|
||||
PrototypeCameraRig.AddShake(win ? 0.25f : 0.5f);
|
||||
}
|
||||
_runBanner.style.display = DisplayStyle.Flex;
|
||||
_runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN";
|
||||
_runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f);
|
||||
@@ -397,6 +488,32 @@ namespace ProjectM.Client
|
||||
else
|
||||
{
|
||||
_runBanner.style.display = DisplayStyle.None;
|
||||
_outcomeFlashedFor = 0;
|
||||
}
|
||||
|
||||
// Outcome flash decay (lazy element; the banner dim sits above it, the world below).
|
||||
if (_outcomeFlashLeft > 0f)
|
||||
{
|
||||
if (_outcomeFlash == null && _doc != null && _doc.rootVisualElement != null)
|
||||
{
|
||||
_outcomeFlash = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_outcomeFlash.style.position = Position.Absolute;
|
||||
_outcomeFlash.style.left = 0; _outcomeFlash.style.right = 0;
|
||||
_outcomeFlash.style.top = 0; _outcomeFlash.style.bottom = 0;
|
||||
_doc.rootVisualElement.Add(_outcomeFlash);
|
||||
}
|
||||
_outcomeFlashLeft -= dt;
|
||||
if (_outcomeFlash != null)
|
||||
{
|
||||
bool winFlash = _outcomeFlashedFor == RunOutcomeId.Victory;
|
||||
var fc = winFlash ? new Color(1f, 0.85f, 0.35f) : new Color(1f, 0.20f, 0.15f);
|
||||
_outcomeFlash.style.backgroundColor = new Color(fc.r, fc.g, fc.b, Mathf.Clamp01(_outcomeFlashLeft) * 0.35f);
|
||||
_outcomeFlash.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
}
|
||||
else if (_outcomeFlash != null)
|
||||
{
|
||||
_outcomeFlash.style.display = DisplayStyle.None;
|
||||
}
|
||||
|
||||
|
||||
@@ -487,6 +604,18 @@ namespace ProjectM.Client
|
||||
HudUi.SetFill(_cooldownFill, cdFrac);
|
||||
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
|
||||
if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
|
||||
if (dead)
|
||||
{
|
||||
// Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the
|
||||
// honest duration — RespawnTick itself is server-only.
|
||||
if (_downedSince < 0f) _downedSince = (float)SystemAPI.Time.ElapsedTime;
|
||||
int delayTicks = 180;
|
||||
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
|
||||
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
|
||||
_downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING...";
|
||||
}
|
||||
else _downedSince = -1f;
|
||||
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
else
|
||||
@@ -1022,7 +1151,12 @@ namespace ProjectM.Client
|
||||
{
|
||||
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
|
||||
}
|
||||
_downed.Add(HudUi.Display("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
|
||||
var downedCol = HudUi.Group(Align.Center);
|
||||
downedCol.Add(HudUi.Display("DOWNED", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
|
||||
_downedText = HudUi.Text("RESPAWNING...", 24, new Color(1f, 0.75f, 0.7f), TextAnchor.MiddleCenter);
|
||||
_downedText.style.marginTop = 6;
|
||||
downedCol.Add(_downedText);
|
||||
_downed.Add(downedCol);
|
||||
_downed.style.display = DisplayStyle.None;
|
||||
root.Add(_downed);
|
||||
}
|
||||
@@ -1042,17 +1176,32 @@ namespace ProjectM.Client
|
||||
_runBannerSub.style.marginTop = 8;
|
||||
col.Add(_runBannerSub);
|
||||
// END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc.
|
||||
// PLAY AGAIN restarts a fresh Single run via the proven menu lifecycle; QUIT TO MENU reuses the teardown.
|
||||
// Both self-guard on WorldLauncher.Busy. The row picks (Position) even though the banner root Ignores.
|
||||
// SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls
|
||||
// forward on stage). CO-OP (operator-locked): the honest exit is a clean teardown for everyone —
|
||||
// the host ends the session (each joiner's ConnectionWatchdog returns them to the menu with a
|
||||
// reason), a joiner just leaves. All self-guard on WorldLauncher.Busy. The row picks (Position)
|
||||
// even though the banner root Ignores.
|
||||
var btnRow = new VisualElement();
|
||||
btnRow.style.flexDirection = FlexDirection.Row;
|
||||
btnRow.style.justifyContent = Justify.Center;
|
||||
btnRow.style.marginTop = 28;
|
||||
btnRow.pickingMode = PickingMode.Position;
|
||||
var again = MenuUi.Button("PLAY AGAIN", () => WorldLauncher.StartSession(SessionMode.Single, null, false));
|
||||
again.style.marginRight = 12;
|
||||
btnRow.Add(again);
|
||||
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
|
||||
switch (WorldLauncher.LastMode)
|
||||
{
|
||||
case SessionMode.Host:
|
||||
btnRow.Add(MenuUi.Button("END SESSION — ALL TO MENU", WorldLauncher.TeardownToMenu));
|
||||
break;
|
||||
case SessionMode.Join:
|
||||
btnRow.Add(MenuUi.Button("LEAVE TO MENU", WorldLauncher.TeardownToMenu));
|
||||
break;
|
||||
default:
|
||||
var again = MenuUi.Button("PLAY AGAIN",
|
||||
() => WorldLauncher.StartSession(SessionMode.Single, null, SaveService.HasSave()));
|
||||
again.style.marginRight = 12;
|
||||
btnRow.Add(again);
|
||||
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
|
||||
break;
|
||||
}
|
||||
col.Add(btnRow);
|
||||
_runBanner.Add(col);
|
||||
_runBanner.style.display = DisplayStyle.None;
|
||||
@@ -1230,7 +1379,6 @@ namespace ProjectM.Client
|
||||
|
||||
// ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ====
|
||||
|
||||
byte _routeShownFor; // last (room ^ options) signature the route buttons were built for
|
||||
|
||||
static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]"
|
||||
: roomType == RoomTypeId.Elite ? "[ELITE]"
|
||||
@@ -1253,8 +1401,9 @@ namespace ProjectM.Client
|
||||
}
|
||||
|
||||
// Rebuild the three cards only when the offer actually changes (a new room's deal).
|
||||
byte sig = (byte)(offer.Option0 ^ (offer.Option1 * 3) ^ (offer.Option2 * 7));
|
||||
if (sig == 0) sig = 1;
|
||||
// Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive
|
||||
// rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel.
|
||||
int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16));
|
||||
if (_boonShownFor != sig)
|
||||
{
|
||||
_boonCardRow.Clear();
|
||||
@@ -1265,10 +1414,18 @@ namespace ProjectM.Client
|
||||
int idx = BoonMath.FindDef(ref defs, id);
|
||||
string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id);
|
||||
string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : "";
|
||||
byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100;
|
||||
byte pick = k; // capture a COPY into the closure, never the loop variable
|
||||
var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick));
|
||||
card.style.width = 200;
|
||||
card.style.height = 96;
|
||||
card.style.height = StyleKeyword.Auto; // long descs grow the card
|
||||
card.style.minHeight = 96;
|
||||
// Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic).
|
||||
var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f)
|
||||
: weight <= 30 ? new Color(0.65f, 0.50f, 1f)
|
||||
: weight <= 60 ? new Color(0.45f, 0.95f, 0.55f)
|
||||
: new Color(1f, 1f, 1f, 0.30f);
|
||||
MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f);
|
||||
card.style.marginLeft = 8;
|
||||
card.style.marginRight = 8;
|
||||
card.style.whiteSpace = WhiteSpace.Normal;
|
||||
@@ -1312,6 +1469,25 @@ namespace ProjectM.Client
|
||||
root.Add(_boonModal);
|
||||
}
|
||||
|
||||
// ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind
|
||||
// to the authoritative RouteOpt* bytes) ----
|
||||
|
||||
const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f;
|
||||
|
||||
static Vector2 MapNodePos(int layer, int col, byte layerWidth)
|
||||
{
|
||||
float x = MapPad + layer * MapStrideX;
|
||||
float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY;
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B"
|
||||
: t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C";
|
||||
|
||||
static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f)
|
||||
: t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f)
|
||||
: t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f);
|
||||
|
||||
void UpdateRoutePanel(RunInfo runInfo)
|
||||
{
|
||||
// Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion).
|
||||
@@ -1319,7 +1495,7 @@ namespace ProjectM.Client
|
||||
if (!show)
|
||||
{
|
||||
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
|
||||
_routeShownFor = 0;
|
||||
_routeMapSig = 0;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
@@ -1330,23 +1506,15 @@ namespace ProjectM.Client
|
||||
_routePanelBuilt = true;
|
||||
}
|
||||
|
||||
byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3)
|
||||
^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount);
|
||||
int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31
|
||||
^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16)
|
||||
^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20);
|
||||
if (sig == 0) sig = 1;
|
||||
if (_routeShownFor != sig)
|
||||
if (_routeMapSig != sig)
|
||||
{
|
||||
_routeBtnRow.Clear();
|
||||
for (byte k = 0; k < runInfo.RouteOptionCount && k < 3; k++)
|
||||
{
|
||||
byte type = k == 2 ? runInfo.RouteOpt2Type : k == 1 ? runInfo.RouteOpt1Type : runInfo.RouteOpt0Type;
|
||||
byte pick = k; // closure copy
|
||||
var b = MenuUi.Button("→ " + RoomTypeLabel(type), () => RouteSendSystem.PickRoute(pick));
|
||||
b.style.marginLeft = 6;
|
||||
b.style.marginRight = 6;
|
||||
_routeBtnRow.Add(b);
|
||||
}
|
||||
RebuildRouteMap(runInfo);
|
||||
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
|
||||
_routeShownFor = sig;
|
||||
_routeMapSig = sig;
|
||||
}
|
||||
_routePanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
@@ -1356,33 +1524,299 @@ namespace ProjectM.Client
|
||||
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_routePanel.style.position = Position.Absolute;
|
||||
_routePanel.style.left = 0; _routePanel.style.right = 0;
|
||||
_routePanel.style.bottom = 120;
|
||||
_routePanel.style.top = 0; _routePanel.style.bottom = 0;
|
||||
_routePanel.style.alignItems = Align.Center;
|
||||
_routePanel.style.justifyContent = Justify.Center;
|
||||
_routePanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement();
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.94f);
|
||||
var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f);
|
||||
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
|
||||
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
|
||||
box.style.paddingLeft = 16; box.style.paddingRight = 16;
|
||||
box.style.paddingTop = 10; box.style.paddingBottom = 12;
|
||||
box.style.paddingLeft = 18; box.style.paddingRight = 18;
|
||||
box.style.paddingTop = 12; box.style.paddingBottom = 12;
|
||||
box.style.alignItems = Align.Center;
|
||||
|
||||
_routeTitle = new Label("CHOOSE YOUR PATH");
|
||||
_routeTitle.style.color = new Color(0.55f, 0.85f, 1f);
|
||||
_routeTitle.style.fontSize = 15;
|
||||
_routeTitle.style.fontSize = 16;
|
||||
_routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
_routeTitle.style.marginBottom = 8;
|
||||
_routeTitle.style.marginBottom = 10;
|
||||
box.Add(_routeTitle);
|
||||
|
||||
_routeBtnRow = new VisualElement();
|
||||
_routeBtnRow.style.flexDirection = FlexDirection.Row;
|
||||
box.Add(_routeBtnRow);
|
||||
_routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_routeMapHost.style.position = Position.Relative;
|
||||
box.Add(_routeMapHost);
|
||||
|
||||
var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13,
|
||||
MenuUi.SubCol, TextAnchor.MiddleCenter);
|
||||
cap.style.marginTop = 10;
|
||||
box.Add(cap);
|
||||
|
||||
_routePanel.Add(box);
|
||||
root.Add(_routePanel);
|
||||
}
|
||||
|
||||
void RebuildRouteMap(RunInfo runInfo)
|
||||
{
|
||||
_routeMapHost.Clear();
|
||||
var map = RunMapMath.Generate(runInfo.RunSeed);
|
||||
_routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize;
|
||||
_routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize;
|
||||
|
||||
// Edges under the nodes (Painter2D); walked segments glow, the rest are faint.
|
||||
var edges = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
edges.style.position = Position.Absolute;
|
||||
edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0;
|
||||
var mapCopy = map;
|
||||
var visited = new List<int>(_routeVisited);
|
||||
edges.generateVisualContent += ctx =>
|
||||
{
|
||||
var p = ctx.painter2D;
|
||||
p.lineWidth = 2f;
|
||||
var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f);
|
||||
for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++)
|
||||
for (int col = 0; col < mapCopy.LayerWidths[layer]; col++)
|
||||
{
|
||||
var node = mapCopy.Node(layer, col);
|
||||
if (node.NextMask == 0) continue;
|
||||
var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]);
|
||||
for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++)
|
||||
{
|
||||
if ((node.NextMask & (1 << j)) == 0) continue;
|
||||
var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]);
|
||||
bool walked = visited.Contains(RunMap.NodeId(layer, col))
|
||||
&& visited.Contains(RunMap.NodeId(layer + 1, j));
|
||||
p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f);
|
||||
p.BeginPath();
|
||||
p.MoveTo(a + c);
|
||||
p.LineTo(b + c);
|
||||
p.Stroke();
|
||||
}
|
||||
}
|
||||
};
|
||||
_routeMapHost.Add(edges);
|
||||
|
||||
int nextLayer = runInfo.CurrentRoom + 1;
|
||||
for (int layer = 0; layer < map.LayerCount; layer++)
|
||||
for (int col = 0; col < map.LayerWidths[layer]; col++)
|
||||
{
|
||||
var node = map.Node(layer, col);
|
||||
bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol;
|
||||
bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col));
|
||||
byte opt = 255;
|
||||
if (layer == nextLayer)
|
||||
{
|
||||
if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0;
|
||||
else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1;
|
||||
else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2;
|
||||
}
|
||||
_routeMapHost.Add(MakeMapNode(node.RoomType,
|
||||
MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom));
|
||||
}
|
||||
}
|
||||
|
||||
VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past)
|
||||
{
|
||||
bool clickable = optionIndex != 255;
|
||||
var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore };
|
||||
n.style.position = Position.Absolute;
|
||||
n.style.left = pos.x; n.style.top = pos.y;
|
||||
n.style.width = MapNodeSize; n.style.height = MapNodeSize;
|
||||
MenuUi.Round(n, MapNodeSize * 0.5f);
|
||||
var c = RoomColor(roomType);
|
||||
float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f;
|
||||
var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA);
|
||||
n.style.backgroundColor = restBg;
|
||||
MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f),
|
||||
isCurrent || clickable ? 2.5f : 1.2f);
|
||||
var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore };
|
||||
lbl.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
lbl.style.flexGrow = 1;
|
||||
lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f);
|
||||
lbl.style.fontSize = 15;
|
||||
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
n.Add(lbl);
|
||||
if (clickable)
|
||||
{
|
||||
byte pick = optionIndex; // closure copy, never the loop variable
|
||||
n.RegisterCallback<ClickEvent>(_ => RouteSendSystem.PickRoute(pick));
|
||||
n.RegisterCallback<MouseEnterEvent>(_ =>
|
||||
n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f));
|
||||
n.RegisterCallback<MouseLeaveEvent>(_ => n.style.backgroundColor = restBg);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ----
|
||||
|
||||
void UpdateReadyPanel(bool show, RunInfo runInfo, int total, int ready, bool localReady, int launchSecs)
|
||||
{
|
||||
if (!show)
|
||||
{
|
||||
if (_readyPanel != null) _readyPanel.style.display = DisplayStyle.None;
|
||||
_readyShownFor = 0;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
if (root == null) return;
|
||||
if (!_readyPanelBuilt)
|
||||
{
|
||||
BuildReadyPanel(root);
|
||||
_readyPanelBuilt = true;
|
||||
}
|
||||
|
||||
bool launching = runInfo.Lifecycle == RunLifecycle.Launching;
|
||||
int sig = 1 + ready + (total << 4) + (localReady ? 1 << 8 : 0) + (launchSecs << 9) + (launching ? 1 << 16 : 0);
|
||||
if (_readyShownFor != sig)
|
||||
{
|
||||
_readyTitle.text = launching
|
||||
? "LAUNCHING IN " + launchSecs
|
||||
: "EXPEDITION — " + ready + "/" + Mathf.Max(total, 1) + " READY";
|
||||
_readyTitle.style.color = launching ? new Color(1f, 0.9f, 0.4f) : new Color(0.55f, 0.85f, 1f);
|
||||
_readyBtn.text = launching ? "ABORT [T]" : localReady ? "UNREADY [T]" : "READY UP [T]";
|
||||
_readyPipRow.Clear();
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
var pip = new VisualElement();
|
||||
pip.style.width = 14; pip.style.height = 14;
|
||||
pip.style.marginLeft = 3; pip.style.marginRight = 3;
|
||||
MenuUi.Round(pip, 7f);
|
||||
pip.style.backgroundColor = i < ready
|
||||
? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.15f);
|
||||
_readyPipRow.Add(pip);
|
||||
}
|
||||
_readyShownFor = sig;
|
||||
}
|
||||
_readyPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void BuildReadyPanel(VisualElement root)
|
||||
{
|
||||
_readyPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_readyPanel.style.position = Position.Absolute;
|
||||
_readyPanel.style.left = 0; _readyPanel.style.right = 0;
|
||||
_readyPanel.style.bottom = 170; // clear of the build palette row + hint bar
|
||||
_readyPanel.style.alignItems = Align.Center;
|
||||
_readyPanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement { pickingMode = PickingMode.Position };
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
||||
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
|
||||
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
|
||||
box.style.paddingLeft = 18; box.style.paddingRight = 18;
|
||||
box.style.paddingTop = 10; box.style.paddingBottom = 12;
|
||||
box.style.alignItems = Align.Center;
|
||||
|
||||
_readyTitle = new Label("EXPEDITION");
|
||||
_readyTitle.style.fontSize = 16;
|
||||
_readyTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
box.Add(_readyTitle);
|
||||
|
||||
_readyPipRow = new VisualElement();
|
||||
_readyPipRow.style.flexDirection = FlexDirection.Row;
|
||||
_readyPipRow.style.justifyContent = Justify.Center;
|
||||
_readyPipRow.style.marginTop = 6; _readyPipRow.style.marginBottom = 8;
|
||||
box.Add(_readyPipRow);
|
||||
|
||||
_readyBtn = MenuUi.Button("READY UP [T]", ReadySendSystem.ToggleReady);
|
||||
box.Add(_readyBtn);
|
||||
|
||||
_readyPanel.Add(box);
|
||||
root.Add(_readyPanel);
|
||||
}
|
||||
|
||||
// ---- boss presence bar (Boss rooms only; red, top-center, under the macro banner) ----
|
||||
|
||||
void UpdateBossBar(bool alive, float hp, float max)
|
||||
{
|
||||
if (!alive || max <= 0f)
|
||||
{
|
||||
if (_bossPanel != null) _bossPanel.style.display = DisplayStyle.None;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
if (root == null) return;
|
||||
if (!_bossBarBuilt)
|
||||
{
|
||||
BuildBossBar(root);
|
||||
_bossBarBuilt = true;
|
||||
}
|
||||
HudUi.SetFill(_bossFill, Mathf.Clamp01(hp / max));
|
||||
_bossText.text = "ALPHA HUSK " + Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(max);
|
||||
_bossPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void BuildBossBar(VisualElement root)
|
||||
{
|
||||
_bossPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_bossPanel.style.position = Position.Absolute;
|
||||
_bossPanel.style.left = 0; _bossPanel.style.right = 0;
|
||||
_bossPanel.style.top = 168;
|
||||
_bossPanel.style.alignItems = Align.Center;
|
||||
_bossPanel.style.display = DisplayStyle.None;
|
||||
|
||||
var col = HudUi.Group(Align.Center);
|
||||
_bossText = HudUi.Display("ALPHA HUSK", 22, new Color(1f, 0.35f, 0.3f), TextAnchor.MiddleCenter);
|
||||
col.Add(_bossText);
|
||||
var bar = HudUi.Bar(420, 12, new Color(0.92f, 0.22f, 0.18f), out _bossFill);
|
||||
bar.style.marginTop = 4;
|
||||
col.Add(bar);
|
||||
_bossPanel.Add(col);
|
||||
root.Add(_bossPanel);
|
||||
}
|
||||
|
||||
// ---- run-depth dots (visible through the whole run; the current room pulses bigger) ----
|
||||
|
||||
void UpdateRunDepth(RunInfo runInfo, bool haveRun)
|
||||
{
|
||||
bool show = haveRun && runInfo.RoomCount > 0
|
||||
&& (runInfo.Lifecycle == RunLifecycle.InRoom
|
||||
|| runInfo.Lifecycle == RunLifecycle.RoomReward
|
||||
|| runInfo.Lifecycle == RunLifecycle.RouteSelect);
|
||||
if (!show)
|
||||
{
|
||||
if (_depthPanel != null) _depthPanel.style.display = DisplayStyle.None;
|
||||
_depthShownFor = 0;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
if (root == null) return;
|
||||
if (!_depthBuilt)
|
||||
{
|
||||
_depthPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_depthPanel.style.position = Position.Absolute;
|
||||
_depthPanel.style.left = 0; _depthPanel.style.right = 0;
|
||||
_depthPanel.style.top = 208; // below the macro cluster (goal/core) AND the boss bar (168)
|
||||
_depthPanel.style.flexDirection = FlexDirection.Row;
|
||||
_depthPanel.style.justifyContent = Justify.Center;
|
||||
root.Add(_depthPanel);
|
||||
_depthBuilt = true;
|
||||
}
|
||||
int sig = 1 + runInfo.CurrentRoom * 37 + runInfo.RoomCount * 3;
|
||||
if (_depthShownFor != sig)
|
||||
{
|
||||
_depthPanel.Clear();
|
||||
for (int i = 0; i < runInfo.RoomCount; i++)
|
||||
{
|
||||
bool current = i == runInfo.CurrentRoom;
|
||||
bool done = i < runInfo.CurrentRoom;
|
||||
var dot = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
float size = current ? 12f : 8f;
|
||||
dot.style.width = size; dot.style.height = size;
|
||||
dot.style.marginLeft = 3; dot.style.marginRight = 3;
|
||||
dot.style.alignSelf = Align.Center;
|
||||
MenuUi.Round(dot, size * 0.5f);
|
||||
dot.style.backgroundColor = current ? new Color(0.55f, 0.85f, 1f)
|
||||
: done ? new Color(0.55f, 0.85f, 1f, 0.55f)
|
||||
: new Color(1f, 1f, 1f, 0.16f);
|
||||
_depthPanel.Add(dot);
|
||||
}
|
||||
_depthShownFor = sig;
|
||||
}
|
||||
_depthPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void UpdateMetaShop(bool show, byte classId, int aether,
|
||||
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
|
||||
{
|
||||
@@ -1417,18 +1851,35 @@ namespace ProjectM.Client
|
||||
if ((defs.Defs[d].ClassMask & classBit) == 0) continue;
|
||||
byte id = defs.Defs[d].Id;
|
||||
byte owned = MetaMath.TierOf(record, classId, id);
|
||||
if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop)
|
||||
bool maxed = owned >= defs.Defs[d].MaxTier;
|
||||
int cost = MetaMath.CostForTier(in defs.Defs[d], owned);
|
||||
string label = defs.Defs[d].Name.ToString() + " [" + owned + "/" + defs.Defs[d].MaxTier + "]"
|
||||
string label = defs.Defs[d].Name.ToString()
|
||||
+ (maxed ? " MAXED" : " - " + cost + " Aether")
|
||||
+ "\n" + defs.Defs[d].Desc.ToString();
|
||||
byte buyId = id; // closure copy, never the loop variable
|
||||
var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId));
|
||||
row.style.width = 290;
|
||||
row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix)
|
||||
row.style.paddingTop = 6; row.style.paddingBottom = 6;
|
||||
row.style.marginBottom = 4;
|
||||
row.style.whiteSpace = WhiteSpace.Normal;
|
||||
row.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway
|
||||
// Owned-tier pips (replaces the "[2/5]" text — reads at a glance).
|
||||
var pipRow = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
pipRow.style.flexDirection = FlexDirection.Row;
|
||||
pipRow.style.marginTop = 3;
|
||||
for (int p = 0; p < defs.Defs[d].MaxTier; p++)
|
||||
{
|
||||
var tp = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
tp.style.width = 9; tp.style.height = 9;
|
||||
tp.style.marginRight = 3;
|
||||
MenuUi.Round(tp, 4.5f);
|
||||
tp.style.backgroundColor = p < owned ? AetherCyan : new Color(1f, 1f, 1f, 0.14f);
|
||||
pipRow.Add(tp);
|
||||
}
|
||||
row.Add(pipRow);
|
||||
_metaRowsHost.Add(row);
|
||||
}
|
||||
_metaShownFor = sig;
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only PROCEDURAL MUSIC — four synchronized loop layers (bass / chord pad / arpeggio / percussion
|
||||
/// pulse), all generated over the SAME 4-bar buffer (100 BPM, Am–F–C–G) so they stay phase-locked forever:
|
||||
/// every AudioSource starts the same frame with loop=true and identical clip length, and per-bar amplitude
|
||||
/// envelopes reach ~zero at each bar boundary so both the chord changes and the loop point are click-free
|
||||
/// (the <c>AmbientAudioSystem.Snap</c> trick generalized to enveloped segments). The MIX is the state
|
||||
/// machine: layer volumes ease toward targets chosen from replicated state only — <see cref="RunInfo"/>
|
||||
/// lifecycle (staging / combat / boss / reward lull), <see cref="CycleState"/> siege, and the terminal
|
||||
/// <see cref="RunOutcome"/> (one-shot victory/defeat sting + aftermath bed). Observe-only presentation
|
||||
/// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at
|
||||
/// <see cref="MasterVolume"/> × <see cref="GameVolume.Music"/>; the low <c>AmbientAudioSystem</c> drone
|
||||
/// (vol 0.10) remains as texture beneath it.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class MusicSystem : SystemBase
|
||||
{
|
||||
const float MasterVolume = 0.45f;
|
||||
const float FadePerSecond = 0.30f;
|
||||
|
||||
// ---- shared musical grid: 100 BPM, 4/4, one chord per bar, 4 bars ----
|
||||
const int SampleRate = 44100;
|
||||
const float BarSeconds = 2.4f; // 4 beats @ 0.6 s
|
||||
const int Bars = 4;
|
||||
const float LoopSeconds = BarSeconds * Bars; // 9.6 s
|
||||
|
||||
// Am – F – C – G (natural-minor loop; index = bar): root/third/fifth per chord.
|
||||
static readonly float[][] ChordHz =
|
||||
{
|
||||
new[] { 110.00f, 130.81f, 164.81f }, // Am: A2 C3 E3
|
||||
new[] { 87.31f, 110.00f, 130.81f }, // F: F2 A2 C3
|
||||
new[] { 130.81f, 164.81f, 196.00f }, // C: C3 E3 G3
|
||||
new[] { 98.00f, 123.47f, 146.83f }, // G: G2 B2 D3
|
||||
};
|
||||
|
||||
GameObject _root;
|
||||
AudioSource _bass, _pad, _arp, _pulse;
|
||||
float _vBass, _vPad, _vArp, _vPulse; // current smoothed volumes (pre-master)
|
||||
byte _outcomePlayed; // which terminal outcome's sting has fired (0 = none)
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_root != null) return;
|
||||
_root = new GameObject("~Music");
|
||||
_bass = MakeSource(BuildBassLoop());
|
||||
_pad = MakeSource(BuildPadLoop());
|
||||
_arp = MakeSource(BuildArpLoop());
|
||||
_pulse = MakeSource(BuildPulseLoop());
|
||||
// Same frame, same length, loop=true -> phase-locked for the whole session.
|
||||
_bass.Play(); _pad.Play(); _arp.Play(); _pulse.Play();
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_root != null) Object.Destroy(_root);
|
||||
}
|
||||
|
||||
AudioSource MakeSource(AudioClip clip)
|
||||
{
|
||||
var src = _root.AddComponent<AudioSource>();
|
||||
src.clip = clip;
|
||||
src.loop = true;
|
||||
src.playOnAwake = false;
|
||||
src.spatialBlend = 0f;
|
||||
src.volume = 0f;
|
||||
return src;
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_bass == null) return;
|
||||
|
||||
// ---- pick the mix from replicated state (defaults = quiet staging bed) ----
|
||||
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
|
||||
|
||||
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var run);
|
||||
SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
||||
bool siege = cyc.Phase == CyclePhase.Siege;
|
||||
bool finalSiege = siege && SystemAPI.TryGetSingleton<GoalProgress>(out var goal)
|
||||
&& goal.Target > 0 && goal.Charge >= goal.Target;
|
||||
|
||||
byte outcome = SystemAPI.TryGetSingleton<RunOutcome>(out var oc) ? oc.Value : RunOutcomeId.InProgress;
|
||||
if (outcome != RunOutcomeId.InProgress)
|
||||
{
|
||||
if (_outcomePlayed != outcome)
|
||||
{
|
||||
_outcomePlayed = outcome;
|
||||
PlayOutcomeSting(outcome == RunOutcomeId.Victory);
|
||||
}
|
||||
tBass = 0.20f; tPad = 0.45f; tArp = 0f; tPulse = 0f; // aftermath bed under the banner
|
||||
}
|
||||
else if (siege)
|
||||
{
|
||||
tBass = 0.70f; tPad = 0.40f; tArp = 0.60f; tPulse = finalSiege ? 0.90f : 0.65f;
|
||||
}
|
||||
else if (haveRun)
|
||||
{
|
||||
switch (run.Lifecycle)
|
||||
{
|
||||
case RunLifecycle.Launching:
|
||||
tBass = 0.55f; tPad = 0.50f; tArp = 0.35f; tPulse = 0.20f; // anticipation swell
|
||||
break;
|
||||
case RunLifecycle.InRoom:
|
||||
bool boss = run.CurrentRoomType == RoomTypeId.Boss;
|
||||
tBass = boss ? 0.75f : 0.65f;
|
||||
tPad = 0.40f;
|
||||
tArp = boss ? 0.80f : 0.65f;
|
||||
tPulse = boss ? 0.75f : 0.35f;
|
||||
break;
|
||||
case RunLifecycle.RoomReward:
|
||||
case RunLifecycle.RouteSelect:
|
||||
tBass = 0.45f; tPad = 0.55f; tArp = 0.25f; tPulse = 0.08f; // between-rooms lull
|
||||
break;
|
||||
case RunLifecycle.Returning:
|
||||
tBass = 0.45f; tPad = 0.60f; tArp = 0.15f; tPulse = 0f; // resolution
|
||||
break;
|
||||
// Staging keeps the defaults.
|
||||
}
|
||||
}
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime * FadePerSecond;
|
||||
_vBass = Mathf.MoveTowards(_vBass, tBass, dt);
|
||||
_vPad = Mathf.MoveTowards(_vPad, tPad, dt);
|
||||
_vArp = Mathf.MoveTowards(_vArp, tArp, dt);
|
||||
_vPulse = Mathf.MoveTowards(_vPulse, tPulse, dt);
|
||||
|
||||
float master = MasterVolume * GameVolume.Music;
|
||||
_bass.volume = _vBass * master;
|
||||
_pad.volume = _vPad * master;
|
||||
_arp.volume = _vArp * master;
|
||||
_pulse.volume = _vPulse * master;
|
||||
}
|
||||
|
||||
void PlayOutcomeSting(bool victory)
|
||||
{
|
||||
// Victory: rising A-minor->major-feel arpeggio; defeat: falling minor third crawl.
|
||||
float[] notes = victory
|
||||
? new[] { 220f, 277.18f, 329.63f, 440f } // A3 C#4 E4 A4 (picardy lift)
|
||||
: new[] { 220f, 207.65f, 174.61f, 146.83f }; // A3 Ab3 F3 D3
|
||||
var clip = BuildStingClip(notes, victory ? 0.16f : 0.28f, victory ? 0.45f : 0.40f);
|
||||
if (_pad != null) _pad.PlayOneShot(clip, 0.9f * GameVolume.Music);
|
||||
}
|
||||
|
||||
// ================= clip builders (deterministic, asset-free) =================
|
||||
|
||||
static AudioClip NewLoopClip(string name, out float[] data)
|
||||
{
|
||||
int len = (int)(LoopSeconds * SampleRate);
|
||||
data = new float[len];
|
||||
return AudioClip.Create(name, len, 1, SampleRate, false);
|
||||
}
|
||||
|
||||
/// <summary>Bar-local envelope that is ~0 at both bar edges (click-free chord changes + loop point).</summary>
|
||||
static float BarEnv(float tInBar, float attack, float release)
|
||||
{
|
||||
float up = Mathf.Clamp01(tInBar / attack);
|
||||
float down = Mathf.Clamp01((BarSeconds - tInBar) / release);
|
||||
return up * up * down;
|
||||
}
|
||||
|
||||
static AudioClip BuildBassLoop()
|
||||
{
|
||||
var clip = NewLoopClip("music_bass", out var data);
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
float t = i / (float)SampleRate;
|
||||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||||
float tb = t - bar * BarSeconds;
|
||||
float f = ChordHz[bar][0] * 0.5f; // root, an octave down
|
||||
float s = Mathf.Sin(2f * Mathf.PI * f * t) * 0.8f
|
||||
+ Mathf.Sin(2f * Mathf.PI * f * 2f * t) * 0.2f;
|
||||
data[i] = s * BarEnv(tb, 0.03f, 0.12f) * 0.50f;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static AudioClip BuildPadLoop()
|
||||
{
|
||||
var clip = NewLoopClip("music_pad", out var data);
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
float t = i / (float)SampleRate;
|
||||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||||
float tb = t - bar * BarSeconds;
|
||||
var chord = ChordHz[bar];
|
||||
float s = 0f;
|
||||
for (int n = 0; n < chord.Length; n++)
|
||||
s += Mathf.Sin(2f * Mathf.PI * chord[n] * t + n * 1.7f);
|
||||
float trem = 0.85f + 0.15f * Mathf.Sin(2f * Mathf.PI * 0.8f * t);
|
||||
data[i] = s / 3f * BarEnv(tb, 0.45f, 0.50f) * trem * 0.34f;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static AudioClip BuildArpLoop()
|
||||
{
|
||||
var clip = NewLoopClip("music_arp", out var data);
|
||||
const float noteLen = BarSeconds / 8f; // 8th notes
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
float t = i / (float)SampleRate;
|
||||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||||
float tb = t - bar * BarSeconds;
|
||||
int step = Mathf.Min(7, (int)(tb / noteLen));
|
||||
float tn = tb - step * noteLen;
|
||||
var chord = ChordHz[bar];
|
||||
// up-and-over pattern: root, fifth, octave, tenth, back down
|
||||
float f = step switch
|
||||
{
|
||||
0 => chord[0] * 2f,
|
||||
1 => chord[2] * 2f,
|
||||
2 => chord[0] * 4f,
|
||||
3 => chord[1] * 4f,
|
||||
4 => chord[2] * 4f,
|
||||
5 => chord[0] * 4f,
|
||||
6 => chord[2] * 2f,
|
||||
_ => chord[1] * 2f,
|
||||
};
|
||||
float env = Mathf.Exp(-9f * tn);
|
||||
data[i] = Mathf.Sin(2f * Mathf.PI * f * tn) * env * 0.32f;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static AudioClip BuildPulseLoop()
|
||||
{
|
||||
var clip = NewLoopClip("music_pulse", out var data);
|
||||
const float beat = BarSeconds / 4f;
|
||||
uint rng = 0x5EED5EEDu; // deterministic hat noise
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
float t = i / (float)SampleRate;
|
||||
float tBeat = t % beat;
|
||||
// Low thump on every beat: 55->38 Hz sweep, tight decay.
|
||||
float thump = 0f;
|
||||
if (tBeat < 0.14f)
|
||||
{
|
||||
float f = Mathf.Lerp(55f, 38f, tBeat / 0.14f);
|
||||
thump = Mathf.Sin(2f * Mathf.PI * f * tBeat) * Mathf.Exp(-22f * tBeat) * 0.85f;
|
||||
}
|
||||
// Off-beat hat: a 30 ms noise tick halfway through each beat.
|
||||
float tHat = (t + beat * 0.5f) % beat;
|
||||
float hat = 0f;
|
||||
if (tHat < 0.03f)
|
||||
{
|
||||
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; // xorshift
|
||||
float n = (rng & 0xFFFF) / 32768f - 1f;
|
||||
hat = n * Mathf.Exp(-120f * tHat) * 0.22f;
|
||||
}
|
||||
data[i] = thump + hat;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static AudioClip BuildStingClip(float[] notes, float noteLen, float vol)
|
||||
{
|
||||
int len = (int)(notes.Length * noteLen * SampleRate) + SampleRate / 2; // + half-second tail
|
||||
var clip = AudioClip.Create("music_sting", len, 1, SampleRate, false);
|
||||
var data = new float[len];
|
||||
for (int n = 0; n < notes.Length; n++)
|
||||
{
|
||||
int start = (int)(n * noteLen * SampleRate);
|
||||
int dur = (int)(SampleRate * (noteLen + (n == notes.Length - 1 ? 0.5f : 0.05f)));
|
||||
for (int i = 0; i < dur && start + i < len; i++)
|
||||
{
|
||||
float tn = i / (float)SampleRate;
|
||||
data[start + i] += Mathf.Sin(2f * Mathf.PI * notes[n] * tn) * Mathf.Exp(-4.5f * tn) * vol;
|
||||
}
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0674dbc6d7e676f4cb88b18a67cdca99
|
||||
Reference in New Issue
Block a user