Implement gravestone features and enhance Lore interactions.
- Add gravestone management with max stack and lifetime settings - Introduce new commands for grave interactions in scenarios - Update Lore handling to include gravestone context and details - Refactor death cause formatting for improved readability - Increment version to 0.28.11 in mod.json
This commit is contained in:
parent
750a303d89
commit
c11b87aa2e
19 changed files with 2426 additions and 130 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -49,6 +49,7 @@ desktop.ini
|
|||
# Local tooling (portable SDKs, decomp dumps, etc.)
|
||||
.dotnet/
|
||||
.local/
|
||||
.tmp-reflect/
|
||||
*.decompiled.cs
|
||||
|
||||
# local test arm files
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ public static class ActorChroniclePatches
|
|||
}
|
||||
|
||||
Chronicle.NoteDeath(__instance, pType, __state);
|
||||
GraveMarkers.AddDeath(__instance);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))]
|
||||
|
|
|
|||
|
|
@ -1772,7 +1772,9 @@ public static class AgentHarness
|
|||
&& focus.isAlive())
|
||||
{
|
||||
killerId = focus.getID();
|
||||
deathPos = focus.current_position;
|
||||
// Offset so place/skull focus is distinct from following the killer.
|
||||
Vector2 killerPos = focus.current_position;
|
||||
deathPos = new Vector3(killerPos.x + 2.5f, killerPos.y, 0f);
|
||||
}
|
||||
else if (mode.IndexOf("place", System.StringComparison.Ordinal) >= 0
|
||||
|| favorite
|
||||
|
|
@ -1803,6 +1805,20 @@ public static class AgentHarness
|
|||
bool ok = id != 0
|
||||
&& Chronicle.HistoryCountFor(id) >= n
|
||||
&& (!favorite || Chronicle.IsFavoriteSubject(id));
|
||||
if (ok && deathPos.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
int tx = Mathf.FloorToInt(deathPos.x);
|
||||
int ty = Mathf.FloorToInt(deathPos.y);
|
||||
GraveMarkers.ForceAdd(
|
||||
id,
|
||||
name,
|
||||
tx,
|
||||
ty,
|
||||
deathPos,
|
||||
Chronicle.FormatGraveDeathSubtitle(id),
|
||||
favorite);
|
||||
}
|
||||
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
|
|
@ -1816,7 +1832,342 @@ public static class AgentHarness
|
|||
cmd,
|
||||
ok,
|
||||
detail:
|
||||
$"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n} manner={manner} killer={killerId} mode={mode} fav={Chronicle.IsFavoriteSubject(id)}");
|
||||
$"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n} manner={manner} killer={killerId} mode={mode} fav={Chronicle.IsFavoriteSubject(id)} stacks={GraveMarkers.StackCount}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "grave_clear":
|
||||
{
|
||||
GraveMarkers.Clear();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: "graves_cleared");
|
||||
break;
|
||||
}
|
||||
|
||||
case "grave_force":
|
||||
{
|
||||
// value=name, count=entries on same tile, tier="dx,dy" tile offset from focus/camera
|
||||
string gName = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "GraveDummy";
|
||||
int n = Math.Max(1, cmd.count);
|
||||
Vector3 pos = Vector3.zero;
|
||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||
{
|
||||
pos = MoveCamera._focus_unit.current_position;
|
||||
}
|
||||
else if (MoveCamera.instance != null)
|
||||
{
|
||||
pos = MoveCamera.instance.transform.position;
|
||||
pos.z = 0f;
|
||||
}
|
||||
|
||||
if (pos.sqrMagnitude < 0.0001f)
|
||||
{
|
||||
pos = new Vector3(48f, 48f, 0f);
|
||||
}
|
||||
|
||||
int tx = Mathf.FloorToInt(pos.x);
|
||||
int ty = Mathf.FloorToInt(pos.y);
|
||||
if (!string.IsNullOrEmpty(cmd.tier) && cmd.tier.Contains(","))
|
||||
{
|
||||
string[] parts = cmd.tier.Split(',');
|
||||
if (parts.Length >= 2
|
||||
&& int.TryParse(parts[0].Trim(), out int ox)
|
||||
&& int.TryParse(parts[1].Trim(), out int oy))
|
||||
{
|
||||
tx += ox;
|
||||
ty += oy;
|
||||
pos = new Vector3(tx + 0.5f, ty + 0.5f, 0f);
|
||||
}
|
||||
}
|
||||
int before = GraveMarkers.EntryCountAt(tx, ty);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
string nm = n > 1 ? gName + i : gName;
|
||||
long id = Chronicle.ForceOrphanHistory(
|
||||
nm,
|
||||
!string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "human",
|
||||
1,
|
||||
"Grave",
|
||||
AttackType.Age,
|
||||
0,
|
||||
pos,
|
||||
favorite: false);
|
||||
GraveMarkers.ForceAdd(
|
||||
id, nm, tx, ty, pos, Chronicle.FormatGraveDeathSubtitle(id), favorite: false);
|
||||
}
|
||||
|
||||
int after = GraveMarkers.EntryCountAt(tx, ty);
|
||||
bool gOk = after >= before + n && GraveMarkers.StackCount >= 1;
|
||||
if (gOk)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
gOk,
|
||||
detail: $"tile={tx},{ty} entries={after} stacks={GraveMarkers.StackCount} added={n}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "grave_open":
|
||||
{
|
||||
Vector3 pos = Vector3.zero;
|
||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||
{
|
||||
pos = MoveCamera._focus_unit.current_position;
|
||||
}
|
||||
else if (MoveCamera.instance != null)
|
||||
{
|
||||
pos = MoveCamera.instance.transform.position;
|
||||
pos.z = 0f;
|
||||
}
|
||||
|
||||
int tx = Mathf.FloorToInt(pos.x);
|
||||
int ty = Mathf.FloorToInt(pos.y);
|
||||
if (!string.IsNullOrEmpty(cmd.value) && cmd.value.Contains(","))
|
||||
{
|
||||
string[] parts = cmd.value.Split(',');
|
||||
if (parts.Length >= 2
|
||||
&& int.TryParse(parts[0].Trim(), out int px)
|
||||
&& int.TryParse(parts[1].Trim(), out int py))
|
||||
{
|
||||
tx = px;
|
||||
ty = py;
|
||||
}
|
||||
}
|
||||
|
||||
bool opened = ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: true);
|
||||
if (opened)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
opened,
|
||||
detail:
|
||||
$"tile={tx},{ty} open={opened} scope={ChronicleHud.GraveStackScopeActive} detail={ChronicleHud.DetailUnitId} stacks={GraveMarkers.StackCount}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "grave_search":
|
||||
{
|
||||
ChronicleHud.SetSearch(cmd.value ?? "");
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail:
|
||||
$"query='{ChronicleHud.SearchText}' filtered={ChronicleHud.GraveStackFilteredCount} scope={ChronicleHud.GraveStackScopeActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "grave_open_lore":
|
||||
{
|
||||
// value=name: search any stack. Else focus/camera tile, then current Lore grave scope.
|
||||
string needle = (cmd.value ?? "").Trim();
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
long unitId = 0;
|
||||
GraveMarkers.GraveStack stack = null;
|
||||
GraveMarkers.GraveEntry pick = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(needle)
|
||||
&& GraveMarkers.TryFindEntryByName(needle, out pick, out stack))
|
||||
{
|
||||
tx = stack.TileX;
|
||||
ty = stack.TileY;
|
||||
unitId = pick.UnitId;
|
||||
}
|
||||
else
|
||||
{
|
||||
Vector3 pos = Vector3.zero;
|
||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||
{
|
||||
pos = MoveCamera._focus_unit.current_position;
|
||||
}
|
||||
else if (MoveCamera.instance != null)
|
||||
{
|
||||
pos = MoveCamera.instance.transform.position;
|
||||
pos.z = 0f;
|
||||
}
|
||||
|
||||
tx = Mathf.FloorToInt(pos.x);
|
||||
ty = Mathf.FloorToInt(pos.y);
|
||||
if (!GraveMarkers.TryGetStack(tx, ty, out stack)
|
||||
&& ChronicleHud.GraveStackScopeActive)
|
||||
{
|
||||
tx = ChronicleHud.GraveStackTileX;
|
||||
ty = ChronicleHud.GraveStackTileY;
|
||||
GraveMarkers.TryGetStack(tx, ty, out stack);
|
||||
}
|
||||
|
||||
if (stack != null && stack.Entries.Count > 0)
|
||||
{
|
||||
pick = stack.Entries[stack.Entries.Count - 1];
|
||||
unitId = pick.UnitId;
|
||||
}
|
||||
}
|
||||
|
||||
bool loreOk = false;
|
||||
if (unitId != 0 && stack != null)
|
||||
{
|
||||
InspectUi.MarkGraveSession();
|
||||
ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: true);
|
||||
if (ChronicleHud.DetailUnitId != unitId)
|
||||
{
|
||||
ChronicleHud.OpenUnitHistory(
|
||||
unitId, pauseIdle: true, followFocus: false, preferPlace: true);
|
||||
}
|
||||
|
||||
loreOk = ChronicleHud.DetailUnitId == unitId
|
||||
&& Chronicle.LastFallenFocusDetail == "place";
|
||||
}
|
||||
|
||||
if (loreOk)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
loreOk,
|
||||
detail:
|
||||
$"tile={tx},{ty} unit={unitId} detail={ChronicleHud.DetailUnitId} fallenFocus={Chronicle.LastFallenFocusDetail} scope={ChronicleHud.GraveStackScopeActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "grave_set_permanent":
|
||||
{
|
||||
// value=true|false; optional tier="tx,ty" else Lore grave scope / focus tile.
|
||||
int tx = ChronicleHud.GraveStackTileX;
|
||||
int ty = ChronicleHud.GraveStackTileY;
|
||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||
{
|
||||
Vector3 p = MoveCamera._focus_unit.current_position;
|
||||
int ftx = Mathf.FloorToInt(p.x);
|
||||
int fty = Mathf.FloorToInt(p.y);
|
||||
if (GraveMarkers.TryGetStack(ftx, fty, out _))
|
||||
{
|
||||
tx = ftx;
|
||||
ty = fty;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ChronicleHud.GraveStackScopeActive
|
||||
&& !GraveMarkers.TryGetStack(tx, ty, out _)
|
||||
&& MoveCamera.hasFocusUnit()
|
||||
&& MoveCamera._focus_unit != null)
|
||||
{
|
||||
Vector3 p = MoveCamera._focus_unit.current_position;
|
||||
tx = Mathf.FloorToInt(p.x);
|
||||
ty = Mathf.FloorToInt(p.y);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(cmd.tier) && cmd.tier.Contains(","))
|
||||
{
|
||||
string[] parts = cmd.tier.Split(',');
|
||||
if (parts.Length >= 2
|
||||
&& int.TryParse(parts[0].Trim(), out int px)
|
||||
&& int.TryParse(parts[1].Trim(), out int py))
|
||||
{
|
||||
tx = px;
|
||||
ty = py;
|
||||
}
|
||||
}
|
||||
|
||||
bool want = ParseBool(
|
||||
!string.IsNullOrEmpty(cmd.value) ? cmd.value : cmd.expect,
|
||||
defaultValue: true);
|
||||
bool setOk = GraveMarkers.TrySetStackPermanent(tx, ty, want);
|
||||
if (setOk && ChronicleHud.GraveStackScopeActive)
|
||||
{
|
||||
// Refresh Keep chrome.
|
||||
ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: false);
|
||||
}
|
||||
|
||||
if (setOk)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
setOk,
|
||||
detail:
|
||||
$"tile={tx},{ty} permanent={GraveMarkers.IsStackPermanent(tx, ty)} want={want}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "grave_delete":
|
||||
{
|
||||
int before = GraveMarkers.StackCount;
|
||||
bool delOk = ChronicleHud.DeleteActiveGraveStack();
|
||||
bool gone = delOk
|
||||
&& !ChronicleHud.GraveStackScopeActive
|
||||
&& GraveMarkers.StackCount < before;
|
||||
if (gone)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
gone,
|
||||
detail:
|
||||
$"deleted={delOk} stacks={GraveMarkers.StackCount} before={before} scope={ChronicleHud.GraveStackScopeActive} lore={Chronicle.HudVisible}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "inspect_dismiss":
|
||||
{
|
||||
InspectUi.DismissAll();
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail:
|
||||
$"graveSession={InspectUi.GraveSessionActive} lore={Chronicle.HudVisible} scope={ChronicleHud.GraveStackScopeActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "lore_focus_killer":
|
||||
{
|
||||
bool kOk = ChronicleHud.FocusDetailKiller();
|
||||
if (kOk)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
kOk,
|
||||
detail: $"ok={kOk} fallenFocus={Chronicle.LastFallenFocusDetail} detail={ChronicleHud.DetailUnitId}");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -5735,8 +6086,22 @@ public static class AgentHarness
|
|||
return;
|
||||
}
|
||||
|
||||
bool ok;
|
||||
string detail;
|
||||
if (int.TryParse((cmd.value ?? "").Trim(), out int intVal)
|
||||
&& (key == ModSettings.GravestoneMaxStacksId
|
||||
|| key == ModSettings.GravestoneTtlSecondsId))
|
||||
{
|
||||
ok = ModSettings.TrySetInt(key, intVal);
|
||||
detail = $"{key}={intVal}";
|
||||
}
|
||||
else
|
||||
{
|
||||
bool val = ParseBool(cmd.value, defaultValue: true);
|
||||
bool ok = ModSettings.TrySetBool(key, val);
|
||||
ok = ModSettings.TrySetBool(key, val);
|
||||
detail = $"{key}={val}";
|
||||
}
|
||||
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
|
|
@ -5746,7 +6111,7 @@ public static class AgentHarness
|
|||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"{key}={val}");
|
||||
Emit(cmd, ok, detail: detail);
|
||||
}
|
||||
|
||||
private static void DoAssert(HarnessCommand cmd)
|
||||
|
|
@ -8407,9 +8772,10 @@ public static class AgentHarness
|
|||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = ChronicleHud.DetailUnitId != 0;
|
||||
pass = Chronicle.HudVisible && have == want;
|
||||
// want=true: Lore open with a subject; want=false: no detail (panel may be closed).
|
||||
pass = want ? (Chronicle.HudVisible && have) : !have;
|
||||
detail =
|
||||
$"detailId={ChronicleHud.DetailUnitId} want={want} tab={ChronicleHud.ActiveTab} follow={ChronicleHud.FollowFocus}";
|
||||
$"detailId={ChronicleHud.DetailUnitId} want={want} visible={Chronicle.HudVisible} tab={ChronicleHud.ActiveTab} follow={ChronicleHud.FollowFocus}";
|
||||
break;
|
||||
}
|
||||
case "lore_follow":
|
||||
|
|
@ -8445,14 +8811,128 @@ public static class AgentHarness
|
|||
detail = $"fallenFocus={Chronicle.LastFallenFocusDetail} want={cmd.value}";
|
||||
break;
|
||||
}
|
||||
case "grave_stacks":
|
||||
{
|
||||
// Prefer value when set so want=0 works (count>0 would skip an explicit 0).
|
||||
int want = !string.IsNullOrEmpty(cmd.value)
|
||||
? ParseInt(cmd.value, 0)
|
||||
: cmd.count;
|
||||
int have = GraveMarkers.StackCount;
|
||||
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
||||
if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
|
||||
{
|
||||
pass = have >= want;
|
||||
}
|
||||
else if (cmp == "max" || cmp == "<=")
|
||||
{
|
||||
pass = have <= want;
|
||||
}
|
||||
else if (cmp == "eq" || cmp == "==")
|
||||
{
|
||||
pass = have == want;
|
||||
}
|
||||
else
|
||||
{
|
||||
pass = have >= want;
|
||||
}
|
||||
|
||||
detail = $"stacks={have} want={want} cmp={cmp}";
|
||||
break;
|
||||
}
|
||||
case "grave_entries":
|
||||
{
|
||||
int want = cmd.count > 0 ? cmd.count : ParseInt(cmd.value, 1);
|
||||
Vector3 pos = Vector3.zero;
|
||||
if (MoveCamera.instance != null)
|
||||
{
|
||||
pos = MoveCamera.instance.transform.position;
|
||||
pos.z = 0f;
|
||||
}
|
||||
|
||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||
{
|
||||
pos = MoveCamera._focus_unit.current_position;
|
||||
}
|
||||
|
||||
int tx = Mathf.FloorToInt(pos.x);
|
||||
int ty = Mathf.FloorToInt(pos.y);
|
||||
int have = GraveMarkers.EntryCountAt(tx, ty);
|
||||
pass = have >= want;
|
||||
detail = $"tile={tx},{ty} entries={have} want={want}";
|
||||
break;
|
||||
}
|
||||
case "grave_stack_scope":
|
||||
case "grave_panel":
|
||||
{
|
||||
// grave_panel kept as alias during transition; means Lore grave-stack scope.
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = Chronicle.HudVisible && ChronicleHud.GraveStackScopeActive;
|
||||
pass = have == want;
|
||||
detail =
|
||||
$"scope={ChronicleHud.GraveStackScopeActive} lore={Chronicle.HudVisible} want={want} detail={ChronicleHud.DetailUnitId} filtered={ChronicleHud.GraveStackFilteredCount}";
|
||||
break;
|
||||
}
|
||||
case "grave_permanent":
|
||||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
int tx = ChronicleHud.GraveStackTileX;
|
||||
int ty = ChronicleHud.GraveStackTileY;
|
||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||
{
|
||||
Vector3 p = MoveCamera._focus_unit.current_position;
|
||||
int ftx = Mathf.FloorToInt(p.x);
|
||||
int fty = Mathf.FloorToInt(p.y);
|
||||
if (GraveMarkers.TryGetStack(ftx, fty, out _))
|
||||
{
|
||||
tx = ftx;
|
||||
ty = fty;
|
||||
}
|
||||
}
|
||||
|
||||
bool have = GraveMarkers.IsStackPermanent(tx, ty);
|
||||
pass = have == want;
|
||||
detail = $"tile={tx},{ty} permanent={have} want={want}";
|
||||
break;
|
||||
}
|
||||
case "grave_search_filtered":
|
||||
{
|
||||
int want = cmd.count > 0 ? cmd.count : ParseInt(cmd.value, 1);
|
||||
pass = ChronicleHud.GraveStackScopeActive
|
||||
&& ChronicleHud.DetailUnitId == 0
|
||||
&& ChronicleHud.GraveStackFilteredCount == want;
|
||||
detail =
|
||||
$"filtered={ChronicleHud.GraveStackFilteredCount} want={want} query='{ChronicleHud.SearchText}' scope={ChronicleHud.GraveStackScopeActive}";
|
||||
break;
|
||||
}
|
||||
case "grave_search_layout":
|
||||
{
|
||||
bool fills = ChronicleHud.GraveSearchFillsTools;
|
||||
pass = ChronicleHud.GraveStackScopeActive
|
||||
&& ChronicleHud.DetailUnitId == 0
|
||||
&& fills;
|
||||
detail =
|
||||
$"fills={fills} scope={ChronicleHud.GraveStackScopeActive} detail={ChronicleHud.DetailUnitId}";
|
||||
break;
|
||||
}
|
||||
case "grave_death_subtitle":
|
||||
{
|
||||
string needle = (cmd.value ?? "").Trim();
|
||||
long id = ChronicleHud.DetailUnitId;
|
||||
string have = id != 0 ? Chronicle.FormatGraveDeathSubtitle(id) : "";
|
||||
pass = !string.IsNullOrEmpty(needle)
|
||||
&& !string.IsNullOrEmpty(have)
|
||||
&& have.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
detail = $"subtitle='{have}' wantContains='{needle}' detailId={id}";
|
||||
break;
|
||||
}
|
||||
case "death_manner":
|
||||
{
|
||||
long id = ChronicleHud.DetailUnitId;
|
||||
DeathManner have = Chronicle.LatestDeathMannerFor(id);
|
||||
string haveLabel = Chronicle.DeathMannerLabel(have);
|
||||
string want = (cmd.value ?? "").Trim().ToLowerInvariant();
|
||||
string want = (cmd.value ?? "").Trim();
|
||||
pass = id != 0
|
||||
&& (haveLabel == want
|
||||
&& (haveLabel.Equals(want, System.StringComparison.OrdinalIgnoreCase)
|
||||
|| have.ToString().Equals(want, System.StringComparison.OrdinalIgnoreCase));
|
||||
detail = $"manner={have} label={haveLabel} want={cmd.value} detailId={id}";
|
||||
break;
|
||||
|
|
@ -9421,6 +9901,16 @@ public static class AgentHarness
|
|||
return defaultValue;
|
||||
}
|
||||
|
||||
private static int ParseInt(string raw, int defaultValue)
|
||||
{
|
||||
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v))
|
||||
{
|
||||
return v;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static string Escape(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s))
|
||||
|
|
|
|||
|
|
@ -1625,37 +1625,8 @@ public static class Chronicle
|
|||
string prefix = string.IsNullOrEmpty(labelPrefix) ? "Fallen" : labelPrefix;
|
||||
bool hasKiller = killerId != 0;
|
||||
DeathManner manner = ClassifyDeathManner(attackType, hasKiller);
|
||||
string baseCause;
|
||||
switch (manner)
|
||||
{
|
||||
case DeathManner.Slain:
|
||||
baseCause = hasKiller ? "Killed by foe" : "Killed in combat";
|
||||
break;
|
||||
case DeathManner.OldAge:
|
||||
baseCause = "Died of old age";
|
||||
break;
|
||||
case DeathManner.Starvation:
|
||||
baseCause = "Starved to death";
|
||||
break;
|
||||
case DeathManner.Drowned:
|
||||
baseCause = "Drowned";
|
||||
break;
|
||||
case DeathManner.Burned:
|
||||
baseCause = "Burned to death";
|
||||
break;
|
||||
case DeathManner.Plague:
|
||||
baseCause = "Died of plague";
|
||||
break;
|
||||
case DeathManner.Divine:
|
||||
baseCause = "Struck down by divine power";
|
||||
break;
|
||||
case DeathManner.Accident:
|
||||
baseCause = "Died in an explosion";
|
||||
break;
|
||||
default:
|
||||
baseCause = "Died";
|
||||
break;
|
||||
}
|
||||
Actor killerActor = hasKiller ? FindUnitById(killerId) : null;
|
||||
string baseCause = FormatDeathCause(null, attackType, killerActor);
|
||||
|
||||
RefreshAgeChapter(force: false);
|
||||
StampWorldDate(out double worldTime, out string dateLabel);
|
||||
|
|
@ -1878,33 +1849,186 @@ public static class Chronicle
|
|||
return DeathManner.Other;
|
||||
}
|
||||
|
||||
/// <summary>Capitalized manner label for Lore / dossier (matches grave subtitle style).</summary>
|
||||
public static string DeathMannerLabel(DeathManner manner)
|
||||
{
|
||||
switch (manner)
|
||||
{
|
||||
case DeathManner.Slain:
|
||||
return "slain";
|
||||
return "Slain";
|
||||
case DeathManner.OldAge:
|
||||
return "old age";
|
||||
return "Old Age";
|
||||
case DeathManner.Starvation:
|
||||
return "starved";
|
||||
return "Starved";
|
||||
case DeathManner.Drowned:
|
||||
return "drowned";
|
||||
return "Drowned";
|
||||
case DeathManner.Burned:
|
||||
return "burned";
|
||||
return "Burned";
|
||||
case DeathManner.Plague:
|
||||
return "plague";
|
||||
return "Plague";
|
||||
case DeathManner.Accident:
|
||||
return "accident";
|
||||
return "Accident";
|
||||
case DeathManner.Divine:
|
||||
return "divine";
|
||||
return "Divine";
|
||||
case DeathManner.Other:
|
||||
return "dead";
|
||||
return "Dead";
|
||||
default:
|
||||
return "fallen";
|
||||
return "Fallen";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Short capitalized grave / Fallen-list flavor, AttackType-specific when known.
|
||||
/// </summary>
|
||||
public static string FormatGraveDeathSubtitle(long unitId)
|
||||
{
|
||||
ChronicleEntry death = LatestDeathEntry(unitId);
|
||||
if (death == null)
|
||||
{
|
||||
return "Dead";
|
||||
}
|
||||
|
||||
string killer = ResolveKillerDisplayName(death);
|
||||
if (!string.IsNullOrEmpty(death.AttackTypeName)
|
||||
&& System.Enum.TryParse(death.AttackTypeName, ignoreCase: true, out AttackType attackType))
|
||||
{
|
||||
return FormatGraveFlavor(attackType, killer, death.Line);
|
||||
}
|
||||
|
||||
return FormatGraveSubtitleFromAttackType(death.AttackTypeName, killer);
|
||||
}
|
||||
|
||||
/// <summary>Punchy Title-Case manner for a known <see cref="AttackType"/>.</summary>
|
||||
public static string FormatGraveFlavor(AttackType attackType, string killer, string deathLine = "")
|
||||
{
|
||||
string k = killer ?? "";
|
||||
bool hasKiller = !string.IsNullOrEmpty(k);
|
||||
string line = deathLine ?? "";
|
||||
bool lava = line.IndexOf("lava", System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
switch (attackType)
|
||||
{
|
||||
case AttackType.Age:
|
||||
return "Faded With Age";
|
||||
case AttackType.Starvation:
|
||||
return "Wasted From Hunger";
|
||||
case AttackType.Fire:
|
||||
return lava ? "Swallowed By Lava" : "Burned To Ash";
|
||||
case AttackType.Drowning:
|
||||
case AttackType.Water:
|
||||
return "Lost To The Deep";
|
||||
case AttackType.Poison:
|
||||
return "Poison Took Them";
|
||||
case AttackType.Plague:
|
||||
return "Claimed By Plague";
|
||||
case AttackType.Infection:
|
||||
return "Rotted From Within";
|
||||
case AttackType.Tumor:
|
||||
return "Consumed By A Tumor";
|
||||
case AttackType.Acid:
|
||||
return "Dissolved In Acid";
|
||||
case AttackType.AshFever:
|
||||
return "Taken By Ash Fever";
|
||||
case AttackType.Gravity:
|
||||
return "Fell From The Heights";
|
||||
case AttackType.Explosion:
|
||||
return "Blown Apart";
|
||||
case AttackType.Divine:
|
||||
return "Smote By The Gods";
|
||||
case AttackType.Eaten:
|
||||
return hasKiller ? "Devoured by " + k : "Devoured";
|
||||
case AttackType.Weapon:
|
||||
return hasKiller ? "Slain by " + k : "Fell In Battle";
|
||||
case AttackType.Smile:
|
||||
return hasKiller ? "Slain by " + k : "Died Smiling";
|
||||
case AttackType.Other:
|
||||
return hasKiller ? "Slain by " + k : "Met A Strange End";
|
||||
case AttackType.Metamorphosis:
|
||||
return "Transformed Away";
|
||||
case AttackType.None:
|
||||
return hasKiller ? "Slain by " + k : "Met Their End";
|
||||
default:
|
||||
return hasKiller ? "Slain by " + k : "Dead";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveKillerDisplayName(ChronicleEntry death)
|
||||
{
|
||||
if (death == null || death.OtherId == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
Actor killer = FindUnitById(death.OtherId);
|
||||
if (killer != null)
|
||||
{
|
||||
return SafeName(killer);
|
||||
}
|
||||
|
||||
return TryExtractKillerFromLine(death.Line);
|
||||
}
|
||||
|
||||
private static string TryExtractKillerFromLine(string line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string[] prefixes =
|
||||
{
|
||||
"Was cut down by ",
|
||||
"Was torn apart and eaten by ",
|
||||
"Was slain by ",
|
||||
"Died with a smile at ",
|
||||
"Killed by ",
|
||||
"Eaten by ",
|
||||
"Slain by ",
|
||||
"Devoured by "
|
||||
};
|
||||
for (int i = 0; i < prefixes.Length; i++)
|
||||
{
|
||||
string prefix = prefixes[i];
|
||||
int at = line.IndexOf(prefix, System.StringComparison.OrdinalIgnoreCase);
|
||||
if (at < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string rest = line.Substring(at + prefix.Length).Trim();
|
||||
if (prefix.IndexOf("smile", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
int hand = rest.IndexOf("'s hand", System.StringComparison.OrdinalIgnoreCase);
|
||||
if (hand > 0)
|
||||
{
|
||||
rest = rest.Substring(0, hand).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
int paren = rest.IndexOf(" (", System.StringComparison.Ordinal);
|
||||
if (paren > 0)
|
||||
{
|
||||
rest = rest.Substring(0, paren).Trim();
|
||||
}
|
||||
|
||||
return rest;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string FormatGraveSubtitleFromAttackType(string attackTypeName, string killer)
|
||||
{
|
||||
string t = (attackTypeName ?? "").Trim();
|
||||
if (!string.IsNullOrEmpty(t)
|
||||
&& System.Enum.TryParse(t, ignoreCase: true, out AttackType attackType))
|
||||
{
|
||||
return FormatGraveFlavor(attackType, killer);
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(killer) ? "Dead" : "Slain by " + killer;
|
||||
}
|
||||
|
||||
/// <summary>Newest death event for a subject, if any.</summary>
|
||||
public static ChronicleEntry LatestDeathEntry(long subjectId)
|
||||
{
|
||||
|
|
@ -2038,10 +2162,12 @@ public static class Chronicle
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Camera for a dead subject: living killer if possible, else last death / history position.
|
||||
/// Camera for a dead subject.
|
||||
/// When <paramref name="preferPlace"/> is true (default), pan to death place / skull (no unit follow).
|
||||
/// Killer follow is only via <see cref="FocusFallenKiller"/>.
|
||||
/// Dossier always shows the fallen unit's last living state (not the killer).
|
||||
/// </summary>
|
||||
public static bool FocusFallenSubject(long unitId)
|
||||
public static bool FocusFallenSubject(long unitId, bool preferPlace = true)
|
||||
{
|
||||
LastFallenFocusDetail = "none";
|
||||
LastFallenFocusPosition = Vector3.zero;
|
||||
|
|
@ -2058,7 +2184,7 @@ public static class Chronicle
|
|||
}
|
||||
|
||||
ChronicleEntry death = LatestDeathEntry(unitId);
|
||||
if (death != null && death.OtherId != 0)
|
||||
if (!preferPlace && death != null && death.OtherId != 0)
|
||||
{
|
||||
Actor killer = FindUnitById(death.OtherId);
|
||||
if (killer != null && killer.isAlive())
|
||||
|
|
@ -2071,10 +2197,10 @@ public static class Chronicle
|
|||
}
|
||||
}
|
||||
|
||||
// Place / skull path: never keep following a living unit (e.g. prior killer focus).
|
||||
CameraDirector.ClearFollow();
|
||||
if (TryResolveFallenPlace(unitId, death, out Vector3 pos))
|
||||
{
|
||||
// Drop any prior follow target or the camera eases back away from the death site.
|
||||
CameraDirector.ClearFollow();
|
||||
CameraDirector.PanTo(pos);
|
||||
LastFallenFocusDetail = "place";
|
||||
LastFallenFocusPosition = pos;
|
||||
|
|
@ -2082,9 +2208,57 @@ public static class Chronicle
|
|||
return true;
|
||||
}
|
||||
|
||||
LastFallenFocusDetail = "place";
|
||||
return fallen != null;
|
||||
}
|
||||
|
||||
/// <summary>Living killer for a fallen subject, or null.</summary>
|
||||
public static Actor FindLivingKillerFor(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ChronicleEntry death = LatestDeathEntry(unitId);
|
||||
if (death == null || death.OtherId == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor killer = FindUnitById(death.OtherId);
|
||||
if (killer == null || !killer.isAlive())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return killer;
|
||||
}
|
||||
|
||||
/// <summary>Camera follow on the living killer of a fallen Lore subject.</summary>
|
||||
public static bool FocusFallenKiller(long unitId)
|
||||
{
|
||||
Actor killer = FindLivingKillerFor(unitId);
|
||||
if (killer == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UnitDossier fallen = ResolveFallenDossier(unitId);
|
||||
if (fallen != null)
|
||||
{
|
||||
WatchCaption.PinWhilePaused();
|
||||
WatchCaption.SetFromDossier(fallen);
|
||||
}
|
||||
|
||||
CameraDirector.FocusUnit(killer, updateCaption: false);
|
||||
LastFallenFocusDetail = "killer:" + killer.getID();
|
||||
LastFallenFocusPosition = Vector3.zero;
|
||||
LogService.LogInfo(
|
||||
$"[IdleSpectator][LORE] focus killer id={killer.getID()} victim={unitId}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool EntryHasWorldPosition(ChronicleEntry e)
|
||||
{
|
||||
if (e == null)
|
||||
|
|
@ -2128,62 +2302,75 @@ public static class Chronicle
|
|||
string killerLabel = killer != null
|
||||
? $"{SafeName(killer)} ({SpeciesOf(killer)})"
|
||||
: "";
|
||||
bool hasKiller = !string.IsNullOrEmpty(killerLabel);
|
||||
|
||||
switch (attackType)
|
||||
{
|
||||
case AttackType.Age:
|
||||
return "Died of old age";
|
||||
return "Faded gently into old age";
|
||||
case AttackType.Starvation:
|
||||
return "Starved to death";
|
||||
return "Wasted away from hunger";
|
||||
case AttackType.Fire:
|
||||
return IsOnLava(victim) ? "Burned in lava" : "Burned to death";
|
||||
return IsOnLava(victim)
|
||||
? "Was swallowed by lava"
|
||||
: "Burned until only ash remained";
|
||||
case AttackType.Drowning:
|
||||
case AttackType.Water:
|
||||
return "Drowned";
|
||||
return "Sank beneath the water and never rose";
|
||||
case AttackType.Poison:
|
||||
return "Died of poison";
|
||||
return "Succumbed as poison flooded their veins";
|
||||
case AttackType.Plague:
|
||||
return "Died of plague";
|
||||
return "Was claimed by a spreading plague";
|
||||
case AttackType.Infection:
|
||||
return "Died of infection";
|
||||
return "Rotted from within as infection took hold";
|
||||
case AttackType.Tumor:
|
||||
return "Died of a tumor";
|
||||
return "Was consumed by a growing tumor";
|
||||
case AttackType.Acid:
|
||||
return "Dissolved in acid";
|
||||
return "Dissolved screaming in acid";
|
||||
case AttackType.AshFever:
|
||||
return "Died of ash fever";
|
||||
return "Burned out from ash fever";
|
||||
case AttackType.Gravity:
|
||||
return "Fell to their death";
|
||||
return "Fell from a height and broke upon the ground";
|
||||
case AttackType.Explosion:
|
||||
return "Died in an explosion";
|
||||
return "Was torn apart in an explosion";
|
||||
case AttackType.Divine:
|
||||
return "Struck down by divine power";
|
||||
return "Was smote by divine power";
|
||||
case AttackType.Eaten:
|
||||
return string.IsNullOrEmpty(killerLabel)
|
||||
? "Was eaten"
|
||||
: $"Eaten by {killerLabel}";
|
||||
return hasKiller
|
||||
? $"Was torn apart and eaten by {killerLabel}"
|
||||
: "Was torn apart and eaten";
|
||||
case AttackType.Weapon:
|
||||
case AttackType.Other:
|
||||
return hasKiller
|
||||
? $"Was cut down by {killerLabel}"
|
||||
: "Fell in bitter combat";
|
||||
case AttackType.Smile:
|
||||
if (!string.IsNullOrEmpty(killerLabel))
|
||||
{
|
||||
return $"Killed by {killerLabel}";
|
||||
}
|
||||
|
||||
return attackType == AttackType.Weapon ? "Killed in combat" : "Died";
|
||||
return hasKiller
|
||||
? $"Died with a smile at {killerLabel}'s hand"
|
||||
: "Died with a strange smile";
|
||||
case AttackType.Other:
|
||||
return hasKiller
|
||||
? $"Was slain by {killerLabel}"
|
||||
: "Met a strange and sudden end";
|
||||
case AttackType.Metamorphosis:
|
||||
return "Transformed away";
|
||||
return "Transformed away and left no body behind";
|
||||
case AttackType.None:
|
||||
return "Died";
|
||||
return hasKiller
|
||||
? $"Was slain by {killerLabel}"
|
||||
: "Met their end";
|
||||
default:
|
||||
return string.IsNullOrEmpty(killerLabel)
|
||||
? $"Died ({attackType})"
|
||||
: $"Killed by {killerLabel}";
|
||||
return hasKiller
|
||||
? $"Was slain by {killerLabel}"
|
||||
: $"Died ({attackType})";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsOnLava(Actor victim)
|
||||
{
|
||||
if (victim == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WorldTile tile = victim.current_tile;
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public static class ChronicleHud
|
|||
private const float TabH = 15f;
|
||||
private const float CharToolsH = 17f;
|
||||
private const float ScrollSensitivity = 55f;
|
||||
private const string ChromeMarker = "LoreV6";
|
||||
private const string ChromeMarker = "LoreV10";
|
||||
|
||||
private static GameObject _root;
|
||||
private static RectTransform _rootRt;
|
||||
|
|
@ -50,6 +50,8 @@ public static class ChronicleHud
|
|||
private static Text _hotkeyText;
|
||||
private static Image _titleIcon;
|
||||
private static readonly List<GameObject> Rows = new List<GameObject>();
|
||||
private static readonly List<GraveMarkers.GraveEntry> GraveEntriesScratch =
|
||||
new List<GraveMarkers.GraveEntry>(64);
|
||||
private static bool _visible;
|
||||
private static int _lastCount = -1;
|
||||
private static string _lastAgeId = "";
|
||||
|
|
@ -76,11 +78,24 @@ public static class ChronicleHud
|
|||
private static bool _pausedIdleForLore;
|
||||
private static GameObject _backBtn;
|
||||
private static Text _backBtnLabel;
|
||||
private static GameObject _focusKillerBtn;
|
||||
private static Text _focusKillerLabel;
|
||||
private static GameObject _keepGraveBtn;
|
||||
private static Text _keepGraveLabel;
|
||||
private static GameObject _deleteGraveBtn;
|
||||
private static Text _deleteGraveLabel;
|
||||
private static GameObject _detailPaneBar;
|
||||
private static GameObject _detailActivityBtn;
|
||||
private static GameObject _detailLifeBtn;
|
||||
private static DetailPane _detailPane = DetailPane.Life;
|
||||
|
||||
/// <summary>Lore is browsing one tile's grave stack (from a skull click).</summary>
|
||||
private static bool _graveStackActive;
|
||||
private static int _graveTileX;
|
||||
private static int _graveTileY;
|
||||
private static bool _openingGraveStack;
|
||||
private static float _ignoreDismissUntil;
|
||||
|
||||
/// <summary>Rows actually built in the last character detail rebuild (harness).</summary>
|
||||
public static int LastDetailHistoryRows { get; private set; }
|
||||
|
||||
|
|
@ -109,6 +124,46 @@ public static class ChronicleHud
|
|||
|
||||
public static long DetailUnitId => _detailUnitId;
|
||||
|
||||
/// <summary>True when Lore is scoped to a gravestone stack (skull open).</summary>
|
||||
public static bool GraveStackScopeActive => _graveStackActive;
|
||||
|
||||
public static int GraveStackTileX => _graveTileX;
|
||||
public static int GraveStackTileY => _graveTileY;
|
||||
|
||||
/// <summary>Filtered stack list rows from the last grave-stack rebuild (harness).</summary>
|
||||
public static int GraveStackFilteredCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Grave stack search should span the tools row (Keep/Del live in the title band).
|
||||
/// </summary>
|
||||
public static bool GraveSearchFillsTools
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_graveStackActive || _searchField == null || _charTools == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RectTransform searchRt = _searchField.GetComponent<RectTransform>();
|
||||
RectTransform toolsRt = _charTools.GetComponent<RectTransform>();
|
||||
if (searchRt == null || toolsRt == null || !searchRt.gameObject.activeInHierarchy)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Full stretch within CharTools (no right inset reserved for Refresh/Fav).
|
||||
return Mathf.Abs(searchRt.offsetMax.x) < 0.5f
|
||||
&& searchRt.anchorMin.x <= 0.01f
|
||||
&& searchRt.anchorMax.x >= 0.99f
|
||||
&& toolsRt.rect.width > 1f
|
||||
&& searchRt.rect.width >= toolsRt.rect.width - 1f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ignore outside-click dismiss briefly after skull open / Keep.</summary>
|
||||
public static float IgnoreDismissUntil => _ignoreDismissUntil;
|
||||
|
||||
private static bool IsCastTab => _tab == LoreTab.Living || _tab == LoreTab.Fallen;
|
||||
|
||||
/// <summary>True when Lore detail was opened via L/books and should track idle focus.</summary>
|
||||
|
|
@ -146,12 +201,19 @@ public static class ChronicleHud
|
|||
_searchField = null;
|
||||
_backBtn = null;
|
||||
_backBtnLabel = null;
|
||||
_focusKillerBtn = null;
|
||||
_focusKillerLabel = null;
|
||||
_keepGraveBtn = null;
|
||||
_keepGraveLabel = null;
|
||||
_deleteGraveBtn = null;
|
||||
_deleteGraveLabel = null;
|
||||
_detailPaneBar = null;
|
||||
_detailActivityBtn = null;
|
||||
_detailLifeBtn = null;
|
||||
_detailUnitId = 0;
|
||||
_followFocus = false;
|
||||
_detailPane = DetailPane.Life;
|
||||
ClearGraveStackScope();
|
||||
}
|
||||
|
||||
if (_root != null)
|
||||
|
|
@ -218,6 +280,23 @@ public static class ChronicleHud
|
|||
_titleText.raycastTarget = false;
|
||||
_titleText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
|
||||
// Grave Keep/Del live in the title band so search can use the full tools row.
|
||||
_keepGraveBtn = BuildTabButton(_root.transform, "KeepGrave", "Keep", ToggleKeepGrave);
|
||||
_keepGraveLabel = _keepGraveBtn.transform.Find("Label")?.GetComponent<Text>();
|
||||
PlaceTitleTool(_keepGraveBtn, -58f, 34f);
|
||||
_keepGraveBtn.SetActive(false);
|
||||
|
||||
_deleteGraveBtn = BuildTabButton(
|
||||
_root.transform, "DeleteGrave", "Del", () => { DeleteActiveGraveStack(); });
|
||||
_deleteGraveLabel = _deleteGraveBtn.transform.Find("Label")?.GetComponent<Text>();
|
||||
if (_deleteGraveLabel != null)
|
||||
{
|
||||
_deleteGraveLabel.color = new Color(0.92f, 0.62f, 0.58f, 1f);
|
||||
}
|
||||
|
||||
PlaceTitleTool(_deleteGraveBtn, -22f, 34f);
|
||||
_deleteGraveBtn.SetActive(false);
|
||||
|
||||
BuildTabs();
|
||||
BuildCharTools();
|
||||
BuildScroll();
|
||||
|
|
@ -289,6 +368,21 @@ public static class ChronicleHud
|
|||
rt.offsetMax = new Vector2(index >= count - 1f ? 0f : -1f, 0f);
|
||||
}
|
||||
|
||||
private static void PlaceTitleTool(GameObject btn, float xFromRight, float width)
|
||||
{
|
||||
if (btn == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RectTransform rt = btn.GetComponent<RectTransform>();
|
||||
rt.anchorMin = new Vector2(1f, 1f);
|
||||
rt.anchorMax = new Vector2(1f, 1f);
|
||||
rt.pivot = new Vector2(1f, 1f);
|
||||
rt.sizeDelta = new Vector2(width, 14f);
|
||||
rt.anchoredPosition = new Vector2(xFromRight, -4f);
|
||||
}
|
||||
|
||||
private static void BuildCharTools()
|
||||
{
|
||||
_charTools = new GameObject("CharTools", typeof(RectTransform));
|
||||
|
|
@ -359,15 +453,24 @@ public static class ChronicleHud
|
|||
_backBtnLabel = _backBtn.transform.Find("Label")?.GetComponent<Text>();
|
||||
RectTransform backRt = _backBtn.GetComponent<RectTransform>();
|
||||
backRt.anchorMin = new Vector2(0f, 0f);
|
||||
backRt.anchorMax = new Vector2(0.36f, 1f);
|
||||
backRt.anchorMax = new Vector2(0.28f, 1f);
|
||||
backRt.offsetMin = Vector2.zero;
|
||||
backRt.offsetMax = Vector2.zero;
|
||||
_backBtn.SetActive(false);
|
||||
|
||||
_focusKillerBtn = BuildTabButton(_charTools.transform, "FocusKiller", "Killer", () => FocusDetailKiller());
|
||||
_focusKillerLabel = _focusKillerBtn.transform.Find("Label")?.GetComponent<Text>();
|
||||
RectTransform killerRt = _focusKillerBtn.GetComponent<RectTransform>();
|
||||
killerRt.anchorMin = new Vector2(0.29f, 0f);
|
||||
killerRt.anchorMax = new Vector2(0.48f, 1f);
|
||||
killerRt.offsetMin = Vector2.zero;
|
||||
killerRt.offsetMax = Vector2.zero;
|
||||
_focusKillerBtn.SetActive(false);
|
||||
|
||||
_detailPaneBar = new GameObject("DetailPanes", typeof(RectTransform));
|
||||
_detailPaneBar.transform.SetParent(_charTools.transform, false);
|
||||
RectTransform paneRt = _detailPaneBar.GetComponent<RectTransform>();
|
||||
paneRt.anchorMin = new Vector2(0.38f, 0f);
|
||||
paneRt.anchorMin = new Vector2(0.50f, 0f);
|
||||
paneRt.anchorMax = new Vector2(1f, 1f);
|
||||
paneRt.offsetMin = Vector2.zero;
|
||||
paneRt.offsetMax = Vector2.zero;
|
||||
|
|
@ -590,7 +693,8 @@ public static class ChronicleHud
|
|||
/// </summary>
|
||||
public static bool ShouldAbsorbScrollZoom()
|
||||
{
|
||||
return IsPointerOverPanel() || WatchCaption.IsPointerOverPanel();
|
||||
return IsPointerOverPanel()
|
||||
|| WatchCaption.IsPointerOverPanel();
|
||||
}
|
||||
|
||||
public static bool IsPointerOverPanel()
|
||||
|
|
@ -648,6 +752,7 @@ public static class ChronicleHud
|
|||
}
|
||||
else
|
||||
{
|
||||
ClearGraveStackScope();
|
||||
if (_searchField != null && EventSystem.current != null
|
||||
&& EventSystem.current.currentSelectedGameObject == _searchField.gameObject)
|
||||
{
|
||||
|
|
@ -704,6 +809,7 @@ public static class ChronicleHud
|
|||
public static void OpenSyncedFromFocus()
|
||||
{
|
||||
EnsureBuilt();
|
||||
ClearGraveStackScope();
|
||||
long id = WatchCaption.CurrentUnitId;
|
||||
if (id == 0)
|
||||
{
|
||||
|
|
@ -725,6 +831,11 @@ public static class ChronicleHud
|
|||
public static void SetTab(LoreTab tab)
|
||||
{
|
||||
EnsureBuilt();
|
||||
if (!_openingGraveStack)
|
||||
{
|
||||
ClearGraveStackScope();
|
||||
}
|
||||
|
||||
if (tab == LoreTab.World)
|
||||
{
|
||||
_detailUnitId = 0;
|
||||
|
|
@ -743,11 +854,149 @@ public static class ChronicleHud
|
|||
Rebuild(force: true);
|
||||
}
|
||||
|
||||
/// <summary>Open Lore scoped to one gravestone tile (skull click / harness).</summary>
|
||||
public static bool OpenGraveStack(int tileX, int tileY, bool pauseIdle = true)
|
||||
{
|
||||
if (!GraveMarkers.TryGetStack(tileX, tileY, out GraveMarkers.GraveStack stack)
|
||||
|| stack.Entries.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureBuilt();
|
||||
_openingGraveStack = true;
|
||||
try
|
||||
{
|
||||
InspectUi.MarkGraveSession();
|
||||
_graveStackActive = true;
|
||||
_graveTileX = tileX;
|
||||
_graveTileY = tileY;
|
||||
_ignoreDismissUntil = Time.unscaledTime + 0.4f;
|
||||
_searchText = "";
|
||||
if (_searchField != null)
|
||||
{
|
||||
_searchField.SetTextWithoutNotify("");
|
||||
}
|
||||
|
||||
if (pauseIdle && SpectatorMode.Active)
|
||||
{
|
||||
_pausedIdleForLore = true;
|
||||
SpectatorMode.SetActive(false, quiet: true);
|
||||
}
|
||||
else if (pauseIdle)
|
||||
{
|
||||
_pausedIdleForLore = false;
|
||||
}
|
||||
|
||||
CameraDirector.ClearFollow();
|
||||
CameraDirector.PanTo(stack.WorldPos);
|
||||
WatchCaption.PinWhilePaused();
|
||||
if (pauseIdle)
|
||||
{
|
||||
WatchCaption.ShowStatusBanner("Paused (viewing grave)");
|
||||
}
|
||||
|
||||
if (stack.Entries.Count == 1)
|
||||
{
|
||||
long id = stack.Entries[stack.Entries.Count - 1].UnitId;
|
||||
OpenUnitHistory(id, pauseIdle: false, followFocus: false, preferPlace: true);
|
||||
_graveStackActive = true;
|
||||
_graveTileX = tileX;
|
||||
_graveTileY = tileY;
|
||||
ApplyTabChrome();
|
||||
return ChronicleHud.Visible && DetailUnitId == id;
|
||||
}
|
||||
|
||||
_detailUnitId = 0;
|
||||
_followFocus = false;
|
||||
_detailPane = DetailPane.Life;
|
||||
_tab = LoreTab.Fallen;
|
||||
_lastCharFingerprint = int.MinValue;
|
||||
SetVisible(true);
|
||||
ApplyTabChrome();
|
||||
Rebuild(force: true);
|
||||
return Visible && GraveStackScopeActive && DetailUnitId == 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_openingGraveStack = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearGraveStackScope()
|
||||
{
|
||||
_graveStackActive = false;
|
||||
_graveTileX = 0;
|
||||
_graveTileY = 0;
|
||||
GraveStackFilteredCount = 0;
|
||||
}
|
||||
|
||||
private static void ToggleKeepGrave()
|
||||
{
|
||||
if (!_graveStackActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool next = !GraveMarkers.IsStackPermanent(_graveTileX, _graveTileY);
|
||||
if (!GraveMarkers.TrySetStackPermanent(_graveTileX, _graveTileY, next))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ignoreDismissUntil = Time.unscaledTime + 0.4f;
|
||||
RefreshKeepGraveVisual();
|
||||
}
|
||||
|
||||
/// <summary>Remove the open grave stack from the world and close Lore.</summary>
|
||||
public static bool DeleteActiveGraveStack()
|
||||
{
|
||||
if (!_graveStackActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int tx = _graveTileX;
|
||||
int ty = _graveTileY;
|
||||
if (!GraveMarkers.TryRemoveStack(tx, ty))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_ignoreDismissUntil = Time.unscaledTime + 0.4f;
|
||||
LogService.LogInfo($"[IdleSpectator][LORE] deleted grave tile={tx},{ty}");
|
||||
InspectUi.DismissAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RefreshKeepGraveVisual()
|
||||
{
|
||||
if (_keepGraveLabel == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool kept = _graveStackActive && GraveMarkers.IsStackPermanent(_graveTileX, _graveTileY);
|
||||
_keepGraveLabel.text = kept ? "Kept" : "Keep";
|
||||
Image bg = _keepGraveBtn != null ? _keepGraveBtn.GetComponent<Image>() : null;
|
||||
if (bg != null)
|
||||
{
|
||||
bg.color = kept
|
||||
? new Color(0.45f, 0.36f, 0.12f, 0.95f)
|
||||
: new Color(0.12f, 0.13f, 0.16f, 0.95f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show one unit's full chronicle in Lore (Living or Fallen tab).
|
||||
/// <paramref name="followFocus"/> true (L/books) retargets with idle; false (list) pins the archive.
|
||||
/// Fallen subjects always open on death place / skull; use Focus Killer for the killer.
|
||||
/// </summary>
|
||||
public static void OpenUnitHistory(long unitId, bool pauseIdle = true, bool followFocus = false)
|
||||
public static void OpenUnitHistory(
|
||||
long unitId,
|
||||
bool pauseIdle = true,
|
||||
bool followFocus = false,
|
||||
bool preferPlace = true)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
|
|
@ -775,7 +1024,8 @@ public static class ChronicleHud
|
|||
bool live = Chronicle.FocusCameraForBrowse(unitId);
|
||||
if (!live)
|
||||
{
|
||||
Chronicle.FocusFallenSubject(unitId);
|
||||
// Default preferPlace=true: death place / skull. Killer only via Focus Killer.
|
||||
Chronicle.FocusFallenSubject(unitId, preferPlace);
|
||||
}
|
||||
|
||||
_tab = live ? LoreTab.Living : LoreTab.Fallen;
|
||||
|
|
@ -792,6 +1042,24 @@ public static class ChronicleHud
|
|||
Rebuild(force: true);
|
||||
}
|
||||
|
||||
/// <summary>Harness / Lore chrome: follow the living killer of the open fallen subject.</summary>
|
||||
public static bool FocusDetailKiller()
|
||||
{
|
||||
if (_detailUnitId == 0 || _tab != LoreTab.Fallen)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = Chronicle.FocusFallenKiller(_detailUnitId);
|
||||
if (ok)
|
||||
{
|
||||
WatchCaption.ShowStatusBanner(Chronicle.FallenPauseBanner());
|
||||
}
|
||||
|
||||
ApplyTabChrome();
|
||||
return ok;
|
||||
}
|
||||
|
||||
public static void ClearUnitDetail()
|
||||
{
|
||||
_detailUnitId = 0;
|
||||
|
|
@ -951,23 +1219,43 @@ public static class ChronicleHud
|
|||
}
|
||||
}
|
||||
|
||||
bool grave = _graveStackActive && _tab == LoreTab.Fallen;
|
||||
|
||||
if (_searchField != null)
|
||||
{
|
||||
_searchField.gameObject.SetActive(chars && !detail);
|
||||
if (chars && !detail)
|
||||
{
|
||||
RectTransform searchRt = _searchField.GetComponent<RectTransform>();
|
||||
if (searchRt != null)
|
||||
{
|
||||
// Living/Fallen: leave room for Refresh+Fav. Grave: full width (Keep/Del in title).
|
||||
searchRt.offsetMax = new Vector2(grave ? 0f : -40f, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_titleText != null)
|
||||
{
|
||||
RectTransform titleRt = _titleText.GetComponent<RectTransform>();
|
||||
if (titleRt != null)
|
||||
{
|
||||
// Leave room for Keep+Del+L when grave-scoped.
|
||||
titleRt.offsetMax = new Vector2(grave ? -96f : -22f, -2f);
|
||||
}
|
||||
}
|
||||
|
||||
if (_favFilterBtn != null)
|
||||
{
|
||||
_favFilterBtn.gameObject.SetActive(chars && !detail);
|
||||
_favFilterBtn.gameObject.SetActive(chars && !detail && !grave);
|
||||
}
|
||||
|
||||
if (_refreshBtn != null)
|
||||
{
|
||||
_refreshBtn.gameObject.SetActive(chars && !detail);
|
||||
_refreshBtn.gameObject.SetActive(chars && !detail && !grave);
|
||||
RectTransform refreshRt = _refreshBtn.GetComponent<RectTransform>();
|
||||
if (refreshRt != null)
|
||||
{
|
||||
// Search | Refresh | Fav on both Living and Fallen.
|
||||
refreshRt.anchoredPosition = new Vector2(-19f, 0f);
|
||||
}
|
||||
}
|
||||
|
|
@ -976,14 +1264,63 @@ public static class ChronicleHud
|
|||
{
|
||||
_backBtn.SetActive(detail);
|
||||
if (_backBtnLabel != null)
|
||||
{
|
||||
if (grave)
|
||||
{
|
||||
_backBtnLabel.text = "← Grave";
|
||||
}
|
||||
else
|
||||
{
|
||||
_backBtnLabel.text = _tab == LoreTab.Fallen ? "← Fallen" : "← Living";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_focusKillerBtn != null)
|
||||
{
|
||||
bool showKiller = detail
|
||||
&& _tab == LoreTab.Fallen
|
||||
&& Chronicle.FindLivingKillerFor(_detailUnitId) != null;
|
||||
_focusKillerBtn.SetActive(showKiller);
|
||||
if (_focusKillerLabel != null)
|
||||
{
|
||||
_focusKillerLabel.text = "Killer";
|
||||
}
|
||||
}
|
||||
|
||||
if (_keepGraveBtn != null)
|
||||
{
|
||||
_keepGraveBtn.SetActive(grave);
|
||||
if (grave)
|
||||
{
|
||||
PlaceTitleTool(_keepGraveBtn, -58f, 34f);
|
||||
RefreshKeepGraveVisual();
|
||||
}
|
||||
}
|
||||
|
||||
if (_deleteGraveBtn != null)
|
||||
{
|
||||
_deleteGraveBtn.SetActive(grave);
|
||||
if (grave)
|
||||
{
|
||||
PlaceTitleTool(_deleteGraveBtn, -22f, 34f);
|
||||
}
|
||||
}
|
||||
|
||||
if (_detailPaneBar != null)
|
||||
{
|
||||
_detailPaneBar.SetActive(detail);
|
||||
if (detail)
|
||||
{
|
||||
RectTransform paneRt = _detailPaneBar.GetComponent<RectTransform>();
|
||||
if (paneRt != null)
|
||||
{
|
||||
// Full Activity|Life strip; Keep/Del sit in the title band.
|
||||
paneRt.anchorMin = new Vector2(0.50f, 0f);
|
||||
paneRt.anchorMax = new Vector2(1f, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
StyleDetailPanes();
|
||||
}
|
||||
|
||||
|
|
@ -1017,6 +1354,13 @@ public static class ChronicleHud
|
|||
return;
|
||||
}
|
||||
|
||||
if (_graveStackActive && _tab == LoreTab.Fallen)
|
||||
{
|
||||
int n = GraveMarkers.EntryCountAt(_graveTileX, _graveTileY);
|
||||
_titleText.text = n <= 1 ? "Grave" : $"Grave · {n}";
|
||||
return;
|
||||
}
|
||||
|
||||
_titleText.text = _tab == LoreTab.Fallen ? "Fallen" : "Living";
|
||||
return;
|
||||
}
|
||||
|
|
@ -1201,6 +1545,10 @@ public static class ChronicleHud
|
|||
hash = (hash * 31) + (_searchText ?? "").GetHashCode();
|
||||
hash = (hash * 31) + _detailUnitId.GetHashCode();
|
||||
hash = (hash * 31) + (int)_tab;
|
||||
hash = (hash * 31) + (_graveStackActive ? 1 : 0);
|
||||
hash = (hash * 31) + _graveTileX;
|
||||
hash = (hash * 31) + _graveTileY;
|
||||
hash = (hash * 31) + GraveMarkers.EntryCountAt(_graveTileX, _graveTileY);
|
||||
// Cheap dirty bits - never rescan all subjects every frame.
|
||||
hash = (hash * 31) + Chronicle.HistoryRevision;
|
||||
hash = (hash * 31) + Chronicle.HistorySubjectCount;
|
||||
|
|
@ -1291,6 +1639,12 @@ public static class ChronicleHud
|
|||
return;
|
||||
}
|
||||
|
||||
if (_graveStackActive && _tab == LoreTab.Fallen)
|
||||
{
|
||||
RebuildGraveStackList();
|
||||
return;
|
||||
}
|
||||
|
||||
Chronicle.CharacterListScope scope = _tab == LoreTab.Fallen
|
||||
? Chronicle.CharacterListScope.Fallen
|
||||
: Chronicle.CharacterListScope.Living;
|
||||
|
|
@ -1299,6 +1653,7 @@ public static class ChronicleHud
|
|||
_lastCharFingerprint = CharacterFingerprint();
|
||||
_builtAtRevision = Chronicle.HistoryRevision;
|
||||
RefreshStaleVisual();
|
||||
GraveStackFilteredCount = 0;
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
|
|
@ -1343,6 +1698,74 @@ public static class ChronicleHud
|
|||
}
|
||||
}
|
||||
|
||||
private static void RebuildGraveStackList()
|
||||
{
|
||||
RefreshTitle();
|
||||
LastListTopTitle = "";
|
||||
GraveStackFilteredCount = 0;
|
||||
if (!GraveMarkers.TryGetStack(_graveTileX, _graveTileY, out GraveMarkers.GraveStack stack))
|
||||
{
|
||||
ClearGraveStackScope();
|
||||
AddEmpty("Grave gone");
|
||||
return;
|
||||
}
|
||||
|
||||
GraveMarkers.SnapshotEntries(stack, GraveEntriesScratch);
|
||||
string q = (_searchText ?? "").Trim();
|
||||
_lastCharFingerprint = CharacterFingerprint();
|
||||
_builtAtRevision = Chronicle.HistoryRevision;
|
||||
RefreshStaleVisual();
|
||||
|
||||
int shown = 0;
|
||||
// Newest first (same order as the old grave panel).
|
||||
for (int i = GraveEntriesScratch.Count - 1; i >= 0; i--)
|
||||
{
|
||||
GraveMarkers.GraveEntry e = GraveEntriesScratch[i];
|
||||
if (e == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (q.Length > 0
|
||||
&& (e.DisplayName == null
|
||||
|| e.DisplayName.IndexOf(q, System.StringComparison.OrdinalIgnoreCase) < 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var row = new ChronicleCharacterSummary
|
||||
{
|
||||
UnitId = e.UnitId,
|
||||
Name = e.DisplayName ?? "",
|
||||
SpeciesId = "",
|
||||
HistoryCount = Chronicle.HistoryCountFor(e.UnitId),
|
||||
IsFavorite = e.Favorite,
|
||||
IsAlive = false,
|
||||
DeathManner = Chronicle.LatestDeathMannerFor(e.UnitId),
|
||||
SortTime = e.CreatedAt
|
||||
};
|
||||
if (shown == 0)
|
||||
{
|
||||
LastListTopTitle = row.Title;
|
||||
}
|
||||
|
||||
GameObject go = BuildCharacterRow(row, shown);
|
||||
go.transform.SetParent(_content, false);
|
||||
Rows.Add(go);
|
||||
shown++;
|
||||
if (shown >= MaxVisibleRows)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
GraveStackFilteredCount = shown;
|
||||
if (shown == 0)
|
||||
{
|
||||
AddEmpty(q.Length > 0 ? "No names match" : "Empty grave");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RebuildActivityDetail(long unitId)
|
||||
{
|
||||
IReadOnlyList<ActivityEntry> entries =
|
||||
|
|
@ -1748,8 +2171,13 @@ public static class ChronicleHud
|
|||
string title = summary.Title;
|
||||
if (!summary.IsAlive && !string.IsNullOrEmpty(title))
|
||||
{
|
||||
string manner = summary.DeathLabel;
|
||||
title += string.IsNullOrEmpty(manner) ? " · fallen" : " · " + manner;
|
||||
string manner = Chronicle.FormatGraveDeathSubtitle(summary.UnitId);
|
||||
if (string.IsNullOrEmpty(manner))
|
||||
{
|
||||
manner = summary.DeathLabel;
|
||||
}
|
||||
|
||||
title += string.IsNullOrEmpty(manner) ? " · Fallen" : " · " + manner;
|
||||
}
|
||||
|
||||
Text label = HudCanvas.MakeText(go.transform, "Text", title, 8);
|
||||
|
|
|
|||
88
IdleSpectator/GraveCountBadges.cs
Normal file
88
IdleSpectator/GraveCountBadges.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Screen-space count badges over multi-entry grave stacks (pooled, on-screen only).
|
||||
/// </summary>
|
||||
public static class GraveCountBadges
|
||||
{
|
||||
private const int PoolSize = 64;
|
||||
private static readonly List<GraveMarkers.GraveStack> Scratch = new List<GraveMarkers.GraveStack>(256);
|
||||
private static readonly List<Badge> Pool = new List<Badge>(PoolSize);
|
||||
private static Transform _root;
|
||||
private static bool _built;
|
||||
|
||||
private sealed class Badge
|
||||
{
|
||||
public GameObject Go;
|
||||
public RectTransform Rt;
|
||||
public Text Label;
|
||||
}
|
||||
|
||||
/// <summary>Badges retired - stack size lives in Lore title. Kept so old pools hide cleanly.</summary>
|
||||
public static void Hide()
|
||||
{
|
||||
for (int i = 0; i < Pool.Count; i++)
|
||||
{
|
||||
if (Pool[i].Go != null)
|
||||
{
|
||||
Pool[i].Go.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Update()
|
||||
{
|
||||
Hide();
|
||||
}
|
||||
|
||||
private static void EnsureBuilt()
|
||||
{
|
||||
if (_built)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Canvas canvas = HudCanvas.Resolve();
|
||||
if (canvas == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject rootGo = new GameObject("IdleGraveCountBadges", typeof(RectTransform));
|
||||
rootGo.transform.SetParent(canvas.transform, false);
|
||||
_root = rootGo.transform;
|
||||
RectTransform rootRt = rootGo.GetComponent<RectTransform>();
|
||||
rootRt.anchorMin = Vector2.zero;
|
||||
rootRt.anchorMax = Vector2.one;
|
||||
rootRt.offsetMin = Vector2.zero;
|
||||
rootRt.offsetMax = Vector2.zero;
|
||||
|
||||
for (int i = 0; i < PoolSize; i++)
|
||||
{
|
||||
GameObject go = new GameObject("Badge" + i, typeof(RectTransform), typeof(Image));
|
||||
go.transform.SetParent(_root, false);
|
||||
RectTransform rt = go.GetComponent<RectTransform>();
|
||||
rt.sizeDelta = new Vector2(14f, 10f);
|
||||
Image img = go.GetComponent<Image>();
|
||||
img.color = new Color(0.08f, 0.09f, 0.11f, 0.85f);
|
||||
img.raycastTarget = false;
|
||||
Text label = HudCanvas.MakeText(go.transform, "N", "2", 8);
|
||||
RectTransform lrt = label.GetComponent<RectTransform>();
|
||||
lrt.anchorMin = Vector2.zero;
|
||||
lrt.anchorMax = Vector2.one;
|
||||
lrt.offsetMin = Vector2.zero;
|
||||
lrt.offsetMax = Vector2.zero;
|
||||
label.alignment = TextAnchor.MiddleCenter;
|
||||
label.color = new Color(0.95f, 0.85f, 0.55f, 1f);
|
||||
label.raycastTarget = false;
|
||||
go.SetActive(false);
|
||||
Pool.Add(new Badge { Go = go, Rt = rt, Label = label });
|
||||
}
|
||||
|
||||
_built = true;
|
||||
}
|
||||
}
|
||||
108
IdleSpectator/GraveHover.cs
Normal file
108
IdleSpectator/GraveHover.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// World skull hover highlight + click → Lore (no separate grave window).
|
||||
/// </summary>
|
||||
public static class GraveHover
|
||||
{
|
||||
private const float HitRadius = 1.35f;
|
||||
|
||||
/// <summary>Stack key under mouse - drives skull hover flicker.</summary>
|
||||
public static long HighlightStackKey { get; private set; }
|
||||
|
||||
public static void Update()
|
||||
{
|
||||
HighlightStackKey = 0;
|
||||
if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (GraveMarkers.StackCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool overUi = false;
|
||||
try
|
||||
{
|
||||
overUi = World.world != null && World.world.isOverUI();
|
||||
}
|
||||
catch
|
||||
{
|
||||
overUi = false;
|
||||
}
|
||||
|
||||
if (overUi
|
||||
&& !ChronicleHud.IsPointerOverPanel()
|
||||
&& !WatchCaption.IsPointerOverPanel())
|
||||
{
|
||||
// Pointer on other game UI - no hover highlight / click.
|
||||
}
|
||||
else
|
||||
{
|
||||
GraveMarkers.GraveStack hover = ResolveHoverStack();
|
||||
if (hover != null)
|
||||
{
|
||||
HighlightStackKey = hover.Key;
|
||||
}
|
||||
}
|
||||
|
||||
HandleWorldClick();
|
||||
}
|
||||
|
||||
private static void HandleWorldClick()
|
||||
{
|
||||
if (!InputHelpers.GetMouseButtonDown(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ChronicleHud.IsPointerOverPanel() || WatchCaption.IsPointerOverInteractive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (World.world != null && World.world.isOverUI())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GraveMarkers.GraveStack stack = ResolveHoverStack();
|
||||
if (stack == null || stack.Entries.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InspectUi.MarkGraveSession();
|
||||
ChronicleHud.OpenGraveStack(stack.TileX, stack.TileY, pauseIdle: true);
|
||||
}
|
||||
|
||||
private static GraveMarkers.GraveStack ResolveHoverStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
WorldTile tile = World.world.getMouseTilePosCachedFrame() ?? World.world.getMouseTilePos();
|
||||
if (tile != null && GraveMarkers.TryGetStack(tile.x, tile.y, out GraveMarkers.GraveStack atTile))
|
||||
{
|
||||
return atTile;
|
||||
}
|
||||
|
||||
Vector2 mouse = World.world.getMousePos();
|
||||
return GraveMarkers.FindNearestStack(mouse, HitRadius);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
688
IdleSpectator/GraveMarkers.cs
Normal file
688
IdleSpectator/GraveMarkers.cs
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
using System.Collections.Generic;
|
||||
using NeoModLoader.services;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Session gravestone stacks keyed by world tile. Drawn via QuantumSprites; click opens Lore.
|
||||
/// </summary>
|
||||
public static class GraveMarkers
|
||||
{
|
||||
public const int PerStackSoftCap = 40;
|
||||
public const string QuantumAssetId = "idle_spectator_gravestones";
|
||||
/// <summary>World skull size - small tile marker, still hover/clickable.</summary>
|
||||
public const float SkullBaseScale = 0.055f;
|
||||
|
||||
public sealed class GraveEntry
|
||||
{
|
||||
public long UnitId;
|
||||
public string DisplayName = "";
|
||||
public string MannerLabel = "";
|
||||
public float ExpireAt;
|
||||
public bool Favorite;
|
||||
public float CreatedAt;
|
||||
}
|
||||
|
||||
public sealed class GraveStack
|
||||
{
|
||||
public int TileX;
|
||||
public int TileY;
|
||||
public Vector3 WorldPos;
|
||||
public readonly List<GraveEntry> Entries = new List<GraveEntry>();
|
||||
public float LastActivityAt;
|
||||
public bool HasFavorite;
|
||||
/// <summary>Kept via Lore Keep button - skips TTL; gold skull.</summary>
|
||||
public bool Permanent;
|
||||
|
||||
public long Key => PackKey(TileX, TileY);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<long, GraveStack> Stacks = new Dictionary<long, GraveStack>();
|
||||
private static readonly List<long> EvictScratch = new List<long>(64);
|
||||
private static Sprite _skullSprite;
|
||||
private static bool _assetRegistered;
|
||||
private static Color _tempTint = new Color(0.85f, 0.88f, 0.92f, 0.95f);
|
||||
private static Color _permTint = new Color(0.92f, 0.78f, 0.42f, 0.98f);
|
||||
|
||||
public static int StackCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Stacks)
|
||||
{
|
||||
return Stacks.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
lock (Stacks)
|
||||
{
|
||||
Stacks.Clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static void Update()
|
||||
{
|
||||
if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled)
|
||||
{
|
||||
if (StackCount > 0)
|
||||
{
|
||||
Clear();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureQuantumAsset();
|
||||
Prune();
|
||||
GraveHover.Update();
|
||||
// Stack size is in the Lore title (Grave · N); no world count badges.
|
||||
GraveCountBadges.Hide();
|
||||
}
|
||||
|
||||
public static void AddDeath(Actor victim)
|
||||
{
|
||||
if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled || victim == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ModSettings.GravestoneMaxStacks <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long unitId = 0;
|
||||
string name = "Nameless";
|
||||
bool favorite = false;
|
||||
Vector3 pos = Vector3.zero;
|
||||
int tileX = 0;
|
||||
int tileY = 0;
|
||||
try
|
||||
{
|
||||
unitId = victim.getID();
|
||||
name = victim.getName();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
name = "Nameless";
|
||||
}
|
||||
|
||||
favorite = victim.isFavorite();
|
||||
if (victim.current_tile != null)
|
||||
{
|
||||
tileX = victim.current_tile.x;
|
||||
tileY = victim.current_tile.y;
|
||||
pos = victim.current_tile.posV3;
|
||||
}
|
||||
else
|
||||
{
|
||||
pos = victim.current_position;
|
||||
tileX = Mathf.FloorToInt(pos.x);
|
||||
tileY = Mathf.FloorToInt(pos.y);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (unitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string mannerLabel = Chronicle.FormatGraveDeathSubtitle(unitId);
|
||||
AddEntry(tileX, tileY, pos, unitId, name, mannerLabel, favorite);
|
||||
}
|
||||
|
||||
/// <summary>Harness / orphan path: place a grave entry at an explicit tile.</summary>
|
||||
public static bool ForceAdd(
|
||||
long unitId,
|
||||
string displayName,
|
||||
int tileX,
|
||||
int tileY,
|
||||
Vector3 worldPos,
|
||||
string mannerLabel = "",
|
||||
bool favorite = false)
|
||||
{
|
||||
if (unitId == 0 || ModSettings.GravestoneMaxStacks <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (worldPos.sqrMagnitude < 0.0001f)
|
||||
{
|
||||
worldPos = new Vector3(tileX + 0.5f, tileY + 0.5f, 0f);
|
||||
}
|
||||
|
||||
AddEntry(
|
||||
tileX,
|
||||
tileY,
|
||||
worldPos,
|
||||
unitId,
|
||||
string.IsNullOrEmpty(displayName) ? "Nameless" : displayName,
|
||||
mannerLabel ?? "",
|
||||
favorite);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static int EntryCountAt(int tileX, int tileY)
|
||||
{
|
||||
long key = PackKey(tileX, tileY);
|
||||
lock (Stacks)
|
||||
{
|
||||
return Stacks.TryGetValue(key, out GraveStack stack) ? stack.Entries.Count : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetStack(int tileX, int tileY, out GraveStack stack)
|
||||
{
|
||||
long key = PackKey(tileX, tileY);
|
||||
lock (Stacks)
|
||||
{
|
||||
if (Stacks.TryGetValue(key, out stack))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
stack = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryGetStackAtWorld(Vector3 worldPos, out GraveStack stack)
|
||||
{
|
||||
int tileX = Mathf.FloorToInt(worldPos.x);
|
||||
int tileY = Mathf.FloorToInt(worldPos.y);
|
||||
return TryGetStack(tileX, tileY, out stack);
|
||||
}
|
||||
|
||||
public static GraveStack FindNearestStack(Vector2 worldPos, float maxDist)
|
||||
{
|
||||
float maxSq = maxDist * maxDist;
|
||||
GraveStack best = null;
|
||||
float bestSq = maxSq;
|
||||
lock (Stacks)
|
||||
{
|
||||
foreach (GraveStack stack in Stacks.Values)
|
||||
{
|
||||
float dx = stack.WorldPos.x - worldPos.x;
|
||||
float dy = stack.WorldPos.y - worldPos.y;
|
||||
float sq = dx * dx + dy * dy;
|
||||
if (sq <= bestSq)
|
||||
{
|
||||
bestSq = sq;
|
||||
best = stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Newest matching grave entry by display name (any stack).</summary>
|
||||
public static bool TryFindEntryByName(string nameNeedle, out GraveEntry entry, out GraveStack stack)
|
||||
{
|
||||
entry = null;
|
||||
stack = null;
|
||||
if (string.IsNullOrEmpty(nameNeedle))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (Stacks)
|
||||
{
|
||||
foreach (GraveStack s in Stacks.Values)
|
||||
{
|
||||
for (int i = s.Entries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
GraveEntry e = s.Entries[i];
|
||||
if (e.DisplayName != null
|
||||
&& e.DisplayName.IndexOf(nameNeedle, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
entry = e;
|
||||
stack = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void CopyStacks(List<GraveStack> into)
|
||||
{
|
||||
into.Clear();
|
||||
lock (Stacks)
|
||||
{
|
||||
foreach (GraveStack stack in Stacks.Values)
|
||||
{
|
||||
into.Add(stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void SnapshotEntries(GraveStack stack, List<GraveEntry> into)
|
||||
{
|
||||
into.Clear();
|
||||
if (stack == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (Stacks)
|
||||
{
|
||||
for (int i = 0; i < stack.Entries.Count; i++)
|
||||
{
|
||||
into.Add(stack.Entries[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Color TempTint => _tempTint;
|
||||
|
||||
public static Color PermTint => _permTint;
|
||||
|
||||
public static bool TrySetStackPermanent(int tileX, int tileY, bool permanent)
|
||||
{
|
||||
long key = PackKey(tileX, tileY);
|
||||
float now = Time.unscaledTime;
|
||||
float ttl = ModSettings.GravestoneTtlSeconds;
|
||||
lock (Stacks)
|
||||
{
|
||||
if (!Stacks.TryGetValue(key, out GraveStack stack))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
stack.Permanent = permanent;
|
||||
stack.LastActivityAt = now;
|
||||
for (int i = 0; i < stack.Entries.Count; i++)
|
||||
{
|
||||
stack.Entries[i].ExpireAt = permanent
|
||||
? float.PositiveInfinity
|
||||
: now + ttl;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsStackPermanent(int tileX, int tileY)
|
||||
{
|
||||
long key = PackKey(tileX, tileY);
|
||||
lock (Stacks)
|
||||
{
|
||||
return Stacks.TryGetValue(key, out GraveStack stack) && stack.Permanent;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Remove one tile stack (Delete in Lore). Returns false if missing.</summary>
|
||||
public static bool TryRemoveStack(int tileX, int tileY)
|
||||
{
|
||||
long key = PackKey(tileX, tileY);
|
||||
lock (Stacks)
|
||||
{
|
||||
return Stacks.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public static Sprite SkullSprite
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_skullSprite == null)
|
||||
{
|
||||
_skullSprite = SpriteTextureLoader.getSprite("ui/Icons/iconSkulls")
|
||||
?? SpriteTextureLoader.getSprite("ui/Icons/iconDead")
|
||||
?? SpriteTextureLoader.getSprite("ui/icons/iconSkulls");
|
||||
}
|
||||
|
||||
return _skullSprite;
|
||||
}
|
||||
}
|
||||
|
||||
public static long PackKey(int tileX, int tileY)
|
||||
{
|
||||
return ((long)tileX << 32) ^ (uint)tileY;
|
||||
}
|
||||
|
||||
private static void AddEntry(
|
||||
int tileX,
|
||||
int tileY,
|
||||
Vector3 worldPos,
|
||||
long unitId,
|
||||
string displayName,
|
||||
string mannerLabel,
|
||||
bool favorite)
|
||||
{
|
||||
float now = Time.unscaledTime;
|
||||
float ttl = ModSettings.GravestoneTtlSeconds;
|
||||
long key = PackKey(tileX, tileY);
|
||||
|
||||
lock (Stacks)
|
||||
{
|
||||
if (!Stacks.TryGetValue(key, out GraveStack stack))
|
||||
{
|
||||
stack = new GraveStack
|
||||
{
|
||||
TileX = tileX,
|
||||
TileY = tileY,
|
||||
WorldPos = worldPos.sqrMagnitude > 0.0001f
|
||||
? worldPos
|
||||
: new Vector3(tileX + 0.5f, tileY + 0.5f, 0f),
|
||||
LastActivityAt = now
|
||||
};
|
||||
Stacks[key] = stack;
|
||||
}
|
||||
else if (worldPos.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
stack.WorldPos = worldPos;
|
||||
}
|
||||
|
||||
float expireAt = stack.Permanent ? float.PositiveInfinity : now + ttl;
|
||||
|
||||
// Refresh existing entry for same unit if re-added.
|
||||
for (int i = 0; i < stack.Entries.Count; i++)
|
||||
{
|
||||
if (stack.Entries[i].UnitId == unitId)
|
||||
{
|
||||
stack.Entries.RemoveAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stack.Entries.Add(new GraveEntry
|
||||
{
|
||||
UnitId = unitId,
|
||||
DisplayName = displayName,
|
||||
MannerLabel = mannerLabel ?? "",
|
||||
ExpireAt = expireAt,
|
||||
Favorite = favorite,
|
||||
CreatedAt = now
|
||||
});
|
||||
stack.LastActivityAt = now;
|
||||
while (stack.Entries.Count > PerStackSoftCap)
|
||||
{
|
||||
stack.Entries.RemoveAt(0);
|
||||
}
|
||||
|
||||
RefreshFavoriteFlag(stack);
|
||||
EnforceStackCapUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Prune()
|
||||
{
|
||||
float now = Time.unscaledTime;
|
||||
lock (Stacks)
|
||||
{
|
||||
EvictScratch.Clear();
|
||||
foreach (KeyValuePair<long, GraveStack> kv in Stacks)
|
||||
{
|
||||
GraveStack stack = kv.Value;
|
||||
if (!stack.Permanent)
|
||||
{
|
||||
for (int i = stack.Entries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (stack.Entries[i].ExpireAt <= now)
|
||||
{
|
||||
stack.Entries.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.Entries.Count == 0)
|
||||
{
|
||||
EvictScratch.Add(kv.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
RefreshFavoriteFlag(stack);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < EvictScratch.Count; i++)
|
||||
{
|
||||
Stacks.Remove(EvictScratch[i]);
|
||||
}
|
||||
|
||||
EnforceStackCapUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnforceStackCapUnlocked()
|
||||
{
|
||||
int max = ModSettings.GravestoneMaxStacks;
|
||||
if (max <= 0)
|
||||
{
|
||||
Stacks.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
while (Stacks.Count > max)
|
||||
{
|
||||
long victimKey = 0;
|
||||
float oldest = float.PositiveInfinity;
|
||||
bool foundNonFav = false;
|
||||
foreach (KeyValuePair<long, GraveStack> kv in Stacks)
|
||||
{
|
||||
if (kv.Value.HasFavorite || kv.Value.Permanent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kv.Value.LastActivityAt < oldest)
|
||||
{
|
||||
oldest = kv.Value.LastActivityAt;
|
||||
victimKey = kv.Key;
|
||||
foundNonFav = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundNonFav)
|
||||
{
|
||||
oldest = float.PositiveInfinity;
|
||||
foreach (KeyValuePair<long, GraveStack> kv in Stacks)
|
||||
{
|
||||
if (kv.Value.LastActivityAt < oldest)
|
||||
{
|
||||
oldest = kv.Value.LastActivityAt;
|
||||
victimKey = kv.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (victimKey == 0 && Stacks.Count > 0)
|
||||
{
|
||||
foreach (long k in Stacks.Keys)
|
||||
{
|
||||
victimKey = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (victimKey == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Stacks.Remove(victimKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RefreshFavoriteFlag(GraveStack stack)
|
||||
{
|
||||
bool fav = false;
|
||||
for (int i = 0; i < stack.Entries.Count; i++)
|
||||
{
|
||||
if (stack.Entries[i].Favorite)
|
||||
{
|
||||
fav = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stack.HasFavorite = fav;
|
||||
}
|
||||
|
||||
private static void EnsureQuantumAsset()
|
||||
{
|
||||
if (_assetRegistered)
|
||||
{
|
||||
EnsureGroupSystem();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (AssetManager.quantum_sprites == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QuantumSpriteAsset existing = null;
|
||||
try
|
||||
{
|
||||
existing = AssetManager.quantum_sprites.get(QuantumAssetId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
existing = null;
|
||||
}
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
QuantumSpriteAsset asset = new QuantumSpriteAsset
|
||||
{
|
||||
id = QuantumAssetId,
|
||||
id_prefab = "p_gameSprite",
|
||||
base_scale = SkullBaseScale,
|
||||
render_gameplay = true,
|
||||
render_map = true,
|
||||
default_amount = 64,
|
||||
create_object = (QuantumSpriteAsset _, QuantumSprite q) =>
|
||||
{
|
||||
Sprite sprite = SkullSprite;
|
||||
if (sprite != null)
|
||||
{
|
||||
q.setSprite(sprite);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
q.sprite_renderer.sortingLayerID = SortingLayer.NameToID("Objects");
|
||||
q.sprite_renderer.sortingOrder = 2;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// sorting optional
|
||||
}
|
||||
},
|
||||
draw_call = DrawGraves
|
||||
};
|
||||
AssetManager.quantum_sprites.add(asset);
|
||||
LogService.LogInfo("[IdleSpectator] Registered gravestone QuantumSprite asset");
|
||||
}
|
||||
|
||||
_assetRegistered = true;
|
||||
EnsureGroupSystem();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
LogService.LogInfo($"[IdleSpectator] Gravestone QuantumSprite register failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureGroupSystem()
|
||||
{
|
||||
try
|
||||
{
|
||||
QuantumSpriteAsset asset = AssetManager.quantum_sprites.get(QuantumAssetId);
|
||||
if (asset == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.group_system != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QuantumSpriteGroupSystem system = new GameObject("IdleSpectatorGravesQS")
|
||||
.AddComponent<QuantumSpriteGroupSystem>();
|
||||
system.create(asset);
|
||||
asset.group_system = system;
|
||||
asset.group_system.turn_off_renderer = asset.turn_off_renderer;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
LogService.LogInfo($"[IdleSpectator] Gravestone group_system init failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawGraves(QuantumSpriteAsset asset)
|
||||
{
|
||||
if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled || asset?.group_system == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Sprite sprite = SkullSprite;
|
||||
if (sprite == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep tiny even if an older asset instance kept a larger base_scale.
|
||||
asset.base_scale = SkullBaseScale;
|
||||
long highlightKey = GraveHover.HighlightStackKey;
|
||||
float pulse = 0.55f + 0.45f * Mathf.Abs(Mathf.Sin(Time.unscaledTime * 9f));
|
||||
lock (Stacks)
|
||||
{
|
||||
foreach (GraveStack stack in Stacks.Values)
|
||||
{
|
||||
if (stack.Entries.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QuantumSprite qs = asset.group_system.getNext();
|
||||
if (qs == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Vector3 pos = stack.WorldPos;
|
||||
pos.y += 0.12f;
|
||||
float scale = SkullBaseScale;
|
||||
if (stack.Entries.Count > 1)
|
||||
{
|
||||
scale *= Mathf.Min(1.25f, 1f + 0.02f * (stack.Entries.Count - 1));
|
||||
}
|
||||
|
||||
Color baseTint = stack.Permanent ? _permTint : _tempTint;
|
||||
Color tint = baseTint;
|
||||
bool highlight = highlightKey != 0 && stack.Key == highlightKey;
|
||||
if (highlight)
|
||||
{
|
||||
// Bright flicker so hover/selection is obvious on a tiny sprite.
|
||||
Color flash = stack.Permanent
|
||||
? new Color(1f, 0.95f, 0.55f, 1f)
|
||||
: new Color(1f, 1f, 1f, 1f);
|
||||
tint = Color.Lerp(baseTint, flash, pulse);
|
||||
scale *= 1.12f + 0.1f * pulse;
|
||||
}
|
||||
|
||||
qs.set(ref pos, scale);
|
||||
qs.setSprite(sprite);
|
||||
qs.setColor(ref tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +116,9 @@ internal static class HarnessScenarios
|
|||
case "chronicle":
|
||||
case "chronicle_smoke":
|
||||
return ChronicleSmoke();
|
||||
case "gravestones":
|
||||
case "grave_smoke":
|
||||
return GravestoneSmoke();
|
||||
case "activity":
|
||||
case "activity_idle_smoke":
|
||||
return ActivityIdleSmoke();
|
||||
|
|
@ -216,6 +219,7 @@ internal static class HarnessScenarios
|
|||
Nested("reg_presentability", "camera_presentability"),
|
||||
Nested("reg_event_coverage", "event_suite"),
|
||||
Nested("reg_chronicle", "chronicle_smoke"),
|
||||
Nested("reg_gravestones", "gravestones"),
|
||||
Nested("reg_activity", "activity_idle_smoke"),
|
||||
Nested("reg_activity_status", "activity_status_smoke"),
|
||||
Nested("reg_activity_happiness", "activity_happiness_smoke"),
|
||||
|
|
@ -2360,11 +2364,13 @@ internal static class HarnessScenarios
|
|||
Step("ch3", "set_setting", expect: "show_dossier_caption", value: "true"),
|
||||
Step("ch4", "set_setting", expect: "chronicle_enabled", value: "true"),
|
||||
Step("ch4b", "chronicle_clear"),
|
||||
Step("ch5", "pick_unit", asset: "auto"),
|
||||
// Fresh spawn so prior scenarios (lava / Miracle Born) cannot steal focus.
|
||||
Step("ch5", "spawn", asset: "human", count: 1),
|
||||
Step("ch6", "spectator", value: "off"),
|
||||
Step("ch7", "spectator", value: "on"),
|
||||
Step("ch8", "focus", asset: "auto"),
|
||||
Step("ch9", "wait", wait: 0.35f),
|
||||
Step("ch9b", "focus", asset: "auto"),
|
||||
Step("ch10", "assert", expect: "health"),
|
||||
Step("ch11", "assert", expect: "dossier_contains", value: "auto"),
|
||||
Step("ch12", "assert", expect: "caption_contains", value: "auto"),
|
||||
|
|
@ -2399,15 +2405,15 @@ internal static class HarnessScenarios
|
|||
Step("ch15i", "screenshot", value: "hud-dossier-long-hist.png"),
|
||||
Step("ch15j", "wait", wait: 0.65f),
|
||||
|
||||
// Death-cause wording samples (victim POV)
|
||||
// Death-cause wording samples (victim POV) - flavored FormatDeathCause lines.
|
||||
Step("ch16a", "chronicle_death_sample", value: "Age"),
|
||||
Step("ch16b", "assert", expect: "chronicle_latest_contains", value: "old age"),
|
||||
Step("ch16c", "chronicle_death_sample", value: "Starvation"),
|
||||
Step("ch16d", "assert", expect: "chronicle_latest_contains", value: "Starved"),
|
||||
Step("ch16d", "assert", expect: "chronicle_latest_contains", value: "hunger"),
|
||||
Step("ch16e", "chronicle_death_sample", value: "Drowning"),
|
||||
Step("ch16f", "assert", expect: "chronicle_latest_contains", value: "Drown"),
|
||||
Step("ch16f", "assert", expect: "chronicle_latest_contains", value: "water"),
|
||||
Step("ch16g", "chronicle_death_sample", value: "Fire"),
|
||||
Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"),
|
||||
Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "ash"),
|
||||
// Character kills/deaths must stay on character history, not World Memory.
|
||||
Step("ch16i", "assert", expect: "world_memory_world_only"),
|
||||
// Soft-cap prune: temporarily lower the cap, flood past it, then restore.
|
||||
|
|
@ -2423,7 +2429,7 @@ internal static class HarnessScenarios
|
|||
Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"),
|
||||
Step("ch17b", "assert", expect: "chronicle_count", value: "1", label: "world"),
|
||||
Step("ch17c", "assert", expect: "world_memory_contains", value: "harness_alpha"),
|
||||
Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"),
|
||||
Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "ash"),
|
||||
Step("ch17e", "assert", expect: "world_memory_age"),
|
||||
Step("ch17e2", "assert", expect: "world_memory_world_only"),
|
||||
Step("ch17e3", "assert", expect: "chronicle_memory_capped"),
|
||||
|
|
@ -2532,7 +2538,7 @@ internal static class HarnessScenarios
|
|||
Step("ch18d6", "assert", expect: "lore_follow", value: "false"),
|
||||
Step("ch18d7", "assert", expect: "lore_history_shown", value: "5", label: "exact"),
|
||||
Step("ch18d7b", "assert", expect: "fallen_focus", value: "place"),
|
||||
Step("ch18d7c", "assert", expect: "death_manner", value: "old age"),
|
||||
Step("ch18d7c", "assert", expect: "death_manner", value: "Old Age"),
|
||||
Step("ch18d7d", "assert", expect: "dossier_contains", value: "Oldbone"),
|
||||
Step("ch18d7e", "assert", expect: "dossier_unit_matches_lore"),
|
||||
Step("ch18d7f", "assert", expect: "dossier_species", value: "skeleton"),
|
||||
|
|
@ -2540,7 +2546,7 @@ internal static class HarnessScenarios
|
|||
Step("ch18d7h", "assert", expect: "fallen_place_no_follow"),
|
||||
Step("ch18d8", "screenshot", value: "hud-lore-fallen.png"),
|
||||
Step("ch18d9", "wait", wait: 0.55f),
|
||||
// Fallen slain → living killer focus.
|
||||
// Fallen slain → place/skull first; Killer button follows the living killer.
|
||||
Step("ch18k1", "lore_back"),
|
||||
Step("ch18k2", "spawn", asset: "human", count: 1),
|
||||
Step("ch18k3", "focus", asset: "auto"),
|
||||
|
|
@ -2558,14 +2564,18 @@ internal static class HarnessScenarios
|
|||
Step("ch18k7", "wait", wait: 0.25f),
|
||||
Step("ch18k8", "assert", expect: "lore_detail", value: "true"),
|
||||
Step("ch18k9", "assert", expect: "lore_follow", value: "false"),
|
||||
Step("ch18k10", "assert", expect: "fallen_focus", value: "killer"),
|
||||
Step("ch18k11", "assert", expect: "death_manner", value: "slain"),
|
||||
Step("ch18k10", "assert", expect: "fallen_focus", value: "place"),
|
||||
Step("ch18k10b", "assert", expect: "fallen_place_no_follow"),
|
||||
Step("ch18k11", "assert", expect: "death_manner", value: "Slain"),
|
||||
Step("ch18k11b", "assert", expect: "dossier_contains", value: "Slainbone"),
|
||||
Step("ch18k11c", "assert", expect: "dossier_unit_matches_lore"),
|
||||
Step("ch18k11d", "assert", expect: "dossier_species", value: "skeleton"),
|
||||
Step("ch18k11e", "assert", expect: "dossier_avatar_species"),
|
||||
Step("ch18k11f", "assert", expect: "fallen_killer_camera"),
|
||||
Step("ch18k11g", "assert", expect: "fallen_killer_banner"),
|
||||
Step("ch18k11f", "lore_focus_killer"),
|
||||
Step("ch18k11g", "wait", wait: 0.2f),
|
||||
Step("ch18k11h", "assert", expect: "fallen_focus", value: "killer"),
|
||||
Step("ch18k11i", "assert", expect: "fallen_killer_camera"),
|
||||
Step("ch18k11j", "assert", expect: "fallen_killer_banner"),
|
||||
Step("ch18k12", "screenshot", value: "hud-lore-fallen-slain.png"),
|
||||
Step("ch18k13", "wait", wait: 0.55f),
|
||||
// Regression: fallen species portrait must not stick onto the next living dossier.
|
||||
|
|
@ -2665,6 +2675,113 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gravestone stacks: skull → Lore stack list/search/Keep, place focus, Killer, cap.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> GravestoneSmoke()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("gv0", "wait_world"),
|
||||
Step("gv1", "dismiss_windows"),
|
||||
Step("gv2", "fast_timing", value: "true"),
|
||||
Step("gv3", "set_setting", expect: "gravestones_enabled", value: "true"),
|
||||
Step("gv5", "set_setting", expect: "gravestone_max_stacks", value: "1000"),
|
||||
Step("gv6", "grave_clear"),
|
||||
Step("gv7", "spawn", asset: "human", count: 1),
|
||||
Step("gv8", "focus", asset: "auto"),
|
||||
Step("gv9", "wait", wait: 0.25f),
|
||||
// Stack three named deaths on the focus tile.
|
||||
Step("gv10", "grave_force", value: "GraveAlice", count: 1),
|
||||
Step("gv11", "grave_force", value: "GraveBob", count: 1),
|
||||
Step("gv12", "grave_force", value: "GraveCara", count: 1),
|
||||
Step("gv13", "assert", expect: "grave_stacks", count: 1, label: "eq"),
|
||||
Step("gv14", "assert", expect: "grave_entries", count: 3),
|
||||
Step("gv15", "grave_open"),
|
||||
Step("gv16", "wait", wait: 0.15f),
|
||||
Step("gv17", "assert", expect: "grave_stack_scope", value: "true"),
|
||||
Step("gv17b", "assert", expect: "lore_detail", value: "false"),
|
||||
Step("gv17c", "assert", expect: "grave_search_layout"),
|
||||
Step("gv18", "grave_search", value: "Bob"),
|
||||
Step("gv19", "wait", wait: 0.1f),
|
||||
Step("gv20", "assert", expect: "grave_search_filtered", count: 1),
|
||||
Step("gv21", "grave_search", value: ""),
|
||||
// Per-grave Keep / Del in Lore chrome (not Mod Settings).
|
||||
Step("gv21b", "grave_set_permanent", value: "true"),
|
||||
Step("gv21c", "assert", expect: "grave_permanent", value: "true"),
|
||||
Step("gv22", "grave_open_lore"),
|
||||
Step("gv23", "wait", wait: 0.25f),
|
||||
Step("gv24", "assert", expect: "lore_detail", value: "true"),
|
||||
Step("gv25", "assert", expect: "fallen_focus", value: "place"),
|
||||
Step("gv26", "assert", expect: "fallen_place_no_follow"),
|
||||
// Outside click / dismiss closes Lore (+ grave scope) together.
|
||||
Step("gv26b", "inspect_dismiss"),
|
||||
Step("gv26c", "assert", expect: "grave_stack_scope", value: "false"),
|
||||
Step("gv26d", "assert", expect: "lore_detail", value: "false"),
|
||||
// Delete removes the skull and closes Lore.
|
||||
Step("gv26e0", "grave_clear"),
|
||||
Step("gv26e", "grave_force", value: "GraveDel", count: 1),
|
||||
Step("gv26f", "grave_open"),
|
||||
Step("gv26g", "assert", expect: "grave_stack_scope", value: "true"),
|
||||
Step("gv26h", "grave_delete"),
|
||||
Step("gv26i", "assert", expect: "grave_stacks", value: "0", label: "eq"),
|
||||
Step("gv26j", "assert", expect: "grave_stack_scope", value: "false"),
|
||||
// Killer path: orphan with living killer, place-open then Focus Killer button.
|
||||
Step("gv27", "lore_back"),
|
||||
Step("gv28", "lore_close"),
|
||||
Step("gv28b", "grave_clear"),
|
||||
Step("gv29", "spawn", asset: "human", count: 1),
|
||||
Step("gv30", "focus", asset: "auto"),
|
||||
Step("gv31", "wait", wait: 0.2f),
|
||||
Step(
|
||||
"gv32",
|
||||
"chronicle_orphan",
|
||||
label: "GraveSlain",
|
||||
asset: "skeleton",
|
||||
value: "Murder",
|
||||
count: 1,
|
||||
expect: "Weapon",
|
||||
tier: "killer"),
|
||||
Step("gv33", "grave_open_lore", value: "GraveSlain"),
|
||||
Step("gv34", "wait", wait: 0.2f),
|
||||
Step("gv34b", "assert", expect: "grave_death_subtitle", value: "Slain by"),
|
||||
Step("gv35", "assert", expect: "fallen_focus", value: "place"),
|
||||
Step("gv36", "lore_focus_killer"),
|
||||
Step("gv37", "wait", wait: 0.2f),
|
||||
Step("gv38", "assert", expect: "fallen_focus", value: "killer"),
|
||||
Step("gv39", "assert", expect: "fallen_killer_camera"),
|
||||
// Cap eviction: shrink max stacks and flood tiles via orphans at offset positions.
|
||||
Step("gv40", "lore_close"),
|
||||
Step("gv41", "set_setting", expect: "gravestone_max_stacks", value: "2"),
|
||||
Step("gv42", "grave_clear"),
|
||||
Step("gv43", "grave_force", value: "CapA", count: 1, tier: "0,0"),
|
||||
Step("gv44", "grave_force", value: "CapB", count: 1, tier: "2,0"),
|
||||
Step("gv45", "grave_force", value: "CapC", count: 1, tier: "4,0"),
|
||||
Step("gv46", "assert", expect: "grave_stacks", count: 2, label: "eq"),
|
||||
Step("gv47", "set_setting", expect: "gravestone_max_stacks", value: "1000"),
|
||||
Step("gv48", "grave_clear"),
|
||||
Step("gv50", "grave_force", value: "PermOne", count: 1),
|
||||
Step("gv50b", "grave_open"),
|
||||
Step("gv50c", "grave_set_permanent", value: "true"),
|
||||
Step("gv50d", "assert", expect: "grave_permanent", value: "true"),
|
||||
Step("gv51", "assert", expect: "grave_stacks", count: 1, label: "min"),
|
||||
Step("gv52", "grave_set_permanent", value: "false"),
|
||||
// Live death → gravestone
|
||||
Step("gv53", "grave_clear"),
|
||||
Step("gv54", "spawn", asset: "sheep", count: 1),
|
||||
Step("gv55", "focus", asset: "auto"),
|
||||
Step("gv56", "wait", wait: 0.2f),
|
||||
Step("gv57", "kill_focus"),
|
||||
Step("gv58", "wait", wait: 0.35f),
|
||||
Step("gv59", "assert", expect: "grave_stacks", count: 1, label: "min"),
|
||||
Step("gv60", "grave_clear"),
|
||||
Step("gv61", "screenshot", value: "hud-gravestones.png"),
|
||||
Step("gv62", "wait", wait: 0.4f),
|
||||
Step("gv63", "fast_timing", value: "false"),
|
||||
Step("gv64", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
private static HarnessCommand Nested(string id, string scenario)
|
||||
{
|
||||
return Step(id, "scenario", value: scenario, expect: "keep");
|
||||
|
|
|
|||
62
IdleSpectator/InspectUi.cs
Normal file
62
IdleSpectator/InspectUi.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Shared dismiss for skull-opened Lore + paused dossier pin.
|
||||
/// </summary>
|
||||
public static class InspectUi
|
||||
{
|
||||
/// <summary>True after a skull open until <see cref="DismissAll"/>.</summary>
|
||||
public static bool GraveSessionActive { get; private set; }
|
||||
|
||||
public static void MarkGraveSession()
|
||||
{
|
||||
GraveSessionActive = true;
|
||||
}
|
||||
|
||||
public static void DismissAll()
|
||||
{
|
||||
GraveSessionActive = false;
|
||||
ChronicleHud.ClearGraveStackScope();
|
||||
if (ChronicleHud.DetailUnitId != 0)
|
||||
{
|
||||
ChronicleHud.ClearUnitDetail();
|
||||
}
|
||||
|
||||
if (ChronicleHud.Visible)
|
||||
{
|
||||
ChronicleHud.SetVisible(false);
|
||||
}
|
||||
|
||||
WatchCaption.ClearPausePin();
|
||||
WatchCaption.ClearStatusBanner();
|
||||
}
|
||||
|
||||
public static void Update()
|
||||
{
|
||||
if (!GraveSessionActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!InputHelpers.GetMouseButtonDown(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Time.unscaledTime < ChronicleHud.IgnoreDismissUntil)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ChronicleHud.IsPointerOverPanel()
|
||||
|| WatchCaption.IsPointerOverPanel())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Click outside Lore / dossier closes the skull inspect session.
|
||||
DismissAll();
|
||||
}
|
||||
}
|
||||
|
|
@ -2939,6 +2939,29 @@ public static class InterestDirector
|
|||
&& !ChronicleHud.IsPointerOverPanel()
|
||||
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
|
||||
{
|
||||
// Grave skull click opens Lore; do not treat as idle pause.
|
||||
if (InputHelpers.GetMouseButtonDown(0) && GraveMarkers.StackCount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
WorldTile tile = World.world.getMouseTilePosCachedFrame() ?? World.world.getMouseTilePos();
|
||||
if (tile != null && GraveMarkers.TryGetStack(tile.x, tile.y, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Vector2 mouse = World.world.getMousePos();
|
||||
if (GraveMarkers.FindNearestStack(mouse, 1.15f) != null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through to manual pause
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,5 +5,8 @@
|
|||
"show_dossier_caption": "Show creature dossier caption while watching",
|
||||
"chronicle_enabled": "Enable Lore panel (L) - World Memory + Character History",
|
||||
"debug_state_probe": "Log focus/power-bar state (for debugging)",
|
||||
"gravestones_enabled": "Show gravestone skulls on death",
|
||||
"gravestone_max_stacks": "Max gravestone stacks on the map",
|
||||
"gravestone_ttl_seconds": "Grave lifetime in seconds (Keep on a grave skips this)",
|
||||
"IdleSpectator Chronicle": "Lore"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,13 @@ public static class LoreProse
|
|||
private static readonly Regex KillPattern =
|
||||
new Regex(@"^Killed (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
|
||||
private static readonly Regex KilledByPattern =
|
||||
new Regex(@"^Killed by (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
|
||||
new Regex(
|
||||
@"^(?:Killed by|Was cut down by|Was slain by) (.+) \(([^)]+)\)$",
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex EatenByPattern =
|
||||
new Regex(@"^Eaten by (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
|
||||
new Regex(
|
||||
@"^(?:Eaten by|Was torn apart and eaten by|Devoured by) (.+)(?: \(([^)]+)\))?$",
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex LoversPattern =
|
||||
new Regex(@"^Became lovers with (.+)$", RegexOptions.CultureInvariant);
|
||||
private static readonly Regex FriendPattern =
|
||||
|
|
@ -107,42 +111,44 @@ public static class LoreProse
|
|||
return "Devoured by " + eaten.Groups[1].Value;
|
||||
}
|
||||
|
||||
// New FormatDeathCause lines are already flavored - keep them.
|
||||
// Legacy strings still get a soft rewrite.
|
||||
switch (raw)
|
||||
{
|
||||
case "Died of old age":
|
||||
return "Passed from old age";
|
||||
return "Faded gently into old age";
|
||||
case "Starved to death":
|
||||
return "Starved in lean seasons";
|
||||
return "Wasted away from hunger";
|
||||
case "Burned to death":
|
||||
return "Burned to ash";
|
||||
return "Burned until only ash remained";
|
||||
case "Burned in lava":
|
||||
return "Claimed by lava";
|
||||
return "Was swallowed by lava";
|
||||
case "Drowned":
|
||||
return "Drowned beneath the waves";
|
||||
return "Sank beneath the water and never rose";
|
||||
case "Died of poison":
|
||||
return "Succumbed to poison";
|
||||
return "Succumbed as poison flooded their veins";
|
||||
case "Died of plague":
|
||||
return "Taken by plague";
|
||||
return "Was claimed by a spreading plague";
|
||||
case "Died of infection":
|
||||
return "Taken by infection";
|
||||
return "Rotted from within as infection took hold";
|
||||
case "Died of a tumor":
|
||||
return "Claimed by a tumor";
|
||||
return "Was consumed by a growing tumor";
|
||||
case "Dissolved in acid":
|
||||
return "Dissolved in acid";
|
||||
return "Dissolved screaming in acid";
|
||||
case "Died of ash fever":
|
||||
return "Taken by ash fever";
|
||||
return "Burned out from ash fever";
|
||||
case "Fell to their death":
|
||||
return "Fell to their death";
|
||||
return "Fell from a height and broke upon the ground";
|
||||
case "Died in an explosion":
|
||||
return "Perished in an explosion";
|
||||
return "Was torn apart in an explosion";
|
||||
case "Struck down by divine power":
|
||||
return "Struck down by divine power";
|
||||
return "Was smote by divine power";
|
||||
case "Was eaten":
|
||||
return "Was eaten";
|
||||
return "Was torn apart and eaten";
|
||||
case "Killed in combat":
|
||||
return "Fell in combat";
|
||||
return "Fell in bitter combat";
|
||||
case "Transformed away":
|
||||
return "Transformed away";
|
||||
return "Transformed away and left no body behind";
|
||||
case "Died":
|
||||
return "Met their end";
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
|
|||
|
||||
WatchCaption.Update();
|
||||
Chronicle.Update();
|
||||
GraveMarkers.Update();
|
||||
InspectUi.Update();
|
||||
FocusRelationshipArrows.PinFocusActor();
|
||||
StateProbe.Update();
|
||||
AutoTest.Update();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ public static class ModSettings
|
|||
public const string ShowDossierCaptionId = "show_dossier_caption";
|
||||
public const string ChronicleEnabledId = "chronicle_enabled";
|
||||
public const string DebugStateProbeId = "debug_state_probe";
|
||||
public const string GravestonesEnabledId = "gravestones_enabled";
|
||||
public const string GravestoneMaxStacksId = "gravestone_max_stacks";
|
||||
public const string GravestoneTtlSecondsId = "gravestone_ttl_seconds";
|
||||
|
||||
public const int DefaultGravestoneMaxStacks = 1000;
|
||||
public const int DefaultGravestoneTtlSeconds = 300;
|
||||
|
||||
private static ModConfig _config;
|
||||
|
||||
|
|
@ -29,6 +35,14 @@ public static class ModSettings
|
|||
|
||||
public static bool DebugStateProbe => GetBool(DebugStateProbeId, defaultValue: true);
|
||||
|
||||
public static bool GravestonesEnabled => GetBool(GravestonesEnabledId, defaultValue: true);
|
||||
|
||||
public static int GravestoneMaxStacks =>
|
||||
GetInt(GravestoneMaxStacksId, DefaultGravestoneMaxStacks, min: 0, max: 2000);
|
||||
|
||||
public static int GravestoneTtlSeconds =>
|
||||
GetInt(GravestoneTtlSecondsId, DefaultGravestoneTtlSeconds, min: 30, max: 1800);
|
||||
|
||||
public static ModConfig Load(ModDeclare declare)
|
||||
{
|
||||
string persistentPath = Path.Combine(Paths.ModsConfigPath, declare.UID + ".config");
|
||||
|
|
@ -52,6 +66,11 @@ public static class ModSettings
|
|||
SpectatorMode.SetActive(false);
|
||||
LogService.LogInfo("[IdleSpectator] Disabled via settings");
|
||||
}
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
GraveMarkers.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TrySetBool(string id, bool value)
|
||||
|
|
@ -80,6 +99,26 @@ public static class ModSettings
|
|||
}
|
||||
}
|
||||
|
||||
public static bool TrySetInt(string id, int value)
|
||||
{
|
||||
if (_config == null || string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ModConfigItem item = _config[GroupId][id];
|
||||
item.SetValue(value);
|
||||
return true;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
LogService.LogInfo($"[IdleSpectator] TrySetInt failed for {id}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool GetBool(string id, bool defaultValue)
|
||||
{
|
||||
if (_config == null)
|
||||
|
|
@ -103,4 +142,38 @@ public static class ModSettings
|
|||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static int GetInt(string id, int defaultValue, int min, int max)
|
||||
{
|
||||
if (_config == null)
|
||||
{
|
||||
return UnityEngine.Mathf.Clamp(defaultValue, min, max);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ModConfigItem item = _config[GroupId][id];
|
||||
object value = item.GetValue();
|
||||
if (value is int i)
|
||||
{
|
||||
return UnityEngine.Mathf.Clamp(i, min, max);
|
||||
}
|
||||
|
||||
if (value is long l)
|
||||
{
|
||||
return UnityEngine.Mathf.Clamp((int)l, min, max);
|
||||
}
|
||||
|
||||
if (value is float f)
|
||||
{
|
||||
return UnityEngine.Mathf.Clamp(UnityEngine.Mathf.RoundToInt(f), min, max);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to default.
|
||||
}
|
||||
|
||||
return UnityEngine.Mathf.Clamp(defaultValue, min, max);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ public sealed class UnitDossier
|
|||
string mannerLabel = Chronicle.DeathMannerLabel(manner);
|
||||
d.ReasonLine = string.IsNullOrEmpty(mannerLabel) || manner == DeathManner.None
|
||||
? "Fallen"
|
||||
: char.ToUpperInvariant(mannerLabel[0]) + mannerLabel.Substring(1);
|
||||
: mannerLabel;
|
||||
d.DetailLine = "";
|
||||
d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine);
|
||||
return d;
|
||||
|
|
|
|||
|
|
@ -705,7 +705,7 @@ public static class WatchCaption
|
|||
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
|
||||
}
|
||||
|
||||
private static void ClearStatusBanner()
|
||||
public static void ClearStatusBanner()
|
||||
{
|
||||
_statusBanner = "";
|
||||
_statusBannerUntil = 0f;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,25 @@
|
|||
"Id": "debug_state_probe",
|
||||
"Type": "SWITCH",
|
||||
"BoolVal": true
|
||||
},
|
||||
{
|
||||
"Id": "gravestones_enabled",
|
||||
"Type": "SWITCH",
|
||||
"BoolVal": true
|
||||
},
|
||||
{
|
||||
"Id": "gravestone_max_stacks",
|
||||
"Type": "INT_SLIDER",
|
||||
"IntVal": 1000,
|
||||
"MinIntVal": 0,
|
||||
"MaxIntVal": 2000
|
||||
},
|
||||
{
|
||||
"Id": "gravestone_ttl_seconds",
|
||||
"Type": "INT_SLIDER",
|
||||
"IntVal": 300,
|
||||
"MinIntVal": 30,
|
||||
"MaxIntVal": 1800
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.27.6",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Variety valve, tip hysteresis, fight balance, lethal/lover parks.",
|
||||
"version": "0.28.11",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Flavored death lines, full-width grave search, no skull counts.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue