Files
chameleon/Assets/Scripts/MeshPainter.Persistence.cs
T
adminandClaude Opus 4.8 057c99fa1d Persist material scalars, reset the HUD, and split alpha from the picker
Save and restore the emission intensity and the three camo knobs
(half-Lambert, half-Lambert brightness, specular dampen) with a named
painting. The .paint format moves to v3 with a small scalar block
between the header and the pixel maps; v2 paintings still load with
their painter values left untouched. Loading re-syncs the emission and
camo sliders, and peers pick the values up for free through the
existing owner-authoritative paint NetworkVariables.

Add a HUD reset (Lib_ResetHud button plus context-menu fallback) that
behaves as if the saved hud-layout file were deleted: it removes the
file, restores panels to their authored prefab positions, and resets
the brush values to the painter/material defaults captured before any
carry/restore runs.

Remove the alpha bar from HsvColorPicker so alpha is driven by
standalone Slider_AlbedoAlpha / Slider_EmissionAlpha bindings; the
picker now writes RGB only and the sliders write alpha only, with no
feedback between them. Add a generic SliderCurve component that reshapes
a slider's handle position into a non-linear value (with an inverse for
setting values back), and make the slider wiring curve-aware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:20:13 +02:00

269 lines
11 KiB
C#

using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
public partial class MeshPainter
{
// --- Persistence: named paint slots, saved/loaded explicitly from the HUD ---
//
// The painter no longer auto-loads on start or auto-saves on quit; painting is volatile until the
// player explicitly saves it to a named slot, and a fresh session always starts from the material
// seed. Saved slots live in a single global library folder (role/object independent), so any avatar
// can load any saved painting as a camouflage preset.
// Raw, bit-exact map dump: 'CHMP' magic + version + name + width + height + map count, then each
// map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity and
// round-trips the exact canonical bytes ReadCanonical produces.
private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian
private const int PaintFileVersion = 3; // v3: added the per-player material scalars (emission + camo)
private const string PaintFileExt = ".paint";
/// <summary>
/// Per-player material scalars stored alongside the maps from v3 on. These are NOT baked into the
/// painted pixels (a global emission multiplier + the PlayerCamoLit knobs), so they ride the slot
/// header and are re-applied onto the painter when the slot loads.
/// </summary>
private struct PaintScalars
{
public bool has; // false = legacy v2 slot with no scalars -> leave the painter's values untouched
public float emissionIntensity;
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen;
}
private static string PaintLibraryDir =>
Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved");
private static string SanitizeKey(string s)
{
if (string.IsNullOrEmpty(s)) return "unnamed";
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
return s.Replace(' ', '_');
}
/// <summary>Disk path for a slot. The display name lives inside the file; the file name is a sanitized key.</summary>
private static string PathForName(string name) =>
Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt);
/// <summary>True if a slot with this name already exists (used to block overwriting on save).</summary>
public static bool PaintExists(string name) =>
!string.IsNullOrWhiteSpace(name) && File.Exists(PathForName(name));
/// <summary>Display names of every saved slot, sorted alphabetically. Each name is read from the file header.</summary>
public static List<string> ListSavedPaintings()
{
var names = new List<string>();
try
{
if (!Directory.Exists(PaintLibraryDir)) return names;
foreach (string path in Directory.GetFiles(PaintLibraryDir, "*" + PaintFileExt))
{
string n = ReadHeaderName(path);
if (!string.IsNullOrEmpty(n)) names.Add(n);
}
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Listing saved paintings failed: {e.Message}");
}
names.Sort(System.StringComparer.CurrentCultureIgnoreCase);
return names;
}
/// <summary>
/// Writes the current painted maps to a new named slot. Returns false without writing if the painter
/// is not ready or a slot with this name already exists (no silent overwrite).
/// </summary>
public bool SavePaintAs(string name)
{
if (!_setupValid || string.IsNullOrWhiteSpace(name)) return false;
name = name.Trim();
if (PaintExists(name)) return false;
try
{
Directory.CreateDirectory(PaintLibraryDir);
int n = textureSize * textureSize;
using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write))
using (var w = new BinaryWriter(fs))
{
w.Write(PaintFileMagic);
w.Write(PaintFileVersion);
w.Write(name); // length-prefixed UTF8 (BinaryWriter.Write(string))
w.Write(textureSize);
w.Write(textureSize);
w.Write(MAP_COUNT);
// v3: per-player material scalars that are NOT baked into the maps (a global emission
// multiplier + the PlayerCamoLit knobs). The painter is ready here (_setupValid), so the
// material-backed camo getters return the live values rather than the not-ready 0.
w.Write(emissionIntensity);
w.Write(CamoHalfLambert);
w.Write(CamoHalfLambertBrightness);
w.Write(CamoSpecularDampen);
var bytes = new byte[n * 4];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
Color32[] px = ReadCanonical(mi);
int count = Mathf.Min(n, px.Length);
for (int i = 0; i < count; i++)
{
int o = i * 4;
bytes[o] = px[i].r;
bytes[o + 1] = px[i].g;
bytes[o + 2] = px[i].b;
bytes[o + 3] = px[i].a;
}
w.Write(bytes, 0, count * 4);
}
}
return true;
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Saving paint '{name}' failed: {e.Message}", this);
return false;
}
}
/// <summary>
/// Loads a named slot and re-seeds the active backend with it, replacing the current painting.
/// Returns false if the slot is missing or incompatible (the current painting is left untouched).
/// </summary>
public bool LoadPaintByName(string name)
{
if (!_setupValid) return false;
Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars);
if (canonical == null) return false;
// Re-seed exactly like SwitchBackend / ApplyPaintSnapshot, so the island/dilation seeding path
// runs and the edge padding is rebuilt correctly for the loaded content.
ReseedCanonical(canonical);
// A load replaces the whole painting and is NOT undoable: the loaded maps become the new history
// baseline, clearing any prior undo/redo. Peers reset to the same baseline via the load broadcast.
ResetHistoryToCurrent(canonical);
// v3 slots also carry the per-player material scalars; apply them so the loaded look is complete.
// Legacy v2 slots have none -> leave the current emission/camo untouched. The painter is ready here
// (_setupValid), so the material-backed camo setters take effect; PlayerPaintSync replicates the
// changed values to peers via their owner-authoritative NetworkVariables on its next LateUpdate.
if (scalars.has)
{
emissionIntensity = scalars.emissionIntensity;
RefreshEmissionColor();
CamoHalfLambert = scalars.camoHalfLambert;
CamoHalfLambertBrightness = scalars.camoHalfLambertBrightness;
CamoSpecularDampen = scalars.camoSpecularDampen;
}
return true;
}
/// <summary>Deletes a named slot. Returns true if a file was removed.</summary>
public static bool DeletePaint(string name)
{
try
{
string path = PathForName(name);
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Deleting paint '{name}' failed: {e.Message}");
return false;
}
}
/// <summary>Reads just the display-name header of a slot file (or the file name as a fallback).</summary>
private static string ReadHeaderName(string path)
{
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (r.ReadUInt32() != PaintFileMagic) return null;
int ver = r.ReadInt32();
if (ver != 2 && ver != PaintFileVersion) return null; // list legacy v2 slots + current
return r.ReadString();
}
}
catch
{
return Path.GetFileNameWithoutExtension(path);
}
}
/// <summary>
/// Reads all maps from a slot file in canonical layout, or null if missing / incompatible. Per-map
/// null entries are tolerated by both backends (they fall back to the material seed).
/// </summary>
private Color32[][] ReadMaps(string path, out PaintScalars scalars)
{
scalars = default;
if (!File.Exists(path)) return null;
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (r.ReadUInt32() != PaintFileMagic) return null;
int ver = r.ReadInt32();
if (ver != 2 && ver != PaintFileVersion) return null; // read legacy v2 + current
r.ReadString(); // display name (header only; not needed for the pixel load)
int w = r.ReadInt32();
int h = r.ReadInt32();
int maps = r.ReadInt32();
// v3+: the per-player material scalars sit between the header and the pixel maps.
if (ver >= 3)
{
scalars.has = true;
scalars.emissionIntensity = r.ReadSingle();
scalars.camoHalfLambert = r.ReadSingle();
scalars.camoHalfLambertBrightness = r.ReadSingle();
scalars.camoSpecularDampen = r.ReadSingle();
}
// Resolution change -> ignore rather than smear mismatched data; the brush re-seeds
// from the material instead.
if (w != textureSize || h != textureSize)
{
Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h}, expected " +
$"{textureSize}x{textureSize}).", this);
return null;
}
int n = textureSize * textureSize;
var result = new Color32[MAP_COUNT][];
int load = Mathf.Min(maps, MAP_COUNT);
for (int mi = 0; mi < load; mi++)
{
byte[] raw = r.ReadBytes(n * 4);
if (raw.Length != n * 4) break; // truncated file -> remaining maps stay seeded
var px = new Color32[n];
for (int i = 0; i < n; i++)
{
int o = i * 4;
px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]);
}
result[mi] = px;
}
return result;
}
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Loading paint failed: {e.Message}", this);
return null;
}
}
}