Expose the camo knobs in the HUD, persist and replicate them

Surface the PlayerCamoLit material knobs (HalfLambert, HalfLambert brightness, specular dampen) as material-backed properties on MeshPainter, guarded by an IsReady gate since the HUD binds before the material instance exists. Wire the three HUD sliders to them and seed the sliders from the live material once the painter is ready. Persist the values into the same HUD JSON as the brush settings (with a hasCamoValues flag so pre-camo save files never clobber the authored material) and carry them across a scene-switch respawn. Replicate them owner-authoritatively via a NetworkVariable in PlayerPaintSync, exactly like emission intensity, so remotes and late joiners show the same camo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-29 20:12:39 +02:00
1 parent b5bd1fdb8b
commit 7c84f6ba92
4 files changed
+187 -1

No files matched your search

+79 -1
View File
@@ -51,6 +51,15 @@ public partial class PaintHud
private readonly HudValues _carry = new HudValues();
private bool _hasCarry;
// Camo knobs (HalfLambert / brightness / specular dampen) live on the player's PlayerCamoLit material
// instance, which only exists after the painter's Start. They are saved/restored with the brush values
// above, but can't be WRITTEN until the material is ready - so a restored (disk) or carried (respawn)
// value waits here and is applied in the LateUpdate ready-gate. _camoEverReady gates the carry/save so
// the not-yet-ready 0 the getter returns never overwrites the material's authored value.
private Vector3 _pendingCamo;
private bool _hasPendingCamo;
private bool _camoEverReady;
private static string HudSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
private string HudSavePath()
@@ -75,6 +84,7 @@ public partial class PaintHud
{
public int version = HudFileVersion;
public bool hasValues; // false = no painter was bound when saved -> restore positions only
public bool hasCamoValues; // false = pre-camo save file -> keep the material's authored camo (no clobber)
public List<PanelPos> panels = new List<PanelPos>();
public HudValues values = new HudValues();
}
@@ -99,6 +109,7 @@ public partial class PaintHud
public int activeBrushIndex;
public int sprayDots;
public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax;
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen; // PlayerCamoLit material knobs
}
/// <summary>A draggable panel was dropped -> persist the layout immediately.</summary>
@@ -124,12 +135,14 @@ public partial class PaintHud
{
CopyValues(painter, state.values);
state.hasValues = true;
state.hasCamoValues = painter.IsReady; // only claim camo when it was actually readable
}
else if (_hasCarry)
{
// No painter bound at teardown (e.g. quit with no avatar) -> persist the last mirrored values.
state.values = _carry;
state.hasValues = true;
state.hasCamoValues = _camoEverReady; // _carry holds valid camo only after a ready painter
}
Directory.CreateDirectory(HudSaveDir);
@@ -164,6 +177,17 @@ public partial class PaintHud
SyncFromPainter();
}
// Camo is material-backed and can't be written until the painter is ready -> stash the saved values
// for the LateUpdate ready-gate. hasCamoValues guards pre-camo save files: without it we would apply
// a 0 and clobber the material's authored camo.
if (state.hasCamoValues && state.values != null)
{
_pendingCamo = new Vector3(state.values.camoHalfLambert,
state.values.camoHalfLambertBrightness,
state.values.camoSpecularDampen);
_hasPendingCamo = true;
}
// Point-anchored panels: anchoredPosition is resolution-independent under the canvas'
// ScaleWithScreenSize scaler, so the raw saved value places them identically next session.
var root = _panel.transform;
@@ -179,6 +203,31 @@ public partial class PaintHud
{
if (painter)
{
// Camo knobs are material-backed and only readable/writable after the painter's Start built the
// instance (binding ran earlier, in the avatar's OnNetworkSpawn). Apply the restored (disk) or
// carried (respawn) values to the material once it is ready, then seed the sliders from it. Done
// BEFORE the carry mirror + change detection below so the just-applied value is already the
// baseline and never re-triggers a redundant auto-save. With nothing pending (first session, no
// save file) the material keeps its authored camo and the sliders just reflect it.
if (!_camoSynced && painter.IsReady)
{
if (_hasPendingCamo)
{
painter.CamoHalfLambert = _pendingCamo.x;
painter.CamoHalfLambertBrightness = _pendingCamo.y;
painter.CamoSpecularDampen = _pendingCamo.z;
_hasPendingCamo = false;
}
SyncCamoSliders();
_savedValues.camoHalfLambert = _lastSeenValues.camoHalfLambert = painter.CamoHalfLambert;
_savedValues.camoHalfLambertBrightness = _lastSeenValues.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_savedValues.camoSpecularDampen = _lastSeenValues.camoSpecularDampen = painter.CamoSpecularDampen;
_camoEverReady = true;
_camoSynced = true;
}
// Mirror the live painter every frame; the next BindPainter (after a respawn) seeds the fresh
// painter from this snapshot instead of its prefab defaults. Captured while the painter lives,
// so it is immune to the destroy-pending timing of the despawn.
@@ -239,7 +288,10 @@ public partial class PaintHud
&& p.sprayDotSizeMin == v.sprayDotSizeMin
&& p.sprayDotSizeMax == v.sprayDotSizeMax
&& p.sprayDotAlphaMin == v.sprayDotAlphaMin
&& p.sprayDotAlphaMax == v.sprayDotAlphaMax;
&& p.sprayDotAlphaMax == v.sprayDotAlphaMax
&& p.CamoHalfLambert == v.camoHalfLambert
&& p.CamoHalfLambertBrightness == v.camoHalfLambertBrightness
&& p.CamoSpecularDampen == v.camoSpecularDampen;
}
/// <summary>Snapshots the bound painter's brush values into the in-memory mirror.</summary>
@@ -256,6 +308,16 @@ public partial class PaintHud
if (!painter || !_hasCarry) return;
ApplyValues(painter, _carry);
SyncFromPainter();
// Camo is material-backed -> defer to the ready-gate using the carried (last live) values. Guarded
// by _camoEverReady so a carry taken before any material was ready can't apply a stale 0.
if (_camoEverReady)
{
_pendingCamo = new Vector3(_carry.camoHalfLambert,
_carry.camoHalfLambertBrightness,
_carry.camoSpecularDampen);
_hasPendingCamo = true;
}
}
private static void CopyValues(MeshPainter from, HudValues v)
@@ -280,6 +342,16 @@ public partial class PaintHud
v.sprayDotSizeMax = from.sprayDotSizeMax;
v.sprayDotAlphaMin = from.sprayDotAlphaMin;
v.sprayDotAlphaMax = from.sprayDotAlphaMax;
// Camo is material-backed: the getters read 0 until the painter's material instance exists, so only
// mirror them once ready - otherwise preserve the desired values already in the target (the value a
// restore/carry put there, which the ready-gate still has to apply).
if (from.IsReady)
{
v.camoHalfLambert = from.CamoHalfLambert;
v.camoHalfLambertBrightness = from.CamoHalfLambertBrightness;
v.camoSpecularDampen = from.CamoSpecularDampen;
}
}
private static void ApplyValues(MeshPainter to, HudValues v)
@@ -304,6 +376,12 @@ public partial class PaintHud
to.sprayDotSizeMax = v.sprayDotSizeMax;
to.sprayDotAlphaMin = v.sprayDotAlphaMin;
to.sprayDotAlphaMax = v.sprayDotAlphaMax;
// Camo setters are a no-op until the material exists; the guaranteed deferred apply runs from the
// LateUpdate ready-gate via _pendingCamo. Writing here too keeps Copy/Apply/Equal symmetric.
to.CamoHalfLambert = v.camoHalfLambert;
to.CamoHalfLambertBrightness = v.camoHalfLambertBrightness;
to.CamoSpecularDampen = v.camoSpecularDampen;
}
private HudState LoadHudState()