Phase C3: Character & Asset Lane Storytelling Improvements

This commit is contained in:
Nick Beckley 2026-06-20 16:18:29 +01:00
parent d0939b914a
commit 36c214fab8
4 changed files with 245 additions and 14 deletions

View File

@ -151,6 +151,21 @@
}
return string.Join(Environment.NewLine, lines);
}
string TrimMarkerLabel(string value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().Length <= 5 ? value.Trim() : value.Trim()[..5];
IReadOnlyList<ContinuityWarning> EntityWarnings(Scene scene, string entityType, int entityId)
=> Model.WarningsByScene.TryGetValue(scene.SceneID, out var sceneWarnings)
? sceneWarnings
.Where(warning => string.Equals(warning.EntityType, entityType, StringComparison.OrdinalIgnoreCase)
&& warning.EntityID == entityId)
.OrderByDescending(warning => warning.SeverityName)
.ThenBy(warning => warning.WarningTypeName)
.ToList()
: [];
bool HasStoryState(SceneCharacter appearance)
=> !string.IsNullOrWhiteSpace(appearance.EmotionalState)
|| !string.IsNullOrWhiteSpace(appearance.PhysicalCondition)
|| !string.IsNullOrWhiteSpace(appearance.KnowledgeNotes);
string AssetMarkerLabel(AssetEvent assetEvent)
{
var typeName = assetEvent.AssetEventTypeName?.Trim() ?? string.Empty;
@ -172,7 +187,7 @@
"investigated" => "?",
"resolved" => "Done",
"state changed" => "State",
_ => string.IsNullOrWhiteSpace(typeName) ? "Asset" : typeName.Length <= 5 ? typeName : typeName[..5]
_ => string.IsNullOrWhiteSpace(typeName) ? "Asset" : TrimMarkerLabel(typeName)
};
}
string AssetMarkerMeaning(AssetEvent assetEvent)
@ -199,27 +214,96 @@
_ => string.IsNullOrWhiteSpace(typeName) ? "Asset event occurs in this scene." : $"Asset event: {typeName}."
};
}
string AssetMarkerTooltip(AssetEvent assetEvent, Scene scene)
string AssetStorySentence(AssetEvent assetEvent)
{
var title = assetEvent.EventTitle?.Trim();
if (!string.IsNullOrWhiteSpace(title) && !string.Equals(title, assetEvent.AssetEventTypeName, StringComparison.OrdinalIgnoreCase))
{
return $"{assetEvent.AssetName}: {title}.";
}
return AssetMarkerMeaning(assetEvent);
}
string AssetMarkerClass(AssetEvent assetEvent, StoryAsset asset, IReadOnlyList<ContinuityWarning> assetWarnings)
{
var classes = new List<string>();
var typeName = assetEvent.AssetEventTypeName?.Trim().ToLowerInvariant() ?? string.Empty;
if (assetWarnings.Any())
{
classes.Add("timeline-marker-has-warning");
}
if (asset.Importance >= 8)
{
classes.Add("timeline-marker-important");
}
if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName))
{
classes.Add("timeline-marker-state-change");
}
if (typeName is "found" or "revealed" or "destroyed" or "resolved" or "state changed")
{
classes.Add("timeline-marker-story-turn");
}
if (typeName is "given" or "taken" or "stolen" or "moved" or "location changed")
{
classes.Add("timeline-marker-transfer");
}
return string.Join(' ', classes);
}
string AssetMarkerTooltip(AssetEvent assetEvent, StoryAsset asset, Scene scene, IReadOnlyList<ContinuityWarning> assetWarnings)
{
var lines = new List<string>
{
AssetMarkerMeaning(assetEvent),
AssetStorySentence(assetEvent),
$"Asset: {assetEvent.AssetName}",
$"Event: {assetEvent.EventTitle}",
$"Event type: {(string.IsNullOrWhiteSpace(assetEvent.AssetEventTypeName) ? "Not set" : assetEvent.AssetEventTypeName)}",
$"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}"
};
if (asset.Importance >= 8)
{
lines.Add("Significance: High-importance story asset.");
}
if (!string.IsNullOrWhiteSpace(assetEvent.FromStateName) || !string.IsNullOrWhiteSpace(assetEvent.ToStateName))
{
lines.Add($"State change: {(string.IsNullOrWhiteSpace(assetEvent.FromStateName) ? "Unknown" : assetEvent.FromStateName)} -> {(string.IsNullOrWhiteSpace(assetEvent.ToStateName) ? "Unknown" : assetEvent.ToStateName)}");
}
if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName))
{
lines.Add($"State changes to: {assetEvent.ToStateName}");
lines.Add($"Current state after this event: {assetEvent.ToStateName}");
}
if (!string.IsNullOrWhiteSpace(assetEvent.EventDescription))
{
lines.Add($"Summary: {assetEvent.EventDescription}");
}
foreach (var warning in assetWarnings.Take(2))
{
lines.Add($"Continuity: {warning.WarningTypeName} - {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}");
}
return string.Join(Environment.NewLine, lines);
}
string CharacterMarkerLabel(SceneCharacter appearance)
string CharacterMarkerLabel(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance)
{
if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID)
{
return "Intro";
}
if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID
&& string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase))
{
return "Exit";
}
if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase))
{
return "POV";
}
if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes))
{
return "Know";
}
if (HasStoryState(appearance))
{
return "State";
}
var presence = appearance.PresenceTypeName?.Trim() ?? string.Empty;
return presence.ToLowerInvariant() switch
{
@ -235,8 +319,25 @@
_ => "Char"
};
}
string CharacterMarkerMeaning(SceneCharacter appearance)
string CharacterMarkerMeaning(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance)
{
if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID)
{
return $"{appearance.CharacterName} enters this story lane in this scene.";
}
if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID
&& string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase))
{
return $"{appearance.CharacterName} exits this story lane in this scene.";
}
if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes))
{
return $"{appearance.CharacterName}'s knowledge changes in this scene.";
}
if (!string.IsNullOrWhiteSpace(appearance.EmotionalState) || !string.IsNullOrWhiteSpace(appearance.PhysicalCondition))
{
return $"{appearance.CharacterName}'s story state changes in this scene.";
}
var presence = appearance.PresenceTypeName?.Trim() ?? string.Empty;
return presence.ToLowerInvariant() switch
{
@ -252,16 +353,75 @@
_ => "Character appears in this scene."
};
}
string CharacterMarkerTooltip(SceneCharacter appearance, Scene scene)
string CharacterMarkerClass(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance, IReadOnlyList<ContinuityWarning> characterWarnings)
{
var classes = new List<string>();
if (characterWarnings.Any())
{
classes.Add("timeline-marker-has-warning");
}
if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID)
{
classes.Add("timeline-marker-story-turn");
}
if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID
&& string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase))
{
classes.Add("timeline-marker-exit");
}
if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase)
|| string.Equals(appearance.RoleInSceneTypeName, "Main Participant", StringComparison.OrdinalIgnoreCase))
{
classes.Add("timeline-marker-important");
}
if (HasStoryState(appearance))
{
classes.Add("timeline-marker-state-change");
}
return string.Join(' ', classes);
}
string CharacterMarkerTooltip(SceneCharacter appearance, Scene scene, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance, IReadOnlyList<ContinuityWarning> characterWarnings)
{
var lines = new List<string>
{
CharacterMarkerMeaning(appearance),
CharacterMarkerMeaning(appearance, firstAppearance, lastAppearance),
$"Character: {appearance.CharacterName}",
$"Role: {(string.IsNullOrWhiteSpace(appearance.RoleInSceneTypeName) ? "Not set" : appearance.RoleInSceneTypeName)}",
$"Presence: {(string.IsNullOrWhiteSpace(appearance.PresenceTypeName) ? "Not set" : appearance.PresenceTypeName)}",
$"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}"
};
if (!string.IsNullOrWhiteSpace(appearance.LocationPath) || !string.IsNullOrWhiteSpace(appearance.LocationName))
{
lines.Add($"Location: {appearance.LocationPath ?? appearance.LocationName}");
}
if (!string.IsNullOrWhiteSpace(appearance.EntryLocationName))
{
lines.Add($"Entry: {appearance.EntryLocationName}");
}
if (!string.IsNullOrWhiteSpace(appearance.ExitLocationName))
{
lines.Add($"Exit: {appearance.ExitLocationName}");
}
if (!string.IsNullOrWhiteSpace(appearance.EmotionalState))
{
lines.Add($"Emotional state: {appearance.EmotionalState}");
}
if (!string.IsNullOrWhiteSpace(appearance.PhysicalCondition))
{
lines.Add($"Physical condition: {appearance.PhysicalCondition}");
}
if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes))
{
lines.Add($"Knowledge: {appearance.KnowledgeNotes}");
}
if (!string.IsNullOrWhiteSpace(appearance.AppearanceNotes))
{
lines.Add($"Summary: {appearance.AppearanceNotes}");
}
foreach (var warning in characterWarnings.Take(2))
{
lines.Add($"Continuity: {warning.WarningTypeName} - {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}");
}
return string.Join(Environment.NewLine, lines);
}
bool HasLeadSceneRole(CharacterLaneViewModel lane) => lane.SceneSlots
@ -1169,8 +1329,10 @@ else
<div class="asset-lane-slot" title="Scene @realAssetScene.SceneNumber: @realAssetScene.SceneTitle">
@foreach (var assetEvent in slot?.Events ?? Enumerable.Empty<AssetEvent>())
{
var assetMarkerTooltip = AssetMarkerTooltip(assetEvent, realAssetScene);
<a class="asset-marker"
var assetWarnings = EntityWarnings(realAssetScene, "StoryAsset", assetEvent.StoryAssetID);
var assetMarkerTooltip = AssetMarkerTooltip(assetEvent, lane.Asset, realAssetScene, assetWarnings);
var assetMarkerClass = AssetMarkerClass(assetEvent, lane.Asset, assetWarnings);
<a class="asset-marker @assetMarkerClass"
asp-controller="Timeline"
asp-action="Index"
asp-route-ProjectID="@Model.Project.ProjectID"
@ -1226,6 +1388,13 @@ else
{
var lane = laneItem.lane;
var characterFocusClass = Model.Filter.FocusType == "character" && Model.Filter.FocusID.HasValue && Model.Filter.FocusID != lane.Character.CharacterID ? "lane-dim" : "";
var orderedLaneAppearances = lane.SceneSlots
.SelectMany(slot => slot.Appearances)
.OrderBy(appearance => timelineColumns.FindIndex(column => column.Scene?.SceneID == appearance.SceneID))
.ThenBy(appearance => appearance.SceneCharacterID)
.ToList();
var firstAppearance = orderedLaneAppearances.FirstOrDefault();
var lastAppearance = orderedLaneAppearances.LastOrDefault();
<div class="character-lane-row @characterFocusClass">
<a class="character-lane-label" asp-controller="Characters" asp-action="Details" asp-route-id="@lane.Character.CharacterID">
<strong>@lane.Character.CharacterName</strong>
@ -1244,8 +1413,10 @@ else
<div class="character-lane-slot" title="Scene @realCharacterScene.SceneNumber: @realCharacterScene.SceneTitle">
@foreach (var appearance in slot?.Appearances ?? Enumerable.Empty<SceneCharacter>())
{
var characterMarkerTooltip = CharacterMarkerTooltip(appearance, realCharacterScene);
<a class="character-marker"
var characterWarnings = EntityWarnings(realCharacterScene, "Character", appearance.CharacterID);
var characterMarkerTooltip = CharacterMarkerTooltip(appearance, realCharacterScene, firstAppearance, lastAppearance, characterWarnings);
var characterMarkerClass = CharacterMarkerClass(appearance, firstAppearance, lastAppearance, characterWarnings);
<a class="character-marker @characterMarkerClass"
asp-controller="Timeline"
asp-action="Index"
asp-route-ProjectID="@Model.Project.ProjectID"
@ -1255,7 +1426,7 @@ else
asp-route-FocusID="@appearance.CharacterID"
title="@characterMarkerTooltip"
aria-label="@characterMarkerTooltip">
@CharacterMarkerLabel(appearance)
@CharacterMarkerLabel(appearance, firstAppearance, lastAppearance)
</a>
}
</div>

View File

@ -16,5 +16,11 @@
"inputFiles": [
"wwwroot/js/site.js"
]
},
{
"outputFileName": "wwwroot/js/word-companion-host.min.js",
"inputFiles": [
"wwwroot/js/word-companion-host.js"
]
}
]

View File

@ -1454,6 +1454,35 @@ body.timeline-inspector-resizing {
background: #dfd6f7;
}
.asset-marker.timeline-marker-story-turn,
.character-marker.timeline-marker-story-turn {
border-width: 2px;
}
.asset-marker.timeline-marker-important,
.character-marker.timeline-marker-important {
box-shadow: inset 0 -3px 0 rgba(197, 139, 43, 0.36);
}
.asset-marker.timeline-marker-state-change,
.character-marker.timeline-marker-state-change {
border-style: double;
}
.asset-marker.timeline-marker-transfer {
box-shadow: inset 3px 0 0 rgba(47, 111, 99, 0.32);
}
.character-marker.timeline-marker-exit {
opacity: 0.86;
border-style: dashed;
}
.asset-marker.timeline-marker-has-warning,
.character-marker.timeline-marker-has-warning {
box-shadow: 0 0 0 2px rgba(179, 95, 59, 0.22);
}
.asset-events-section {
margin-top: 16px;
}

File diff suppressed because one or more lines are too long