diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ac097b..7dc9df6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,30 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots.
- Human-believable NPC agent design doc covering trait axes, relationship
ledger, memory tiers, needs, mood, stress, proactive communication, and
research anchors for LLM-driven NPC behavior.
+- NPC society multi-agent architecture research doc covering Convai-inspired
+ character product patterns, centralized society orchestration, shared
+ blackboard memory, per-agent memory, relationship ledgers, conversation
+ turn-taking, LLM scheduling, and Unity/Fusion/Nakama/api.dos.ai boundaries.
+- Nakama NPC decision prompts now include compact `KnowledgePack` context for
+ Vinh Hai Relay Ward public lore, early Nibirium era timing, BodyTime,
+ SECOND, Frames, Gates, factions, professions, quest hints, public rumors,
+ and selected private memory. Public packs are authored as versioned JSON
+ content, generated into the Nakama runtime registry, seeded into Nakama
+ storage on module load, and recorded by selected knowledge pack id in
+ PromptTrace. Private memory packs are assembled at runtime from bounded
+ per-body memory records.
+- Convai deep architecture audit covering its reactive plus reasoning mind
+ pattern, Live APIs, Mindview-style prompt tracing, Knowledge Bank context
+ packs, Narrative Design objectives, Action API discipline, External API
+ tool schemas, State of Mind observability, and contextual animation lessons
+ for SECOND SPAWN.
+- OpenClaw agent connection architecture covering onboarding, OAuth/device-code
+ auth, capability grants, actor binding, context pull and intent submit
+ contracts, rate limits, moderation, revocation, skill/plugin packaging,
+ failure modes, and phase plan.
+- Story bible defining the first playable narrative hook, Relay Yard hub, Ash
+ Underpass Gate, Tollkeeper boss, starting quest beats, factions, named NPC
+ archetypes, and LLM NPC story consistency rules.
- Unity `NetworkPlayer` now carries prototype level, combat stats, BodyTime,
lifecycle, SECOND balance, reincarnation count, visual key, and agent-control
state as networked fields.
@@ -80,6 +104,70 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots.
`secondspawn_npc_context_get` returns context plus interaction rules, and
`secondspawn_npc_intent_submit` validates bounded `say` intents against
distance, hostility, affinity, and repeated-interaction rules.
+- Unity now has a nearby NPC chat box that sends player lines to local NPC
+ brains, adds recent player speech into the model decision context, and keeps
+ NPC talk visuals facing the addressed actor while temporarily hiding weapons.
+- Nearby player chat now shows a speech bubble over the local player and
+ interrupts NPC cooldowns when a fresh nearby player message arrives.
+- Nearby player chat now also records a Nakama `player_chat_nearby_npc`
+ society event and writes heard-player-chat memories onto nearby permanent
+ NPC profiles, giving the future `NpcSocietyOrchestrator` a server-owned event
+ feed instead of only local Unity context.
+- Nakama now exposes a prototype `secondspawn_npc_society_tick` RPC that reads
+ recent society events and returns eligible NPC decision candidates without
+ mutating world state or bypassing validated intent RPCs.
+- The society tick now creates a server-owned `ConversationSession` scaffold
+ for nearby player chat, including participants, trigger event, objective, and
+ turn cap for later multi-turn NPC coordination.
+- Unity nearby NPC chat now consumes the `secondspawn_npc_society_tick` queue,
+ so NPCs only receive the player line after Nakama has accepted the society
+ event and selected eligible responders. Local delivery remains as a fallback
+ if the prototype backend path fails.
+- Nakama NPC `say` intents now write `npc_speech` society events and advance
+ the associated `ConversationSession` transcript and turn counter, giving the
+ society orchestrator durable conversation state instead of a one-shot event
+ queue.
+- Local Fusion dev-build tooling now builds a Windows development client and
+ launches a second local process as host, client, or server with unique local
+ Nakama prototype identity flags.
+- Prototype visual hotkeys now reserve `E` for talking to the nearest NPC and
+ move pickup animation to `F`; the network `Interact` input remains on `E`.
+- Nakama reactive fallback now answers nearby player chat before choosing a
+ movement fallback, and movement fallback uses each NPC's behavior tendency
+ instead of sending every NPC along the same patrol offset.
+- Nakama model prompts now include a compact `persona_card` with each NPC's
+ role, faction, home base, voice anchor, conflict, rumor, memory hook, and
+ behavior hints so DOS.AI has stronger per-NPC context without loading the
+ whole GDD.
+- Reactive fallback speech now varies by NPC greeting tone, home base, idle
+ action, and memory hook, so degraded mode still sounds like different Frames.
+- Prototype HUD now surfaces the inhabited body's name, profession, age, home
+ base, and first memory hook so login visibly feels like entering a specific
+ existing Frame.
+- Roadmap now converts the Convai-pattern research into concrete backlog lanes
+ for PromptTrace, Knowledge Packs, ConversationObjective, State of Mind, and
+ debug inspection while keeping Nakama, Fusion, and `api.dos.ai` as the
+ long-term backbone.
+- Roadmap and character docs now require stable per-NPC `voice_profile`
+ assignment and scoped `api.dos.ai` voice sessions so each important NPC can
+ keep a distinct voice without exposing provider keys to Unity.
+- Nakama now records redacted `PromptTrace` logs for `secondspawn_agent_decide`
+ calls, including model, source, source reason, action, latency, validation
+ result, selected memory ids, and prompt component metadata without storing
+ raw hidden prompts.
+- Unity HUD can fetch and show the latest prompt-trace summary from Nakama so
+ Play Mode debugging can distinguish model, fallback, backoff, and validation
+ behavior without opening backend logs.
+- Nakama now updates a prototype State of Mind on each agent decision, with
+ mood, stress, dominant need, last trigger, and last plan summary surfaced in
+ the Unity HUD.
+- Nakama now enforces a prototype per-player DOS.AI decision budget for
+ `secondspawn_agent_decide`, with daily request and estimated-token caps that
+ degrade to deterministic fallback instead of calling the model.
+- Nakama `ConversationSession` now includes a structured
+ `ConversationObjective` with section, trigger, priority, allowed intents,
+ turn cap, and completion state for NPC society turns.
+- Roadmap now tracks a browser/WebGL demo build lane for external playtests.
- Unity now has a Persistent NPC Debug panel for seeding, listing, and
smoke-testing LLM-style NPC context and say-intent submission between
permanent NPC Frames.
@@ -90,9 +178,50 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots.
- Gate, dungeon, and Pioneer Charter design doc for ranked Gates,
server-issued first-clear rights, common Tower/Gate genre mechanics, and
in-game-only Clearance Royalty loops.
+- Story bible for the first playable narrative foundation, including the Relay
+ Yard hub, Ash Underpass Gate, Tollkeeper boss, first five quest beats, faction
+ tensions, named NPC archetypes, and LLM NPC consistency rules.
+- Gate and story docs now explicitly separate persistent real-world exterior
+ spaces from distorted Portal Dungeon interiors with their own instance rules,
+ memory residue, impossible geometry, and TIME pressure.
+- Gate interior direction now allows fantasy-readable dungeon themes such as
+ orc-like warbands, troll-like brutes, castle ruins, forests, caves, towers,
+ constructs, and boss guardians while keeping the global explanation sci-fi.
+- Gate system docs now include reference takeaways from portal, Gate, Tower,
+ floor, and scenario manhwa patterns so dungeon interiors are designed as
+ fantasy-readable rule-spaces rather than ordinary sci-fi corridors.
+- Manhwa market reference ranking doc comparing public reach signals for
+ Solo Leveling, Tower of God, Omniscient Reader, The Gamer, Hardcore Leveling
+ Warrior, Tutorial Tower, The World After the Fall, Return to Player, Second
+ Life Ranker, SSS-Class Revival Hunter, and Level Up with the Gods.
+- Story bible now defines a recommended pre-MetaDOS demo direction in the early
+ Nibirium era, internally around 2033-2038, in `Vinh Hai Relay Ward`, a small
+ near-future coastal logistics ward where DOS Labs is still piloting early
+ AMB, Frame transfer, BodyTime accounting, Gate response, and the systems that
+ later become public MetaDOS spectacle.
+- Demo lore and story direction doc defining the 2028 Nibiru timeline revision,
+ Vinh Hai Relay Ward hub, MetaDOS-derived faction set, player origin, theme
+ rules, Gate fantasy rules, first permanent NPC set, and Chapter 1 arc.
+- Unity permanent NPC brains now propagate validated model-backed NPC speech to
+ nearby target NPC brains, allowing NPC-to-NPC conversations to continue
+ through the Nakama decision boundary instead of local hardcoded lines.
+- Unity now uses a shared local NPC decision request scheduler so autonomous
+ NPCs do not burst all model requests at once, while direct player-chat
+ responses keep priority slots.
+- Player nearby chat now reaches permanent NPC brains through the Nakama society
+ event and tick path, then requests model-backed replies with player-chat
+ context and visible model/fallback status.
+- NPC-to-NPC response propagation now respects conversation session turn caps
+ and per-NPC response cooldowns to avoid runaway local conversation loops.
+- `PrototypeAgentBrain` now uses a shared speech-stimulus path for player and
+ NPC speech, reducing drift between player-chat and NPC-hears-NPC handling.
### Changed
+- GDD continuity now treats SECOND SPAWN's demo as the early Nibirium era before
+ MetaDOS becomes a 2050 public tournament spectacle, while preserving MetaDOS
+ as the later commercialization of local Frame, BodyTime, Gate, and agent
+ systems.
- GDD and system docs now anchor SECOND SPAWN to the MetaDOS technology stack:
AMB cocoons, bio-synthetic Frames, Hunter Frames, TIME as the life medium,
SECOND as the unit/currency, manga/manhwa system-story progression tone, and
@@ -231,6 +360,31 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots.
the fixed body variant, avoiding duplicate startup prefab/animator warmup.
- Prototype agent brain now avoids duplicate fallback HTTP calls; Nakama returns
either a model-backed decision or a deterministic fallback in one RPC.
+- Architecture and character-profile docs now define the
+ `behavior_tendencies` adapter that maps identity, soul, traits, story, memory,
+ and relationship context into compact runtime hints for talk cadence, social
+ range, idle behavior, tone, conflict response, movement style, and decision
+ cadence.
+- Nakama model decisions now use a larger JSON budget and stricter short-output
+ prompt so DOS.AI responses are less likely to be truncated mid-intent.
+- Unity permanent NPC brains now share a small global DOS.AI request gate so 10
+ NPCs queue model decisions instead of bursting into 429 model backoff.
+- Model-backed NPC speech persistence now skips non-permanent or unknown
+ targets, avoiding noisy `unknown target NPC actor` warnings for player or
+ landmark-directed speech bubbles.
+- Permanent NPC prototype spacing now uses a wider spawn grid, smaller patrol
+ radius, and local personal-space separation so model-driven social movement
+ does not visually stack NPC bodies into one cluster.
+- Nearby NPC interaction now enters a prototype dialog mode: the local player
+ and target NPC stop free movement, face each other, and keep a short dialog
+ focus window while player chat continues.
+- Dialog mode now keeps the local player and focused NPC facing each other
+ every frame during the chat focus window, preventing movement or rotation
+ updates from breaking the conversation pose.
+- Nakama NPC prompt rules now push direct player answers toward natural
+ in-scene speech instead of menu-like or support-bot phrasing.
+- Nearby NPC chat now shows an `Esc` hint and exits Chat Mode on Escape,
+ clearing the dialog input lock and releasing the focused NPC.
### Verification
diff --git a/ROADMAP.md b/ROADMAP.md
index 77da322..f22eb7a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,7 +1,7 @@
# SECOND SPAWN Roadmap
Status: Pre-alpha, vertical slice foundation in development.
-Last updated: 2026-05-19.
+Last updated: 2026-05-20.
This roadmap tracks implementation status. Detailed design remains in `docs/`,
especially `docs/design/02-vertical-slice-spec.md` and
@@ -132,6 +132,12 @@ Recommended views:
- [x] Permanent NPC brains send nearby actor context to the model-backed
decision path and persist model-selected `say` intents through Nakama memory
and relationship records.
+- [x] Permanent NPC brains can now hear validated nearby NPC speech and answer
+ through the same model-backed Nakama decision path, with local request
+ scheduling, NPC speech cooldowns, and conversation turn-cap checks.
+- [x] Player nearby chat now reaches permanent NPC brains through the Nakama
+ society event and tick path, then produces model-backed replies when
+ `api.dos.ai` is healthy.
- [ ] Real combat damage, enemy rewards, loot drops, quest progress, and player
time-loot from other users are not implemented yet.
@@ -173,10 +179,16 @@ MVP, and a visible offline-agent prototype.
actions while Nakama validates range, hostility, affinity, and intent shape.
- [x] Add model-backed proactive NPC social decisions with nearby actor context
and validated Nakama memory or relationship persistence.
+- [x] Add model-backed NPC-to-NPC response propagation in Unity after Nakama
+ accepts a validated `say` intent.
+- [x] Add local prototype LLM request scheduling so 10 NPCs do not burst
+ requests into `api.dos.ai` at the same instant.
+- [x] Add player-chat priority handling so direct player messages do not wait
+ behind normal autonomous NPC ticks.
- [x] Add Nakama channel-based basic chat for the vertical slice.
- [x] Surface agent runtime stats and recent activity in an in-editor or
prototype debug UI.
-- [ ] Add per-player LLM rate limit and token-budget enforcement in Nakama for
+- [x] Add per-player LLM rate limit and token-budget enforcement in Nakama for
`secondspawn_agent_decide`.
- [ ] Add server-owned Nakama storage collection boundaries, permissions, and
optimistic concurrency for sensitive body, inventory, economy, memory, and
@@ -185,6 +197,17 @@ MVP, and a visible offline-agent prototype.
and agent activity so snapshots are auditable.
- [ ] Add Nakama metrics and structured observability for AI decisions,
fallback reasons, storage conflicts, reward claims, and BodyTime mutations.
+- [x] Add redacted `PromptTrace` records for NPC decisions, inspired by
+ Convai Mindview, showing which persona, memory, objective, knowledge pack,
+ world snapshot, source, latency, and validation result shaped each turn.
+- [x] Add NPC knowledge packs for `zone_lore`, `profession_lore`,
+ `faction_lore`, `quest_lore`, and `private_memory` so NPC prompts retrieve
+ compact context instead of stuffing whole design docs into every call.
+- [x] Add `ConversationObjective` records with objective, trigger, section,
+ priority, turn cap, allowed intents, and completion state to reduce generic
+ repeated NPC chatter.
+- [x] Add NPC State of Mind runtime fields for mood, stress, dominant need,
+ last trigger, and last plan summary, then surface them in the Unity debug UI.
- [ ] Separate client, internal worker, and admin RPC namespaces with explicit
auth and secret boundaries.
- [ ] Define shard-ready account routing and database-per-shard operations
@@ -193,7 +216,15 @@ MVP, and a visible offline-agent prototype.
offline-agent and NPC simulation instead of long-running Nakama request loops.
- [ ] Add the future voice-session Nakama RPC that mints short-lived
`api.dos.ai` voice tokens without exposing provider keys to Unity.
-- [ ] Wire Convai phase 1 NPC dialogue through the server-side intent boundary.
+- [ ] Add per-NPC `voice_profile` assignment for permanent NPCs, including
+ voice id, language, speaking style, pitch/rate hints, emotional range, and
+ fallback text-only behavior. Each important NPC should keep a stable voice
+ across sessions and body respawns unless lore explicitly changes it.
+- [ ] Add NPC TTS playback in Unity through scoped `api.dos.ai` voice sessions:
+ Nakama owns authorization, `api.dos.ai` owns provider routing, and Unity only
+ receives short-lived playback/session material.
+- [ ] Decide whether Convai remains a phase 1 spike for one boss or hub NPC.
+ The default long-term backbone stays Nakama plus Fusion plus `api.dos.ai`.
- [x] Add BodyTime meter MVP with one earn source and one spend sink.
- [x] Add reincarnation placeholder flow: death -> SECOND token check ->
respawn with current-body reset.
@@ -217,6 +248,8 @@ MVP, and a visible offline-agent prototype.
- [ ] Add one Hunter NFT skin equip placeholder with DOS Chain escrow design
still server-authoritative.
- [ ] Run Multiplayer Play Mode smoke for 2-4 local clients.
+- [ ] Add a browser/WebGL demo build lane so external playtesters can try the
+ vertical slice without installing the Unity editor or a native client.
- [ ] Resolve Unity Fusion CodeGen Play Mode smoke blocker tracked in issue #7.
- [ ] Prepare Linux headless dedicated server build path.
@@ -229,7 +262,8 @@ MVP, and a visible offline-agent prototype.
- [ ] Add multiple zones with travel.
- [ ] Add marketplace and NFT trade.
- [ ] Add guild system before PvP.
-- [ ] Add voice NPC if ephemeral-token cost and reliability are acceptable.
+- [ ] Add higher-quality NPC voice and interruption once ephemeral-token cost,
+ latency, reliability, and per-NPC voice identity are acceptable.
## Beta
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
index 1644aa9..f9c4659 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
@@ -46,6 +46,7 @@ public sealed class BodyProfileDto
public EquipmentLoadoutDto equipment;
public CharacterStatsDto stats;
public CharacterTraitsDto characteristics;
+ public BehaviorTendenciesDto behavior_tendencies;
public BodyStoryDto story;
public AnimationCapabilitiesDto animation_capabilities;
public BodyTimeDto time;
@@ -114,6 +115,18 @@ public sealed class CharacterTraitsDto
public int sociability = 5;
}
+ [Serializable]
+ public sealed class BehaviorTendenciesDto
+ {
+ public float talk_frequency = 0.5f;
+ public float approach_distance_meters = 4f;
+ public string idle_action = "observe_hub";
+ public string greeting_tone = "brief_cautious";
+ public string conflict_response = "avoid_risk";
+ public string movement_style = "cautious_patrol";
+ public float decision_interval_hint_seconds = 4f;
+ }
+
[Serializable]
public sealed class BodyStoryDto
{
@@ -215,6 +228,11 @@ public sealed class FrameHeartbeatDto
public string offline_session_state;
public string last_action_summary;
public string fallback_state;
+ public string mood;
+ public int stress;
+ public string dominant_need;
+ public string last_trigger;
+ public string last_plan_summary;
}
[Serializable]
@@ -532,6 +550,130 @@ public sealed class ChatSendResponseDto
public ChatMessageDto[] messages;
}
+ [Serializable]
+ public sealed class NpcPlayerChatEventRequestDto
+ {
+ public string id;
+ public string channel_id = "prototype-hub";
+ public string player_actor_id;
+ public string player_display_name;
+ public string message;
+ public string[] nearby_actor_ids;
+ }
+
+ [Serializable]
+ public sealed class NpcPlayerChatEventDto
+ {
+ public string id;
+ public string kind;
+ public string channel_id;
+ public string actor_id;
+ public string actor_display_name;
+ public string player_id;
+ public string player_actor_id;
+ public string player_display_name;
+ public string text;
+ public string[] target_actor_ids;
+ public string target_actor_id;
+ public string target_display_name;
+ public string target_player_actor_id;
+ public string intent_id;
+ public string conversation_session_id;
+ public string trigger_event_id;
+ public int recipient_count;
+ public string occurred_at;
+ public string source;
+ }
+
+ [Serializable]
+ public sealed class NpcPlayerChatRecipientDto
+ {
+ public string actor_id;
+ public string display_name;
+ public string activity_id;
+ }
+
+ [Serializable]
+ public sealed class NpcPlayerChatEventResponseDto
+ {
+ public string channel_id;
+ public NpcPlayerChatEventDto @event;
+ public NpcPlayerChatRecipientDto[] notified_actors;
+ public NpcPlayerChatEventDto[] events;
+ }
+
+ [Serializable]
+ public sealed class NpcSocietyTickRequestDto
+ {
+ public string channel_id = "prototype-hub";
+ public string trigger_event_id;
+ public int event_limit = 16;
+ public int max_decisions = 4;
+ }
+
+ [Serializable]
+ public sealed class NpcSocietyDecisionDto
+ {
+ public string id;
+ public string actor_id;
+ public string display_name;
+ public string trigger_event_id;
+ public string trigger_kind;
+ public string reason;
+ public int priority;
+ public string[] allowed_intents;
+ public string target_actor_id;
+ public string target_display_name;
+ public string player_message;
+ public string narrative_objective;
+ public string greeting_tone;
+ public float decision_interval_hint_seconds;
+ public string created_at;
+ }
+
+ [Serializable]
+ public sealed class NpcConversationSessionDto
+ {
+ public string session_id;
+ public string channel_id;
+ public string status;
+ public string topic;
+ public string objective;
+ public ConversationObjectiveDto conversation_objective;
+ public string[] participant_actor_ids;
+ public string trigger_event_id;
+ public int max_turns;
+ public int current_turn;
+ public string transcript_summary;
+ public string created_at;
+ public string updated_at;
+ }
+
+ [Serializable]
+ public sealed class ConversationObjectiveDto
+ {
+ public string objective_id;
+ public string objective;
+ public string section;
+ public string trigger_event_id;
+ public int priority;
+ public string[] allowed_intents;
+ public int turn_cap;
+ public string completion_state;
+ }
+
+ [Serializable]
+ public sealed class NpcSocietyTickResponseDto
+ {
+ public string channel_id;
+ public string orchestrator;
+ public int queue_depth;
+ public NpcSocietyDecisionDto[] decisions;
+ public NpcConversationSessionDto conversation_session;
+ public int events_considered;
+ public string boundary;
+ }
+
[Serializable]
public sealed class ChatListResponseDto
{
@@ -602,6 +744,10 @@ public sealed class NpcIntentSubmitRequestDto
public string id;
public string actor_id;
public string target_actor_id;
+ public string target_player_actor_id;
+ public string conversation_session_id;
+ public string trigger_event_id;
+ public string channel_id = "prototype-hub";
public string intent = "say";
public string source = "debug";
public string text;
@@ -617,7 +763,11 @@ public sealed class NpcIntentDto
public string source;
public string reason;
public string requested_at;
+ public string channel_id;
public string target_actor_id;
+ public string target_player_actor_id;
+ public string conversation_session_id;
+ public string trigger_event_id;
public NpcIntentPayloadDto payload;
}
@@ -626,6 +776,7 @@ public sealed class NpcIntentPayloadDto
{
public string text;
public string target_actor_id;
+ public string target_player_actor_id;
}
[Serializable]
@@ -636,6 +787,8 @@ public sealed class NpcIntentSubmitResponseDto
public NpcIntentDto intent;
public ActorProfileDto actor;
public ActorProfileDto target_actor;
+ public NpcPlayerChatEventDto society_event;
+ public NpcConversationSessionDto conversation_session;
}
[Serializable]
@@ -660,6 +813,59 @@ public sealed class NpcInteractionRulesDto
public string[] soft_prompt_guidance;
}
+ [Serializable]
+ public sealed class PromptTraceListRequestDto
+ {
+ public string scope = "agent_decide";
+ public int limit = 8;
+ }
+
+ [Serializable]
+ public sealed class PromptTraceListResponseDto
+ {
+ public string scope;
+ public PromptTraceDto[] traces;
+ public bool raw_prompt_stored;
+ }
+
+ [Serializable]
+ public sealed class PromptTraceDto
+ {
+ public string trace_id;
+ public string actor_id;
+ public string body_id;
+ public string display_name;
+ public string channel_id;
+ public string conversation_session_id;
+ public string model;
+ public string source;
+ public string source_reason;
+ public string action;
+ public string target_id;
+ public string validation_result;
+ public int latency_ms;
+ public int http_status;
+ public bool raw_prompt_stored;
+ public PromptTraceComponentsDto prompt_components;
+ public string created_at;
+ }
+
+ [Serializable]
+ public sealed class PromptTraceComponentsDto
+ {
+ public string persona_card;
+ public string[] allowed_actions;
+ public string[] memory_ids;
+ public int memory_count;
+ public string[] relationship_ids;
+ public int relationship_count;
+ public string[] knowledge_pack_ids;
+ public int knowledge_pack_count;
+ public int nearby_actor_count;
+ public int recent_speech_count;
+ public bool has_player_message;
+ }
+
[Serializable]
public sealed class AgentDecisionRequestDto
{
@@ -677,10 +883,22 @@ public sealed class WorldSnapshotDto
public WorldTargetDto[] nearby_targets;
public WorldObjectDto[] nearby_actors;
public WorldObjectDto[] nearby_objects;
+ public PlayerChatContextDto last_player_message;
+ public string[] recent_speech;
public int danger_level;
public long body_time_seconds;
}
+ [Serializable]
+ public sealed class PlayerChatContextDto
+ {
+ public string player_actor_id;
+ public string player_display_name;
+ public string text;
+ public float age_seconds;
+ public Vector2Dto position;
+ }
+
[Serializable]
public sealed class Vector2Dto
{
@@ -707,6 +925,7 @@ public sealed class WorldObjectDto
public int affinity;
public int hostility;
public float distance;
+ public Vector2Dto position;
}
[Serializable]
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs
index 930aa0e..e130c5f 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs
@@ -191,7 +191,15 @@ private IEnumerator ApplyProfileToLocalPlayerWhenAvailable()
yield return new WaitForSeconds(retryIntervalSeconds);
}
- Debug.LogWarning("[CharacterMemorySync] No local state-authority player was ready for profile body sync.");
+ var message = "[CharacterMemorySync] No local state-authority player was ready for profile body sync.";
+ if (Application.isBatchMode)
+ {
+ Debug.Log(message);
+ }
+ else
+ {
+ Debug.LogWarning(message);
+ }
}
private bool TryApplyProfileBody(BodyProfileDto body)
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/NpcDecisionRequestScheduler.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/NpcDecisionRequestScheduler.cs
new file mode 100644
index 0000000..984391c
--- /dev/null
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/NpcDecisionRequestScheduler.cs
@@ -0,0 +1,67 @@
+using System.Collections;
+using UnityEngine;
+
+namespace SecondSpawn.AI
+{
+ ///
+ /// Shared local prototype throttle for model-backed NPC decisions.
+ /// This is a client-side guard only; Nakama and api.dos.ai remain the
+ /// authoritative rate-limit and validation boundary.
+ ///
+ internal static class NpcDecisionRequestScheduler
+ {
+ private const int AutonomousConcurrency = 1;
+ private const int PriorityConcurrency = 2;
+ private const float AutonomousSpacingSeconds = 0.9f;
+ private const float PrioritySpacingSeconds = 0.225f;
+
+ private static int _activeRequests;
+ private static float _nextAutonomousRequestAt;
+ private static float _nextPriorityRequestAt;
+ private static float _playerChatFocusUntil;
+
+ public static bool IsAutonomousQueueFull => _activeRequests >= AutonomousConcurrency;
+ public static bool IsPlayerChatFocusActive => Time.realtimeSinceStartup < _playerChatFocusUntil;
+
+ public static void NotePlayerChatFocus(float seconds)
+ {
+ _playerChatFocusUntil = Mathf.Max(_playerChatFocusUntil, Time.realtimeSinceStartup + Mathf.Max(0f, seconds));
+ }
+
+ public static IEnumerator WaitForSlot(bool priority, System.Action onQueued = null)
+ {
+ while (true)
+ {
+ var maxConcurrent = priority ? PriorityConcurrency : AutonomousConcurrency;
+ var nextAt = priority ? _nextPriorityRequestAt : _nextAutonomousRequestAt;
+ var spacingWait = nextAt - Time.realtimeSinceStartup;
+ var playerChatPause = !priority && IsPlayerChatFocusActive;
+ if (!playerChatPause && _activeRequests < maxConcurrent && spacingWait <= 0f)
+ {
+ break;
+ }
+
+ onQueued?.Invoke();
+ yield return new WaitForSeconds(playerChatPause
+ ? 0.25f
+ : Mathf.Clamp(spacingWait, 0.05f, 0.25f));
+ }
+
+ _activeRequests++;
+ var spacing = priority ? PrioritySpacingSeconds : AutonomousSpacingSeconds;
+ if (priority)
+ {
+ _nextPriorityRequestAt = Time.realtimeSinceStartup + spacing;
+ }
+ else
+ {
+ _nextAutonomousRequestAt = Time.realtimeSinceStartup + spacing;
+ }
+ }
+
+ public static void ReleaseSlot()
+ {
+ _activeRequests = Mathf.Max(0, _activeRequests - 1);
+ }
+ }
+}
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/NpcDecisionRequestScheduler.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/NpcDecisionRequestScheduler.cs.meta
new file mode 100644
index 0000000..cbfb4d1
--- /dev/null
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/NpcDecisionRequestScheduler.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1b6e5b0dbf7144df8efbdb77d071b80f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PermanentNpcPrototypeSpawner.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PermanentNpcPrototypeSpawner.cs
index b24a26f..ce266e3 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/AI/PermanentNpcPrototypeSpawner.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PermanentNpcPrototypeSpawner.cs
@@ -18,21 +18,21 @@ public sealed class PermanentNpcPrototypeSpawner : MonoBehaviour
[SerializeField, Range(1, 50)] private int _maxNpcMarkers = 10;
[SerializeField] private Vector3 _spawnOrigin = new Vector3(-8f, 0f, 7f);
[SerializeField, Min(1)] private int _columns = 5;
- [SerializeField, Min(0.5f)] private float _spacing = 4f;
+ [SerializeField, Min(0.5f)] private float _spacing = 5.5f;
[SerializeField, Min(0.5f)] private float _markerHeight = 1.8f;
[SerializeField, Min(0.1f)] private float _markerRadius = 0.45f;
- [SerializeField, Min(0.5f)] private float _labelHeight = 2.65f;
+ [SerializeField, Min(0.5f)] private float _labelHeight = 3.05f;
[SerializeField, Range(12, 24)] private int _labelFontSize = 18;
- [SerializeField, Range(8, 32)] private int _labelMaxLineLength = 20;
- [SerializeField, Min(96f)] private float _labelMaxWidth = 260f;
+ [SerializeField, Range(8, 32)] private int _labelMaxLineLength = 30;
+ [SerializeField, Min(96f)] private float _labelMaxWidth = 320f;
[SerializeField, Min(1f)] private float _labelVisibleDistance = 24f;
[SerializeField] private Key _refreshKey = Key.F6;
[SerializeField] private bool _logStatus = true;
[SerializeField] private string _zoneId = "prototype-hub";
[SerializeField] private bool _attachAgentBrains = true;
- [SerializeField, Min(0.25f)] private float _agentDecisionIntervalSeconds = 30f;
- [SerializeField, Min(0f)] private float _agentStartStaggerSeconds = 5f;
- [SerializeField, Min(0.5f)] private float _agentPatrolRadius = 5f;
+ [SerializeField, Min(0.25f)] private float _agentDecisionIntervalSeconds = 4f;
+ [SerializeField, Min(0f)] private float _agentStartStaggerSeconds = 0.35f;
+ [SerializeField, Min(0.5f)] private float _agentPatrolRadius = 3.25f;
[SerializeField] private bool _agentPhaseLogs = false;
private const string RootName = "_PermanentNpcMarkers";
@@ -390,9 +390,10 @@ public sealed class PrototypeScreenSpaceLabel : MonoBehaviour
private PrototypeAgentBrain _brain;
private GUIContent _content;
private GUIStyle _labelStyle;
- private GUIStyle _shadowStyle;
- private string _cachedDisplayText = "";
- private Color _cachedLabelColor = new Color(0.92f, 0.94f, 0.96f);
+ private GUIStyle _statusStyle;
+ private string _cachedNameText = "";
+ private string _cachedStatusText = "";
+ private Color _cachedStatusColor = new Color(0.82f, 0.86f, 0.9f);
private float _cachedHeight;
private float _nextStatusRefreshAt;
@@ -402,12 +403,13 @@ public void Configure(string text, float visibleDistance, int fontSize, float ma
_visibleDistance = Mathf.Max(1f, visibleDistance);
_fontSize = Mathf.Clamp(fontSize, 12, 24);
_maxWidth = Mathf.Max(96f, maxWidth);
- _cachedDisplayText = _text;
- _cachedLabelColor = new Color(0.92f, 0.94f, 0.96f);
+ _cachedNameText = _text;
+ _cachedStatusText = "";
+ _cachedStatusColor = new Color(0.82f, 0.86f, 0.9f);
_cachedHeight = _fontSize * 2.25f;
_nextStatusRefreshAt = 0f;
_content ??= new GUIContent();
- _content.text = _cachedDisplayText;
+ _content.text = _cachedNameText;
}
private void OnGUI()
@@ -443,15 +445,22 @@ private void OnGUI()
EnsureStyles();
RefreshDisplayCache(force: false);
- var content = _content ??= new GUIContent(_cachedDisplayText);
- _labelStyle.normal.textColor = _cachedLabelColor;
var height = _cachedHeight;
var x = Mathf.Clamp(screenPoint.x - _maxWidth * 0.5f, ScreenPadding, Screen.width - _maxWidth - ScreenPadding);
var y = Mathf.Clamp(Screen.height - screenPoint.y - height * 0.5f, ScreenPadding, Screen.height - height - ScreenPadding);
var rect = new Rect(x, y, _maxWidth, height);
+ var nameRect = new Rect(rect.x, rect.y, rect.width, Mathf.Max(_fontSize * 2.2f, rect.height - _fontSize - 4f));
+ var statusRect = new Rect(rect.x, nameRect.yMax - 1f, rect.width, _fontSize + 8f);
- GUI.Label(new Rect(rect.x + 1f, rect.y + 1f, rect.width, rect.height), content, _shadowStyle);
- GUI.Label(rect, content, _labelStyle);
+ _content.text = _cachedNameText;
+ GUI.Label(nameRect, _content, _labelStyle);
+
+ if (!string.IsNullOrWhiteSpace(_cachedStatusText))
+ {
+ _content.text = _cachedStatusText;
+ _statusStyle.normal.textColor = _cachedStatusColor;
+ GUI.Label(statusRect, _content, _statusStyle);
+ }
}
private static Camera GetMainCameraCached()
@@ -476,17 +485,14 @@ private void RefreshBrainReference()
_brain = GetComponentInParent();
}
- private string BuildDisplayText()
+ private string BuildStatusText()
{
if (_brain == null || string.IsNullOrWhiteSpace(_brain.BrainStatusLabel))
{
- return _text;
+ return "";
}
- var reason = string.IsNullOrWhiteSpace(_brain.BrainStatusReason)
- ? ""
- : $" {_brain.BrainStatusReason.Trim()}";
- return $"{_text}\n{_brain.BrainStatusLabel}{reason}";
+ return _brain.BrainStatusLabel.Trim();
}
private Color ResolveLabelColor()
@@ -503,19 +509,22 @@ private void RefreshDisplayCache(bool force)
_nextStatusRefreshAt = Time.unscaledTime + StatusRefreshSeconds;
RefreshBrainReference();
- var nextText = BuildDisplayText();
+ var nextStatus = BuildStatusText();
var nextColor = ResolveLabelColor();
- if (!force && nextText == _cachedDisplayText && nextColor == _cachedLabelColor)
+ if (!force && nextStatus == _cachedStatusText && nextColor == _cachedStatusColor)
{
return;
}
EnsureStyles();
- _cachedDisplayText = nextText;
- _cachedLabelColor = nextColor;
+ _cachedNameText = _text;
+ _cachedStatusText = nextStatus;
+ _cachedStatusColor = nextColor;
_content ??= new GUIContent();
- _content.text = _cachedDisplayText;
- _cachedHeight = _labelStyle.CalcHeight(_content, _maxWidth);
+ _content.text = string.IsNullOrWhiteSpace(_cachedStatusText)
+ ? _cachedNameText
+ : _cachedNameText + "\n" + _cachedStatusText;
+ _cachedHeight = _labelStyle.CalcHeight(_content, _maxWidth) + _fontSize * 0.35f;
}
private void EnsureStyles()
@@ -534,10 +543,12 @@ private void EnsureStyles()
normal = { textColor = new Color(0.92f, 0.94f, 0.96f) }
};
- _shadowStyle = new GUIStyle(_labelStyle)
+ _statusStyle = new GUIStyle(_labelStyle)
{
- normal = { textColor = new Color(0f, 0f, 0f, 0.72f) }
+ fontSize = Mathf.Max(11, _fontSize - 2),
+ normal = { textColor = new Color(0.82f, 0.86f, 0.9f) }
};
+
}
}
}
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs
index 53d0ab3..f09db38 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs
@@ -34,16 +34,18 @@ private enum BrainPhase
[SerializeField] private string _zoneId = "prototype-hub";
[SerializeField] private int _visualVariant = 10;
[SerializeField] private float _decisionIntervalSeconds = 1.6f;
- [SerializeField, Min(1f)] private float _modelFailureCooldownSeconds = 120f;
- [SerializeField, Min(0f)] private float _decisionCooldownJitterSeconds = 45f;
+ [SerializeField, Min(1f)] private float _modelFailureCooldownSeconds = 12f;
+ [SerializeField, Min(0f)] private float _decisionCooldownJitterSeconds = 1.5f;
[SerializeField] private float _initialDecisionDelaySeconds;
[SerializeField] private float _moveSpeed = 2.4f;
[SerializeField] private float _patrolRadius = 5f;
[SerializeField] private float _socialSenseRadius = 8f;
[SerializeField] private float _playerGreetingRadius = 4.5f;
- [SerializeField] private float _playerGreetingCooldownSeconds = 12f;
+ [SerializeField] private float _playerGreetingCooldownSeconds = 4f;
[SerializeField] private int _maxNearbySocialActors = 3;
- [SerializeField] private float _talkIntervalSeconds = 7.5f;
+ [SerializeField] private float _talkIntervalSeconds = 4f;
+ [SerializeField, Min(0.25f)] private float _personalSpaceRadius = 2.1f;
+ [SerializeField, Min(0f)] private float _separationSpeed = 2.4f;
[SerializeField] private bool _seedSoulOnStart = true;
[SerializeField] private bool _alignFeetToGround = true;
[SerializeField] private bool _logPhaseTransitions = true;
@@ -60,6 +62,7 @@ private enum BrainPhase
private Animator _animator;
private GameObject _visualRoot;
private Coroutine _brainLoop;
+ private Coroutine _playerChatResponseLoop;
private Vector3 _homePosition;
private Vector3 _moveTarget;
private float _baseMoveSpeed;
@@ -72,10 +75,32 @@ private enum BrainPhase
private float _stableDecisionJitterSeconds;
private float _intentPersistenceBackoffUntil;
private ActorProfileDto _configuredActorProfile;
+ private BehaviorTendenciesDto _behaviorTendencies;
private readonly List _phaseTrace = new List();
- private bool _hasDecisionSlot;
- private static int _activeDecisionRequests;
- private const int MaxConcurrentDecisionRequests = 1;
+ private readonly Queue _recentSpeechLines = new Queue();
+ private readonly Dictionary _talkHiddenWeaponRenderers = new Dictionary();
+ private string _pendingPlayerChatText;
+ private string _pendingPlayerChatActorId;
+ private string _pendingPlayerChatDisplayName;
+ private string _pendingConversationSessionId;
+ private string _pendingTriggerEventId;
+ private Vector3 _pendingPlayerChatPosition;
+ private float _pendingPlayerChatAt;
+ private bool _pendingMessageFromNpc;
+ private float _nextNpcSpeechResponseAt;
+ private float _talkVisualUntil;
+ private float _dialogHoldUntil;
+ private Vector3 _dialogLookPosition;
+ private bool _hasDialogLookPosition;
+ private int _heldDecisionSlots;
+ private bool _playerChatDecisionInFlight;
+ private const float PlayerChatContextLifetimeSeconds = 18f;
+ private const float NpcSpeechResponseCooldownSeconds = 8f;
+ private const float PlayerChatFocusSeconds = 6f;
+ private const float DialogHoldSeconds = 18f;
+ private const float TalkVisualHoldSeconds = 2.8f;
+ private const int RecentSpeechMemoryLimit = 5;
+ private static readonly List ActiveBrains = new List();
public string BrainStatusLabel { get; private set; } = "AI booting";
public Color BrainStatusColor { get; private set; } = new Color(0.82f, 0.86f, 0.9f);
@@ -112,6 +137,14 @@ private void LateUpdate()
}
}
+ private void OnEnable()
+ {
+ if (!ActiveBrains.Contains(this))
+ {
+ ActiveBrains.Add(this);
+ }
+ }
+
public void StartBrain()
{
if (_brainLoop != null)
@@ -145,7 +178,14 @@ public void StopBrain()
_brainLoop = null;
}
- ReleaseDecisionSlot();
+ if (_playerChatResponseLoop != null)
+ {
+ StopCoroutine(_playerChatResponseLoop);
+ _playerChatResponseLoop = null;
+ }
+
+ _playerChatDecisionInFlight = false;
+ ReleaseAllHeldDecisionSlots();
_hasMoveTarget = false;
ApplyLocomotion(0f);
LogPhase(BrainPhase.Idle, "brain stopped");
@@ -153,6 +193,8 @@ public void StopBrain()
private void OnDisable()
{
+ ActiveBrains.Remove(this);
+ RestoreWeaponProps();
StopBrain();
}
@@ -161,6 +203,100 @@ public void SetPhaseLogging(bool enabled)
_logPhaseTransitions = enabled;
}
+ public string AgentId => string.IsNullOrWhiteSpace(_agentId) ? name : _agentId.Trim();
+ public string DisplayName => string.IsNullOrWhiteSpace(_displayName) ? name : _displayName.Trim();
+
+ public void NotifyNearbyPlayerChat(
+ string message,
+ string playerActorId,
+ string playerDisplayName,
+ Vector3 playerPosition,
+ string conversationSessionId = "",
+ string triggerEventId = "")
+ {
+ if (string.IsNullOrWhiteSpace(message))
+ {
+ return;
+ }
+
+ NpcDecisionRequestScheduler.NotePlayerChatFocus(PlayerChatFocusSeconds);
+ QueueSpeechStimulus(
+ message,
+ string.IsNullOrWhiteSpace(playerActorId) ? "player-body-local" : playerActorId.Trim(),
+ string.IsNullOrWhiteSpace(playerDisplayName) ? "Player" : playerDisplayName.Trim(),
+ playerPosition,
+ false,
+ conversationSessionId,
+ triggerEventId);
+ }
+
+ public void NotifyNearbyNpcSpeech(
+ string message,
+ string speakerActorId,
+ string speakerDisplayName,
+ Vector3 speakerPosition,
+ string conversationSessionId = "",
+ string triggerEventId = "")
+ {
+ if (string.IsNullOrWhiteSpace(message))
+ {
+ return;
+ }
+
+ if (NpcDecisionRequestScheduler.IsPlayerChatFocusActive)
+ {
+ SetBrainStatus("AI listening", new Color(0.82f, 0.86f, 0.9f), "player chat focus");
+ return;
+ }
+
+ if (Time.time < _nextNpcSpeechResponseAt)
+ {
+ SetBrainStatus("AI listening", new Color(0.82f, 0.86f, 0.9f), "npc speech cooldown");
+ return;
+ }
+
+ _nextNpcSpeechResponseAt = Time.time + NpcSpeechResponseCooldownSeconds + _stableDecisionJitterSeconds;
+ QueueSpeechStimulus(
+ message,
+ string.IsNullOrWhiteSpace(speakerActorId) ? "npc-unknown" : speakerActorId.Trim(),
+ string.IsNullOrWhiteSpace(speakerDisplayName) ? "Nearby NPC" : speakerDisplayName.Trim(),
+ speakerPosition,
+ true,
+ conversationSessionId,
+ triggerEventId);
+ }
+
+ private void QueueSpeechStimulus(
+ string message,
+ string actorId,
+ string displayName,
+ Vector3 actorPosition,
+ bool fromNpc,
+ string conversationSessionId,
+ string triggerEventId)
+ {
+ _pendingPlayerChatText = ShortenForLog(message, 180);
+ _pendingPlayerChatActorId = string.IsNullOrWhiteSpace(actorId) ? "actor-unknown" : actorId.Trim();
+ _pendingPlayerChatDisplayName = string.IsNullOrWhiteSpace(displayName) ? "Nearby Actor" : displayName.Trim();
+ _pendingConversationSessionId = string.IsNullOrWhiteSpace(conversationSessionId) ? "" : conversationSessionId.Trim();
+ _pendingTriggerEventId = string.IsNullOrWhiteSpace(triggerEventId) ? "" : triggerEventId.Trim();
+ _pendingPlayerChatPosition = actorPosition;
+ _pendingPlayerChatAt = Time.time;
+ _pendingMessageFromNpc = fromNpc;
+ _nextTalkAt = 0f;
+ _nextPlayerGreetingAt = 0f;
+ BeginDialogFocus(actorPosition, DialogHoldSeconds);
+
+ var stimulus = fromNpc ? "npc_speech" : "player_chat";
+ SetBrainStatus(fromNpc ? "AI heard NPC" : "AI heard player", new Color(0.72f, 0.95f, 0.82f), stimulus);
+ Debug.Log($"[PrototypeAgentBrain] Speech stimulus heard agent={AgentId}, stimulus={stimulus}, from={_pendingPlayerChatDisplayName}, message={_pendingPlayerChatText}");
+
+ if (_playerChatResponseLoop == null && isActiveAndEnabled)
+ {
+ _playerChatResponseLoop = StartCoroutine(PlayerChatResponseLoop());
+ }
+ }
+
public void ConfigureActorProfile(
ActorProfileDto profile,
string zoneId,
@@ -183,6 +319,7 @@ public void ConfigureActorProfile(
_initialDecisionDelaySeconds = Mathf.Max(0f, initialDecisionDelaySeconds);
_seedSoulOnStart = false;
_context = BuildContextFromActorProfile(profile);
+ ApplyBehaviorTendencies(profile.body?.behavior_tendencies);
var previousVariant = VisualPrefabCatalog.NormalizeVariant(_visualVariant);
var resolvedVariant = ResolveActorVisualVariant(profile);
@@ -200,6 +337,30 @@ public void ConfigureActorProfile(
ApplyContextToPrototypeBody();
}
+ private void ApplyBehaviorTendencies(BehaviorTendenciesDto tendencies)
+ {
+ if (tendencies == null)
+ {
+ _behaviorTendencies = null;
+ return;
+ }
+
+ _behaviorTendencies = tendencies;
+ var talkFrequency = Mathf.Clamp01(tendencies.talk_frequency);
+ if (tendencies.decision_interval_hint_seconds > 0f)
+ {
+ _decisionIntervalSeconds = Mathf.Clamp(tendencies.decision_interval_hint_seconds, 1.5f, 12f);
+ }
+
+ _talkIntervalSeconds = Mathf.Lerp(8f, 2.5f, talkFrequency);
+ _playerGreetingCooldownSeconds = Mathf.Lerp(8f, 2.5f, talkFrequency);
+ if (tendencies.approach_distance_meters > 0f)
+ {
+ _playerGreetingRadius = Mathf.Clamp(tendencies.approach_distance_meters, 2.25f, 8f);
+ _socialSenseRadius = Mathf.Max(_socialSenseRadius, _playerGreetingRadius + 2f);
+ }
+ }
+
private IEnumerator BrainLoop()
{
yield return BootstrapContext();
@@ -213,6 +374,19 @@ private IEnumerator BrainLoop()
while (enabled)
{
+ if (_playerChatDecisionInFlight)
+ {
+ yield return new WaitForSeconds(0.25f);
+ continue;
+ }
+
+ if (NpcDecisionRequestScheduler.IsPlayerChatFocusActive && !HasFreshPendingPlayerChat())
+ {
+ SetBrainStatus("AI listening", new Color(0.82f, 0.86f, 0.9f), "player dialog nearby");
+ yield return new WaitForSeconds(0.5f);
+ continue;
+ }
+
_loopSequence++;
LogPhase(BrainPhase.Sense, BuildSenseLogDetail());
SetBrainStatus("AI thinking", new Color(0.72f, 0.82f, 0.95f));
@@ -222,14 +396,16 @@ private IEnumerator BrainLoop()
AgentDecisionDto decision = null;
string decisionError = null;
- if (_activeDecisionRequests >= MaxConcurrentDecisionRequests)
+ var isPlayerChatDecision = HasFreshPendingPlayerChat();
+ if (!isPlayerChatDecision && NpcDecisionRequestScheduler.IsAutonomousQueueFull)
{
SetBrainStatus("AI queued", new Color(0.72f, 0.82f, 0.95f));
}
- yield return WaitForDecisionSlot();
- _activeDecisionRequests++;
- _hasDecisionSlot = true;
+ yield return NpcDecisionRequestScheduler.WaitForSlot(
+ isPlayerChatDecision,
+ () => SetBrainStatus(isPlayerChatDecision ? "AI answering" : "AI queued", new Color(0.72f, 0.82f, 0.95f)));
+ _heldDecisionSlots++;
SetBrainStatus("AI DOS.AI request", new Color(0.72f, 0.82f, 0.95f));
try
{
@@ -237,7 +413,7 @@ private IEnumerator BrainLoop()
}
finally
{
- ReleaseDecisionSlot();
+ ReleaseHeldDecisionSlot();
}
TrackDecisionResult(decisionError);
@@ -406,8 +582,16 @@ private AgentDecisionRequestDto BuildDecisionRequest()
var position = transform.position;
var nearbyObjects = BuildNearbyObjects(position);
var playerNearby = HasNearbyPlayer(nearbyObjects);
+ var pendingPlayerChat = BuildPendingPlayerChatContext(position);
var shouldTalk = Time.time >= _nextTalkAt ||
- (playerNearby && Time.time >= _nextPlayerGreetingAt);
+ (playerNearby && Time.time >= _nextPlayerGreetingAt) ||
+ pendingPlayerChat != null;
+
+ var allowedIntents = pendingPlayerChat != null
+ ? new[] { "say" }
+ : shouldTalk
+ ? new[] { "say", "move", "stop" }
+ : new[] { "move", "stop" };
return new AgentDecisionRequestDto
{
@@ -420,14 +604,53 @@ private AgentDecisionRequestDto BuildDecisionRequest()
danger_level = 0,
body_time_seconds = _context?.body?.time?.remaining_seconds ?? 3600,
nearby_actors = ExtractNearbyActors(nearbyObjects),
- nearby_objects = nearbyObjects
+ nearby_objects = nearbyObjects,
+ last_player_message = pendingPlayerChat,
+ recent_speech = RecentSpeechArray()
},
- allowed = shouldTalk
- ? new[] { "say", "stop" }
- : new[] { "move", "stop" }
+ allowed = allowedIntents
};
}
+ private PlayerChatContextDto BuildPendingPlayerChatContext(Vector3 npcPosition)
+ {
+ if (string.IsNullOrWhiteSpace(_pendingPlayerChatText))
+ {
+ return null;
+ }
+
+ var ageSeconds = Time.time - _pendingPlayerChatAt;
+ if (ageSeconds > PlayerChatContextLifetimeSeconds)
+ {
+ ClearPendingPlayerChat();
+ return null;
+ }
+
+ var distance = Vector3.Distance(npcPosition, _pendingPlayerChatPosition);
+ if (distance > Mathf.Max(_socialSenseRadius, _playerGreetingRadius))
+ {
+ return null;
+ }
+
+ return new PlayerChatContextDto
+ {
+ player_actor_id = _pendingPlayerChatActorId,
+ player_display_name = _pendingPlayerChatDisplayName,
+ text = _pendingPlayerChatText,
+ age_seconds = Mathf.Max(0f, ageSeconds),
+ position = new Vector2Dto
+ {
+ x = _pendingPlayerChatPosition.x,
+ z = _pendingPlayerChatPosition.z
+ }
+ };
+ }
+
+ private string[] RecentSpeechArray()
+ {
+ return _recentSpeechLines.Count == 0 ? null : _recentSpeechLines.ToArray();
+ }
+
private WorldObjectDto[] BuildNearbyObjects(Vector3 position)
{
var objects = new List
@@ -487,7 +710,12 @@ private void AddNearbyPlayers(List nearbyActors, Vector3 positio
role = "player-controlled Frame body",
affinity = 8,
hostility = 0,
- distance = distance
+ distance = distance,
+ position = new Vector2Dto
+ {
+ x = player.transform.position.x,
+ z = player.transform.position.z
+ }
});
}
}
@@ -497,7 +725,10 @@ private void AddNearbyNpcs(List nearbyActors, Vector3 position,
var brains = FindObjectsByType(FindObjectsInactive.Exclude);
foreach (var brain in brains)
{
- if (brain == null || brain == this || !brain.isActiveAndEnabled)
+ if (brain == null ||
+ brain == this ||
+ !brain.isActiveAndEnabled ||
+ brain.GetComponent() == null)
{
continue;
}
@@ -518,12 +749,17 @@ private void AddNearbyNpcs(List nearbyActors, Vector3 position,
: brain._context.body.identity.public_role,
affinity = 0,
hostility = 0,
- distance = distance
+ distance = distance,
+ position = new Vector2Dto
+ {
+ x = brain.transform.position.x,
+ z = brain.transform.position.z
+ }
});
}
}
- private static string BuildNearbyPlayerActorId(NetworkPlayer player)
+ public static string BuildNearbyPlayerActorId(NetworkPlayer player)
{
if (player == null)
{
@@ -581,6 +817,12 @@ private static bool IsNearbyPlayerActorId(string targetId)
targetId.StartsWith("player-body-", System.StringComparison.OrdinalIgnoreCase);
}
+ private static bool IsPermanentNpcActorId(string targetId)
+ {
+ return !string.IsNullOrWhiteSpace(targetId) &&
+ targetId.StartsWith("npc-", System.StringComparison.OrdinalIgnoreCase);
+ }
+
private static WorldObjectDto[] ExtractNearbyActors(WorldObjectDto[] nearbyObjects)
{
if (nearbyObjects == null || nearbyObjects.Length == 0)
@@ -684,16 +926,19 @@ private static bool CanInterruptCooldownForNearbyPlayer(AgentDecisionDto decisio
private IEnumerator WaitForCooldown(float cooldownSeconds, bool allowPlayerInterrupt)
{
- if (!allowPlayerInterrupt)
- {
- yield return new WaitForSeconds(cooldownSeconds);
- yield break;
- }
-
var endAt = Time.time + Mathf.Max(0f, cooldownSeconds);
while (Time.time < endAt)
{
- if (Time.time >= _nextPlayerGreetingAt &&
+ // Nearby player chat is a direct reactive stimulus. It should
+ // wake the NPC even if model fallback/backoff lengthened the
+ // normal autonomous decision cooldown.
+ if (HasFreshPendingPlayerChat())
+ {
+ yield break;
+ }
+
+ if (allowPlayerInterrupt &&
+ Time.time >= _nextPlayerGreetingAt &&
HasNearbyPlayer(BuildNearbyObjects(transform.position)))
{
yield break;
@@ -703,23 +948,70 @@ private IEnumerator WaitForCooldown(float cooldownSeconds, bool allowPlayerInter
}
}
- private static IEnumerator WaitForDecisionSlot()
+ private bool HasFreshPendingPlayerChat()
{
- while (_activeDecisionRequests >= MaxConcurrentDecisionRequests)
+ return !string.IsNullOrWhiteSpace(_pendingPlayerChatText) &&
+ Time.time - _pendingPlayerChatAt <= PlayerChatContextLifetimeSeconds;
+ }
+
+ private IEnumerator PlayerChatResponseLoop()
+ {
+ if (_playerChatDecisionInFlight)
+ {
+ yield break;
+ }
+
+ _playerChatDecisionInFlight = true;
+ yield return null;
+
+ AgentDecisionDto decision = null;
+ string decisionError = null;
+ var request = BuildDecisionRequest();
+ SetBrainStatus("AI answering", new Color(0.72f, 0.82f, 0.95f), "player chat");
+ var stimulus = _pendingMessageFromNpc ? "npc_speech" : "player_chat";
+ Debug.Log($"[PrototypeAgentBrain] Player chat decision request agent={AgentId}, stimulus={stimulus}, allowed={string.Join(",", request.allowed ?? new string[0])}, message={request.world_snapshot?.last_player_message?.text}");
+ yield return NpcDecisionRequestScheduler.WaitForSlot(
+ true,
+ () => SetBrainStatus("AI answering", new Color(0.72f, 0.82f, 0.95f)));
+ _heldDecisionSlots++;
+ try
{
- yield return null;
+ yield return _gateway.Decide(request, value => decision = value, error => decisionError = error);
}
+ finally
+ {
+ ReleaseHeldDecisionSlot();
+ }
+
+ TrackDecisionResult(decisionError);
+ UpdateBrainStatus(decision, decisionError);
+ LogDecisionOutcome("player_chat", request, decision, decisionError);
+ if (decision != null)
+ {
+ yield return ApplyDecision(decision, request);
+ }
+
+ _playerChatDecisionInFlight = false;
+ _playerChatResponseLoop = null;
}
- private void ReleaseDecisionSlot()
+ private void ReleaseHeldDecisionSlot()
{
- if (!_hasDecisionSlot)
+ if (_heldDecisionSlots <= 0)
{
return;
}
- _hasDecisionSlot = false;
- _activeDecisionRequests = Mathf.Max(0, _activeDecisionRequests - 1);
+ _heldDecisionSlots--;
+ NpcDecisionRequestScheduler.ReleaseSlot();
+ }
+
+ private void ReleaseAllHeldDecisionSlots()
+ {
+ while (_heldDecisionSlots > 0)
+ {
+ ReleaseHeldDecisionSlot();
+ }
}
private static bool IsModelBackoffReason(string reason)
@@ -760,10 +1052,12 @@ private void UpdateBrainStatus(AgentDecisionDto decision, string decisionError)
if (decision == null)
{
var hasError = !string.IsNullOrWhiteSpace(decisionError);
+ var extractedReason = ExtractDecisionReason(decisionError);
+ var isRecoverableModelError = IsRecoverableModelStatusReason(extractedReason);
SetBrainStatus(
- hasError ? "AI ERROR" : "AI idle",
- hasError ? new Color(1f, 0.24f, 0.22f) : new Color(0.82f, 0.86f, 0.9f),
- ExtractDecisionReason(decisionError));
+ hasError ? isRecoverableModelError ? "AI BACKOFF" : "AI ERROR" : "AI idle",
+ hasError ? isRecoverableModelError ? new Color(1f, 0.62f, 0.16f) : new Color(1f, 0.24f, 0.22f) : new Color(0.82f, 0.86f, 0.9f),
+ extractedReason);
return;
}
@@ -779,8 +1073,8 @@ private void UpdateBrainStatus(AgentDecisionDto decision, string decisionError)
if (IsModelBackoffReason(decision.source_reason))
{
SetBrainStatus(
- "AI ERROR",
- new Color(1f, 0.24f, 0.22f),
+ "AI BACKOFF",
+ new Color(1f, 0.62f, 0.16f),
FirstNonEmpty(decision.source_reason, ExtractDecisionReason(decisionError)));
return;
}
@@ -802,6 +1096,11 @@ private void SetBrainStatus(string label, Color color, string reason = "")
BrainStatusReason = FormatBrainStatusReason(reason);
}
+ private static bool IsRecoverableModelStatusReason(string reason)
+ {
+ return reason is "timeout" or "rate_limited" or "provider_502";
+ }
+
private static string FormatBrainStatusReason(string reason)
{
if (string.IsNullOrWhiteSpace(reason))
@@ -828,6 +1127,7 @@ private static string FormatBrainStatusReason(string reason)
"nakama_interact_fallback" => "safe interact",
"nakama_social_fallback" => "safe social",
"nakama_no_allowed_action" => "no safe action",
+ "nakama_auth_retry" => "Nakama auth",
_ => normalized
};
}
@@ -876,6 +1176,12 @@ private static string ExtractDecisionReason(string error)
return "timeout";
}
+ if (error.Contains("401", System.StringComparison.OrdinalIgnoreCase) ||
+ error.Contains("Auth token invalid", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return "nakama_auth_retry";
+ }
+
return "decision_error";
}
@@ -883,12 +1189,40 @@ private IEnumerator ApplyDecision(AgentDecisionDto decision, AgentDecisionReques
{
if (decision.action == "say")
{
- var text = string.IsNullOrWhiteSpace(decision.say)
- ? $"{_displayName} is watching the hub."
- : decision.say;
- _speechBubble.Show(text);
+ if (!IsModelDecisionSource(decision.source))
+ {
+ SetBrainStatus("AI FALLBACK", new Color(1f, 0.62f, 0.16f), decision.source_reason);
+ Debug.LogWarning($"[PrototypeAgentBrain] Suppressed non-model speech agent={AgentId}, source={decision.source}, source_reason={decision.source_reason}, action={decision.action}");
+ if (request.world_snapshot?.last_player_message != null)
+ {
+ ClearPendingPlayerChat();
+ }
+ yield break;
+ }
+
+ var text = string.IsNullOrWhiteSpace(decision.say) ? "" : decision.say;
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ SetBrainStatus("AI empty", new Color(1f, 0.62f, 0.16f), "empty model speech");
+ Debug.LogWarning($"[PrototypeAgentBrain] Suppressed empty model speech agent={AgentId}, source={decision.source}, source_reason={decision.source_reason}");
+ if (request.world_snapshot?.last_player_message != null)
+ {
+ ClearPendingPlayerChat();
+ }
+ yield break;
+ }
+
+ _hasMoveTarget = false;
+ FaceSpeechTarget(decision, request);
+ BeginTalkVisuals();
+ var routedToFocusedDialog = PrototypeNearbyNpcChatBox.TryRouteFocusedNpcSpeech(AgentId, DisplayName, text);
+ if (!routedToFocusedDialog)
+ {
+ _speechBubble.Show(text);
+ }
_voiceCue.PlayCue(text);
_intentDriver?.TryPlay(VisualAnimationIntent.Talk);
+ RememberSpeech(text);
_nextTalkAt = Time.time + Mathf.Max(2f, _talkIntervalSeconds);
if (IsNearbyPlayerActorId(decision.target_id) ||
HasNearbyPlayer(request.world_snapshot?.nearby_objects))
@@ -896,6 +1230,10 @@ private IEnumerator ApplyDecision(AgentDecisionDto decision, AgentDecisionReques
_nextPlayerGreetingAt = Time.time + Mathf.Max(2f, _playerGreetingCooldownSeconds);
}
yield return RecordModelNpcIntent(decision, request, text);
+ if (request.world_snapshot?.last_player_message != null)
+ {
+ ClearPendingPlayerChat();
+ }
yield break;
}
@@ -912,6 +1250,185 @@ private IEnumerator ApplyDecision(AgentDecisionDto decision, AgentDecisionReques
yield break;
}
+ private static bool IsModelDecisionSource(string source)
+ {
+ return string.Equals(source, "model", System.StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(source, "dos_ai_model", System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void LogDecisionOutcome(string origin, AgentDecisionRequestDto request, AgentDecisionDto decision, string decisionError)
+ {
+ if (decision == null)
+ {
+ Debug.LogWarning($"[PrototypeAgentBrain] Decision result origin={origin}, agent={AgentId}, decision=none, error={decisionError}");
+ return;
+ }
+
+ var hasPlayerMessage = request?.world_snapshot?.last_player_message != null;
+ if (hasPlayerMessage || !IsModelDecisionSource(decision.source))
+ {
+ var sayLength = string.IsNullOrWhiteSpace(decision.say) ? 0 : decision.say.Length;
+ Debug.Log($"[PrototypeAgentBrain] Decision result origin={origin}, agent={AgentId}, action={decision.action}, source={decision.source}, source_reason={decision.source_reason}, say_length={sayLength}, error={decisionError}");
+ }
+ }
+
+ private void BeginTalkVisuals()
+ {
+ _talkVisualUntil = Time.time + TalkVisualHoldSeconds;
+ ApplyLocomotion(0f);
+ }
+
+ public void BeginDialogFocus(Vector3 lookPosition, float seconds)
+ {
+ _hasMoveTarget = false;
+ _dialogHoldUntil = Mathf.Max(_dialogHoldUntil, Time.time + Mathf.Max(0f, seconds));
+ _dialogLookPosition = lookPosition;
+ _hasDialogLookPosition = true;
+ FacePosition(lookPosition);
+ ApplyLocomotion(0f);
+ SetBrainStatus("AI dialog", new Color(0.74f, 0.86f, 0.92f), "dialog focus");
+ }
+
+ public void EndDialogFocus()
+ {
+ _dialogHoldUntil = 0f;
+ _hasDialogLookPosition = false;
+ SetBrainStatus("AI ready", new Color(0.82f, 0.86f, 0.9f), "dialog ended");
+ }
+
+ private void FaceSpeechTarget(AgentDecisionDto decision, AgentDecisionRequestDto request)
+ {
+ var targetId = decision == null ? "" : decision.target_id;
+ if (!string.IsNullOrWhiteSpace(targetId) && TryResolveActorPosition(targetId, out var targetPosition))
+ {
+ FacePosition(targetPosition);
+ FaceActorBack(targetId, transform.position);
+ return;
+ }
+
+ var playerMessage = request?.world_snapshot?.last_player_message;
+ if (playerMessage?.position != null)
+ {
+ FacePosition(new Vector3(playerMessage.position.x, transform.position.y, playerMessage.position.z));
+ return;
+ }
+
+ var nearbyObjects = request?.world_snapshot?.nearby_objects;
+ if (nearbyObjects == null)
+ {
+ return;
+ }
+
+ for (var index = 0; index < nearbyObjects.Length; index++)
+ {
+ var actorId = nearbyObjects[index]?.id;
+ if (!string.IsNullOrWhiteSpace(actorId) && TryResolveActorPosition(actorId, out targetPosition))
+ {
+ FacePosition(targetPosition);
+ FaceActorBack(actorId, transform.position);
+ return;
+ }
+ }
+ }
+
+ private bool TryResolveActorPosition(string actorId, out Vector3 position)
+ {
+ position = default;
+ var normalized = string.IsNullOrWhiteSpace(actorId) ? "" : actorId.Trim();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return false;
+ }
+
+ var brains = FindObjectsByType(FindObjectsInactive.Exclude);
+ foreach (var brain in brains)
+ {
+ if (brain == null)
+ {
+ continue;
+ }
+
+ if (string.Equals(brain.AgentId, normalized, System.StringComparison.OrdinalIgnoreCase))
+ {
+ position = brain.transform.position;
+ return true;
+ }
+ }
+
+ var players = FindObjectsByType(FindObjectsInactive.Exclude);
+ foreach (var player in players)
+ {
+ if (player == null)
+ {
+ continue;
+ }
+
+ if (string.Equals(BuildNearbyPlayerActorId(player), normalized, System.StringComparison.OrdinalIgnoreCase))
+ {
+ position = player.transform.position;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void FaceActorBack(string actorId, Vector3 lookAtPosition)
+ {
+ var brains = FindObjectsByType(FindObjectsInactive.Exclude);
+ foreach (var brain in brains)
+ {
+ if (brain == null || brain == this)
+ {
+ continue;
+ }
+
+ if (string.Equals(brain.AgentId, actorId, System.StringComparison.OrdinalIgnoreCase))
+ {
+ brain.FacePosition(lookAtPosition);
+ return;
+ }
+ }
+ }
+
+ private void FacePosition(Vector3 position)
+ {
+ var direction = position - transform.position;
+ direction.y = 0f;
+ if (direction.sqrMagnitude <= 0.0001f)
+ {
+ return;
+ }
+
+ transform.rotation = Quaternion.LookRotation(direction.normalized, Vector3.up);
+ }
+
+ private void RememberSpeech(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return;
+ }
+
+ _recentSpeechLines.Enqueue(ShortenForLog(text, 160));
+ while (_recentSpeechLines.Count > RecentSpeechMemoryLimit)
+ {
+ _recentSpeechLines.Dequeue();
+ }
+ }
+
+ private void ClearPendingPlayerChat()
+ {
+ _pendingPlayerChatText = "";
+ _pendingPlayerChatActorId = "";
+ _pendingPlayerChatDisplayName = "";
+ _pendingConversationSessionId = "";
+ _pendingTriggerEventId = "";
+ _pendingPlayerChatAt = 0f;
+ _pendingPlayerChatPosition = default;
+ _pendingMessageFromNpc = false;
+ }
+
private IEnumerator RecordModelNpcIntent(AgentDecisionDto decision, AgentDecisionRequestDto request, string text)
{
if (_gateway == null || decision == null || request == null)
@@ -938,6 +1455,10 @@ private IEnumerator RecordModelNpcIntent(AgentDecisionDto decision, AgentDecisio
id = CharacterMemorySync.BuildClientEventId("npc-intent"),
actor_id = _agentId,
target_actor_id = targetId,
+ target_player_actor_id = IsNearbyPlayerActorId(decision.target_id) ? decision.target_id : null,
+ conversation_session_id = _pendingConversationSessionId,
+ trigger_event_id = _pendingTriggerEventId,
+ channel_id = _zoneId,
intent = "say",
source = "dos_ai_model",
text = text,
@@ -953,7 +1474,77 @@ private IEnumerator RecordModelNpcIntent(AgentDecisionDto decision, AgentDecisio
else
{
_intentPersistenceBackoffUntil = 0f;
+ NotifyTargetNpcOfSpeech(decision, request, text, response);
+ }
+ }
+
+ private void NotifyTargetNpcOfSpeech(
+ AgentDecisionDto decision,
+ AgentDecisionRequestDto request,
+ string text,
+ NpcIntentSubmitResponseDto response)
+ {
+ if (decision == null || string.IsNullOrWhiteSpace(text))
+ {
+ return;
+ }
+
+ var targetId = PersistentIntentTargetId(decision.target_id, request?.world_snapshot?.nearby_objects);
+ if (string.IsNullOrWhiteSpace(targetId) || string.Equals(targetId, AgentId, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ var target = ResolveBrain(targetId);
+ if (target == null)
+ {
+ return;
+ }
+
+ var sessionId = response?.conversation_session?.session_id ?? _pendingConversationSessionId;
+ if (!CanContinueConversation(response?.conversation_session))
+ {
+ return;
+ }
+
+ var triggerId = response?.society_event?.id ?? _pendingTriggerEventId;
+ target.NotifyNearbyNpcSpeech(text, AgentId, DisplayName, transform.position, sessionId, triggerId);
+ }
+
+ private static bool CanContinueConversation(NpcConversationSessionDto session)
+ {
+ if (session == null)
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrWhiteSpace(session.status) &&
+ !string.Equals(session.status, "open", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return session.max_turns <= 0 || session.current_turn < session.max_turns;
+ }
+
+ private static PrototypeAgentBrain ResolveBrain(string actorId)
+ {
+ if (string.IsNullOrWhiteSpace(actorId))
+ {
+ return null;
+ }
+
+ var normalized = actorId.Trim();
+ var brains = FindObjectsByType(FindObjectsInactive.Exclude);
+ foreach (var brain in brains)
+ {
+ if (brain != null && string.Equals(brain.AgentId, normalized, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return brain;
+ }
}
+
+ return null;
}
private static string PersistentIntentTargetId(string targetId, WorldObjectDto[] nearbyObjects)
@@ -969,9 +1560,14 @@ private static string PersistentIntentTargetId(string targetId, WorldObjectDto[]
return null;
}
+ if (!IsPermanentNpcActorId(normalizedTargetId))
+ {
+ return null;
+ }
+
if (nearbyObjects == null)
{
- return normalizedTargetId;
+ return null;
}
foreach (var nearbyObject in nearbyObjects)
@@ -981,12 +1577,13 @@ private static string PersistentIntentTargetId(string targetId, WorldObjectDto[]
continue;
}
- return string.Equals(nearbyObject.kind, "nearby_actor", System.StringComparison.OrdinalIgnoreCase)
+ return string.Equals(nearbyObject.kind, "nearby_actor", System.StringComparison.OrdinalIgnoreCase) &&
+ IsPermanentNpcActorId(normalizedTargetId)
? normalizedTargetId
: null;
}
- return normalizedTargetId;
+ return null;
}
private static float ResolveNearbyObjectDistance(WorldObjectDto[] objects, string targetId)
@@ -1076,13 +1673,43 @@ private void LogPhase(BrainPhase phase, string detail)
private void TickMovement()
{
+ var current = transform.position;
+ if (Time.time < _dialogHoldUntil)
+ {
+ if (_hasDialogLookPosition)
+ {
+ FacePosition(_dialogLookPosition);
+ }
+
+ ApplyLocomotion(0f);
+ return;
+ }
+
+ _hasDialogLookPosition = false;
+ var separation = ComputeCrowdSeparation(current);
+
if (!_hasMoveTarget)
{
+ if (separation.sqrMagnitude > 0.0001f)
+ {
+ var separated = ClampToPatrol(current + separation.normalized * (_separationSpeed * Time.deltaTime));
+ separated.y = current.y;
+ transform.position = separated;
+ if (separation.sqrMagnitude > 0.01f)
+ {
+ transform.rotation = Quaternion.Slerp(
+ transform.rotation,
+ Quaternion.LookRotation(separation.normalized),
+ 10f * Time.deltaTime);
+ }
+ ApplyLocomotion(0.2f);
+ return;
+ }
+
ApplyLocomotion(0f);
return;
}
- var current = transform.position;
var delta = _moveTarget - current;
delta.y = 0f;
if (delta.sqrMagnitude <= 0.04f)
@@ -1093,11 +1720,64 @@ private void TickMovement()
}
var direction = delta.normalized;
- transform.position = Vector3.MoveTowards(current, _moveTarget, _moveSpeed * Time.deltaTime);
+ if (separation.sqrMagnitude > 0.0001f)
+ {
+ direction = (direction + separation.normalized * 1.25f).normalized;
+ }
+
+ if (direction.sqrMagnitude <= 0.0001f)
+ {
+ direction = delta.normalized;
+ }
+
+ var next = current + direction * (_moveSpeed * Time.deltaTime);
+ next = ClampToPatrol(next);
+ next.y = current.y;
+ transform.position = next;
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), 12f * Time.deltaTime);
ApplyLocomotion(1f);
}
+ private Vector3 ComputeCrowdSeparation(Vector3 current)
+ {
+ var radius = Mathf.Max(0.25f, _personalSpaceRadius);
+ var radiusSqr = radius * radius;
+ var separation = Vector3.zero;
+
+ for (var index = 0; index < ActiveBrains.Count; index++)
+ {
+ var brain = ActiveBrains[index];
+ if (brain == null || brain == this || !brain.isActiveAndEnabled)
+ {
+ continue;
+ }
+
+ AddSeparationFrom(current, brain.transform.position, radius, radiusSqr, ref separation);
+ }
+
+ separation.y = 0f;
+ return separation.sqrMagnitude > 1f ? separation.normalized : separation;
+ }
+
+ private static void AddSeparationFrom(
+ Vector3 current,
+ Vector3 other,
+ float radius,
+ float radiusSqr,
+ ref Vector3 separation)
+ {
+ var away = current - other;
+ away.y = 0f;
+ var distanceSqr = away.sqrMagnitude;
+ if (distanceSqr <= 0.0001f || distanceSqr >= radiusSqr)
+ {
+ return;
+ }
+
+ var distance = Mathf.Sqrt(distanceSqr);
+ separation += away.normalized * ((radius - distance) / radius);
+ }
+
private Vector3 ClampToPatrol(Vector3 target)
{
var offset = target - _homePosition;
@@ -1376,22 +2056,95 @@ private static bool ExposesLocomotionContract(Animator animator)
private void ApplyLocomotion(float speed)
{
- if (_animator == null)
+ if (!CanWriteAnimatorParameters(_animator))
{
return;
}
+ var talking = speed <= 0.02f && Time.time < _talkVisualUntil;
+ if (talking)
+ {
+ SetWeaponPropVisibility(false);
+ }
+ else
+ {
+ RestoreWeaponProps();
+ }
+
SetBool("Moving", speed > 0.02f);
SetFloat("Velocity", speed);
SetFloat("Velocity X", 0f);
SetFloat("Velocity Z", speed);
SetFloat("AnimationSpeed", 1f);
SetFloat("Animation Speed", 1f);
- SetInt("Weapon", -1);
+ SetInt("Talking", talking ? 1 : 0);
+ SetInt("Weapon", talking
+ ? (int)CharacterWeaponStyle.Relax
+ : EquipmentVisualCatalog.GetAnimatorWeaponValue(ResolveEquipmentVisualId()));
+ }
+
+ private void SetWeaponPropVisibility(bool visible)
+ {
+ if (_visualRoot == null)
+ {
+ return;
+ }
+
+ foreach (var renderer in _visualRoot.GetComponentsInChildren(includeInactive: true))
+ {
+ if (renderer == null ||
+ !EquipmentVisualCatalog.IsWeaponProp(renderer.transform, _visualRoot.transform))
+ {
+ continue;
+ }
+
+ if (!_talkHiddenWeaponRenderers.ContainsKey(renderer))
+ {
+ _talkHiddenWeaponRenderers.Add(renderer, renderer.enabled);
+ }
+
+ renderer.enabled = visible ? _talkHiddenWeaponRenderers[renderer] : false;
+ }
+ }
+
+ private void RestoreWeaponProps()
+ {
+ if (_talkHiddenWeaponRenderers.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var entry in _talkHiddenWeaponRenderers)
+ {
+ if (entry.Key != null)
+ {
+ entry.Key.enabled = entry.Value;
+ }
+ }
+
+ _talkHiddenWeaponRenderers.Clear();
+ if (_visualRoot != null)
+ {
+ EquipmentVisualCatalog.ApplyEquipmentVisual(_visualRoot, ResolveEquipmentVisualId());
+ }
+ }
+
+ private static bool CanWriteAnimatorParameters(Animator animator)
+ {
+ return animator != null &&
+ animator.isActiveAndEnabled &&
+ animator.gameObject.activeInHierarchy &&
+ animator.isInitialized &&
+ animator.runtimeAnimatorController != null;
}
private void SetBool(string parameterName, bool value)
{
+ if (!CanWriteAnimatorParameters(_animator))
+ {
+ return;
+ }
+
foreach (var parameter in _animator.parameters)
{
if (parameter.name == parameterName && parameter.type == AnimatorControllerParameterType.Bool)
@@ -1404,6 +2157,11 @@ private void SetBool(string parameterName, bool value)
private void SetFloat(string parameterName, float value)
{
+ if (!CanWriteAnimatorParameters(_animator))
+ {
+ return;
+ }
+
foreach (var parameter in _animator.parameters)
{
if (parameter.name == parameterName && parameter.type == AnimatorControllerParameterType.Float)
@@ -1416,6 +2174,11 @@ private void SetFloat(string parameterName, float value)
private void SetInt(string parameterName, int value)
{
+ if (!CanWriteAnimatorParameters(_animator))
+ {
+ return;
+ }
+
foreach (var parameter in _animator.parameters)
{
if (parameter.name == parameterName && parameter.type == AnimatorControllerParameterType.Int)
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs
index dbeab32..d4a54e7 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs
@@ -9,6 +9,7 @@ namespace SecondSpawn.AI
[RequireComponent(typeof(SecondSpawnGatewayClient))]
public sealed class PrototypeNPCChatClient : MonoBehaviour
{
+ [SerializeField] private bool _enablePrototypeHotkeys;
[SerializeField] private string _npcId = "prototype-guide";
[SerializeField, TextArea] private string _prototypeMessage =
"What should this body remember while I am offline?";
@@ -24,6 +25,11 @@ private void Awake()
private void Update()
{
+ if (!_enablePrototypeHotkeys)
+ {
+ return;
+ }
+
var keyboard = Keyboard.current;
if (keyboard == null)
{
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs
new file mode 100644
index 0000000..1ed85fb
--- /dev/null
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs
@@ -0,0 +1,796 @@
+using System.Collections;
+using System.Collections.Generic;
+using SecondSpawn.Networking;
+using UnityEngine;
+using UnityEngine.InputSystem;
+
+namespace SecondSpawn.AI
+{
+ ///
+ /// Prototype chat box that lets the local player speak to nearby NPC agents.
+ /// NPC brains receive the line as context for their next server-validated
+ /// DOS.AI decision.
+ ///
+ [DisallowMultipleComponent]
+ [RequireComponent(typeof(SecondSpawnGatewayClient))]
+ public sealed class PrototypeNearbyNpcChatBox : MonoBehaviour
+ {
+ [SerializeField] private bool _showPanel = true;
+ [SerializeField] private bool _useLegacyImguiPanel;
+ [SerializeField] private Vector2 _panelSize = new Vector2(470f, 230f);
+ [SerializeField] private float _nearbyRadius = 8f;
+ [SerializeField] private int _maxRecipients = 4;
+ [SerializeField] private string _channelId = "prototype-hub";
+ [SerializeField] private string _displayName = "JOY";
+ [SerializeField] private string _draftMessage = "";
+ [SerializeField] private int _historyLimit = 6;
+ [SerializeField] private Key _talkNearestKey = Key.E;
+ [SerializeField, TextArea] private string _quickTalkMessage = "Can you brief me on this body and the yard?";
+
+ private const float InputFocusGraceSeconds = 0.35f;
+ private const float DialogInputLockSeconds = 18f;
+
+ private readonly List _history = new List();
+ private readonly List _dialogueLines = new List();
+ private SecondSpawnGatewayClient _gateway;
+ private GUIStyle _labelStyle;
+ private GUIStyle _mutedStyle;
+ private GUIStyle _inputStyle;
+ private bool _busy;
+ private string _status = "Nearby NPC chat ready";
+ private Vector2 _historyScrollPosition;
+ private bool _societyEventRpcAvailable = true;
+ private string _focusedNpcActorId;
+ private string _focusedNpcDisplayName;
+ private PrototypeAgentBrain _focusedNpcBrain;
+ private NetworkPlayer _cachedLocalPlayer;
+
+ public bool IsBusy => _busy;
+ public string Status => IsFocusedNpcActive()
+ ? $"{_status} - Chat Mode: Esc to quit"
+ : _status;
+ public IReadOnlyList History => _history;
+ public IReadOnlyList DialogueLines => _dialogueLines;
+ public bool IsChatModeActive => IsFocusedNpcActive();
+ public string FocusedNpcDisplayName => string.IsNullOrWhiteSpace(_focusedNpcDisplayName) ? "Nearby NPC" : _focusedNpcDisplayName;
+ public string DisplayName
+ {
+ get => SafeDisplayName();
+ set => _displayName = string.IsNullOrWhiteSpace(value) ? "Player" : value.Trim();
+ }
+
+ public static bool TryRouteFocusedNpcSpeech(string actorId, string displayName, string text)
+ {
+ var chat = FindAnyObjectByType();
+ return chat != null && chat.TryAddFocusedNpcSpeech(actorId, displayName, text);
+ }
+
+ public sealed class DialogueLine
+ {
+ public string speaker;
+ public string text;
+ public bool is_player;
+ }
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
+ private static void AttachToGatewayOnSceneLoad()
+ {
+ var gateway = FindAnyObjectByType();
+ if (gateway == null || gateway.GetComponent() != null)
+ {
+ return;
+ }
+
+ gateway.gameObject.AddComponent();
+ }
+
+ private void Awake()
+ {
+ _gateway = GetComponent();
+ }
+
+ private void Update()
+ {
+ MaintainDialogFacing();
+
+ var keyboard = Keyboard.current;
+ if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame && IsFocusedNpcActive())
+ {
+ ExitChatMode();
+ return;
+ }
+
+ if (keyboard == null || _busy || PrototypeInputFocusGate.IsTextInputFocused || IsChatFieldFocused())
+ {
+ return;
+ }
+
+ if (keyboard[_talkNearestKey].wasPressedThisFrame && CanTalkToNearestNpc())
+ {
+ SubmitNearestNpcTalk();
+ }
+ }
+
+ private void OnGUI()
+ {
+ if (!_useLegacyImguiPanel || !_showPanel)
+ {
+ return;
+ }
+
+ EnsureStyles();
+ var rect = new Rect(16f, Screen.height - _panelSize.y - 16f, _panelSize.x, _panelSize.y);
+ RefreshInputFocusGate(rect);
+ var submitRequested = ConsumeSubmitEventForChatField();
+ GUI.Box(rect, "Nearby NPC Chat");
+ GUILayout.BeginArea(new Rect(rect.x + 12f, rect.y + 24f, rect.width - 24f, rect.height - 32f));
+ GUILayout.Label(_status, _mutedStyle);
+ DrawHistory(Mathf.Max(48f, rect.height - 112f));
+
+ GUI.enabled = !_busy;
+ GUILayout.BeginHorizontal();
+ GUI.SetNextControlName("NearbyNpcChatDisplayField");
+ _displayName = GUILayout.TextField(SafeDisplayName(), _inputStyle, GUILayout.Width(90f));
+ GUI.SetNextControlName("NearbyNpcChatField");
+ _draftMessage = GUILayout.TextField(_draftMessage, _inputStyle);
+ RefreshInputFocusGate(rect);
+ if (GUILayout.Button("Send", GUILayout.Width(64f)) || submitRequested)
+ {
+ SubmitLocalPlayerMessage(_draftMessage);
+ }
+ GUILayout.EndHorizontal();
+ GUI.enabled = true;
+ GUILayout.EndArea();
+ }
+
+ private void OnDisable()
+ {
+ PrototypeInputFocusGate.Clear();
+ ClearFocusedNpc();
+ }
+
+ public void SubmitNearestNpcTalk()
+ {
+ if (_busy)
+ {
+ return;
+ }
+
+ var player = ResolveLocalPlayer();
+ var playerPosition = player != null ? player.transform.position : Vector3.zero;
+ var nearest = ResolveNearestNpcBrain(playerPosition);
+ if (nearest == null || string.IsNullOrWhiteSpace(nearest.AgentId))
+ {
+ _status = "No nearby NPC is close enough to talk.";
+ return;
+ }
+
+ var message = string.IsNullOrWhiteSpace(_quickTalkMessage) ? "Hey, can we talk?" : _quickTalkMessage.Trim();
+ FocusNpc(nearest);
+ EnterLocalDialogMode(player, nearest);
+ _status = $"Talking to {nearest.DisplayName}.";
+ Debug.Log($"[PrototypeNearbyNpcChatBox] Focused NPC chat target actor={nearest.AgentId}, name={nearest.DisplayName}");
+ StartCoroutine(SendNearbyMessage(message, new List { nearest.AgentId }));
+ }
+
+ private bool CanTalkToNearestNpc()
+ {
+ var player = ResolveLocalPlayer();
+ var playerPosition = player != null ? player.transform.position : Vector3.zero;
+ return ResolveNearestNpcBrain(playerPosition) != null;
+ }
+
+ public void SubmitLocalPlayerMessage(string message)
+ {
+ if (_busy || string.IsNullOrWhiteSpace(message))
+ {
+ return;
+ }
+
+ StartCoroutine(SendNearbyMessage(message.Trim(), ResolveFocusedNpcRecipient()));
+ }
+
+ private IEnumerator SendNearbyMessage(string message, List explicitActorIds)
+ {
+ _busy = true;
+ var player = ResolveLocalPlayer();
+ var playerPosition = player != null ? player.transform.position : Vector3.zero;
+ var playerActorId = player != null ? PrototypeAgentBrain.BuildNearbyPlayerActorId(player) : "player-body-local";
+ var displayName = SafeDisplayName();
+ var nearbyActorIds = explicitActorIds != null && explicitActorIds.Count > 0
+ ? explicitActorIds
+ : ResolveNearbyNpcActorIds(playerPosition);
+ RefreshDialogModeForRecipients(player, nearbyActorIds);
+ if (!IsFocusedNpcRecipient(nearbyActorIds))
+ {
+ ShowPlayerSpeechBubble(player, message);
+ }
+ AddHistory(displayName, message, true);
+ _draftMessage = "";
+ _status = nearbyActorIds.Count == 0
+ ? "No nearby NPC heard that line."
+ : IsFocusedNpcRecipient(nearbyActorIds)
+ ? $"Queued for {_focusedNpcDisplayName}."
+ : $"Queued for {nearbyActorIds.Count} nearby NPC{(nearbyActorIds.Count == 1 ? "" : "s")}.";
+ Debug.Log($"[PrototypeNearbyNpcChatBox] Player message route recipients={nearbyActorIds.Count}, focused={IsFocusedNpcRecipient(nearbyActorIds)}, message={Shorten(message, 80)}");
+
+ if (_gateway != null)
+ {
+ ChatSendResponseDto response = null;
+ string error = null;
+ yield return _gateway.SendHubChatMessage(new ChatSendRequestDto
+ {
+ channel_id = SafeChannelId(),
+ sender_display_name = displayName,
+ message = message,
+ source = "player_nearby_npc_chat"
+ }, value => response = value, value => error = value);
+
+ if (response == null)
+ {
+ _status = $"NPCs heard it locally. Chat log save failed: {Shorten(error, 80)}";
+ }
+
+ if (!_societyEventRpcAvailable)
+ {
+ NotifyNearbyNpcs(nearbyActorIds, message, playerActorId, displayName, playerPosition);
+ _status = "NPCs heard it locally. Society RPC is not loaded in Nakama yet.";
+ _busy = false;
+ yield break;
+ }
+
+ NpcPlayerChatEventResponseDto eventResponse = null;
+ error = null;
+ yield return _gateway.RecordNpcPlayerChatEvent(new NpcPlayerChatEventRequestDto
+ {
+ channel_id = SafeChannelId(),
+ player_actor_id = playerActorId,
+ player_display_name = displayName,
+ message = message,
+ nearby_actor_ids = nearbyActorIds.ToArray()
+ }, value => eventResponse = value, value => error = value);
+
+ if (eventResponse == null)
+ {
+ if (IsRpcNotFound(error))
+ {
+ _societyEventRpcAvailable = false;
+ }
+
+ _status = $"NPCs heard it locally. Society event save failed: {Shorten(error, 80)}";
+ NotifyNearbyNpcs(nearbyActorIds, message, playerActorId, displayName, playerPosition);
+ _busy = false;
+ yield break;
+ }
+
+ NpcSocietyTickResponseDto tickResponse = null;
+ error = null;
+ yield return _gateway.TickNpcSociety(new NpcSocietyTickRequestDto
+ {
+ channel_id = SafeChannelId(),
+ trigger_event_id = eventResponse.@event?.id,
+ event_limit = 16,
+ max_decisions = Mathf.Max(1, _maxRecipients)
+ }, value => tickResponse = value, value => error = value);
+
+ if (tickResponse == null)
+ {
+ _status = $"Society tick failed; using local fallback: {Shorten(error, 80)}";
+ NotifyNearbyNpcs(nearbyActorIds, message, playerActorId, displayName, playerPosition);
+ }
+ else
+ {
+ var delivered = ApplySocietyDecisions(tickResponse, playerPosition);
+ if (delivered == 0 && nearbyActorIds.Count > 0)
+ {
+ delivered = NotifyNearbyNpcs(nearbyActorIds, message, playerActorId, displayName, playerPosition);
+ _status = delivered == 0
+ ? "NPCs are nearby, but no local brain accepted the line."
+ : $"NPCs heard it locally; {delivered} brain response{(delivered == 1 ? "" : "s")} queued.";
+ }
+ else
+ {
+ _status = delivered == 0
+ ? "Society tick returned no local NPC decisions."
+ : $"Society queued {delivered} NPC response{(delivered == 1 ? "" : "s")}.";
+ }
+
+ Debug.Log($"[PrototypeNearbyNpcChatBox] Society tick delivered={delivered}, decisions={tickResponse.decisions?.Length ?? 0}, session={tickResponse.conversation_session?.session_id}");
+ }
+ }
+ else
+ {
+ NotifyNearbyNpcs(nearbyActorIds, message, playerActorId, displayName, playerPosition);
+ }
+
+ _busy = false;
+ }
+
+ private static void ShowPlayerSpeechBubble(NetworkPlayer player, string message)
+ {
+ if (player == null || string.IsNullOrWhiteSpace(message))
+ {
+ return;
+ }
+
+ var bubble = player.GetComponent();
+ if (bubble == null)
+ {
+ bubble = player.gameObject.AddComponent();
+ }
+
+ bubble.Show(message);
+ }
+
+ private List ResolveNearbyNpcActorIds(Vector3 playerPosition)
+ {
+ var brains = new List(FindObjectsByType(FindObjectsInactive.Exclude));
+ brains.Sort((left, right) =>
+ Vector3.Distance(playerPosition, left.transform.position).CompareTo(Vector3.Distance(playerPosition, right.transform.position)));
+
+ var recipients = new List();
+ var radius = Mathf.Max(0.5f, _nearbyRadius);
+ var maxRecipients = Mathf.Max(1, _maxRecipients);
+ foreach (var brain in brains)
+ {
+ if (!IsPermanentNpcBrain(brain) || Vector3.Distance(playerPosition, brain.transform.position) > radius)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrWhiteSpace(brain.AgentId) && !recipients.Contains(brain.AgentId))
+ {
+ recipients.Add(brain.AgentId);
+ }
+
+ if (recipients.Count >= maxRecipients)
+ {
+ break;
+ }
+ }
+
+ return recipients;
+ }
+
+ private PrototypeAgentBrain ResolveNearestNpcBrain(Vector3 playerPosition)
+ {
+ PrototypeAgentBrain nearest = null;
+ var nearestDistance = float.MaxValue;
+ var radius = Mathf.Max(0.5f, _nearbyRadius);
+ var brains = FindObjectsByType(FindObjectsInactive.Exclude);
+ foreach (var brain in brains)
+ {
+ if (!IsPermanentNpcBrain(brain) || string.IsNullOrWhiteSpace(brain.AgentId))
+ {
+ continue;
+ }
+
+ var distance = Vector3.Distance(playerPosition, brain.transform.position);
+ if (distance > radius || distance >= nearestDistance)
+ {
+ continue;
+ }
+
+ nearest = brain;
+ nearestDistance = distance;
+ }
+
+ return nearest;
+ }
+
+ private void FocusNpc(PrototypeAgentBrain brain)
+ {
+ if (brain == null || string.IsNullOrWhiteSpace(brain.AgentId))
+ {
+ ClearFocusedNpc();
+ return;
+ }
+
+ _focusedNpcActorId = brain.AgentId;
+ _focusedNpcDisplayName = brain.DisplayName;
+ _focusedNpcBrain = brain;
+ }
+
+ public void ExitChatMode()
+ {
+ var brain = ResolveFocusedBrain();
+ brain?.EndDialogFocus();
+ var player = ResolveLocalPlayer();
+ player?.EndDialogVisual();
+ PrototypeInputFocusGate.ClearDialogLock();
+ ClearFocusedNpc();
+ _status = "Chat Mode closed.";
+ Debug.Log("[PrototypeNearbyNpcChatBox] Dialog mode exited by Escape.");
+ }
+
+ private void ClearFocusedNpc()
+ {
+ _focusedNpcActorId = "";
+ _focusedNpcDisplayName = "";
+ _focusedNpcBrain = null;
+ }
+
+ private static void EnterLocalDialogMode(NetworkPlayer player, PrototypeAgentBrain brain)
+ {
+ if (player == null || brain == null)
+ {
+ return;
+ }
+
+ PrototypeInputFocusGate.LockForDialog(DialogInputLockSeconds);
+ player.FaceWorldPosition(brain.transform.position);
+ player.BeginDialogVisual(DialogInputLockSeconds);
+ brain.BeginDialogFocus(player.transform.position, DialogInputLockSeconds);
+ Debug.Log($"[PrototypeNearbyNpcChatBox] Dialog mode entered player={player.name}, npc={brain.DisplayName}, hold_seconds={DialogInputLockSeconds:0.0}");
+ }
+
+ private static void RefreshDialogModeForRecipients(NetworkPlayer player, List actorIds)
+ {
+ if (player == null || actorIds == null || actorIds.Count == 0)
+ {
+ return;
+ }
+
+ var nearest = ResolveNearestBrain(player.transform.position, actorIds);
+ if (nearest == null)
+ {
+ return;
+ }
+
+ EnterLocalDialogMode(player, nearest);
+ }
+
+ private void MaintainDialogFacing()
+ {
+ if (!IsFocusedNpcActive())
+ {
+ return;
+ }
+
+ var player = ResolveLocalPlayer();
+ var brain = ResolveFocusedBrain();
+ if (player == null || brain == null)
+ {
+ return;
+ }
+
+ PrototypeInputFocusGate.LockForDialog(0.4f);
+ player.FaceWorldPosition(brain.transform.position);
+ player.BeginDialogVisual(0.75f);
+ brain.BeginDialogFocus(player.transform.position, 0.25f);
+ }
+
+ private List ResolveFocusedNpcRecipient()
+ {
+ if (string.IsNullOrWhiteSpace(_focusedNpcActorId))
+ {
+ ClearFocusedNpc();
+ return null;
+ }
+
+ var brain = ResolveFocusedBrain();
+ if (brain == null)
+ {
+ ClearFocusedNpc();
+ return null;
+ }
+
+ return new List { _focusedNpcActorId };
+ }
+
+ private bool IsFocusedNpcRecipient(List actorIds)
+ {
+ return actorIds != null &&
+ actorIds.Count == 1 &&
+ !string.IsNullOrWhiteSpace(_focusedNpcActorId) &&
+ string.Equals(actorIds[0], _focusedNpcActorId, System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ private bool IsFocusedNpcActive()
+ {
+ return !string.IsNullOrWhiteSpace(_focusedNpcActorId);
+ }
+
+ private static bool IsPermanentNpcBrain(PrototypeAgentBrain brain)
+ {
+ return brain != null && brain.GetComponent() != null;
+ }
+
+ private int NotifyNearbyNpcs(List actorIds, string message, string playerActorId, string displayName, Vector3 playerPosition)
+ {
+ var delivered = 0;
+ foreach (var actorId in actorIds)
+ {
+ var brain = ResolveBrain(actorId);
+ if (brain == null)
+ {
+ continue;
+ }
+
+ brain.NotifyNearbyPlayerChat(message, playerActorId, displayName, playerPosition);
+ delivered++;
+ }
+
+ return delivered;
+ }
+
+ private int ApplySocietyDecisions(NpcSocietyTickResponseDto tickResponse, Vector3 playerPosition)
+ {
+ var decisions = tickResponse?.decisions;
+ if (decisions == null || decisions.Length == 0)
+ {
+ return 0;
+ }
+
+ var delivered = 0;
+ foreach (var decision in decisions)
+ {
+ if (decision == null || string.IsNullOrWhiteSpace(decision.actor_id) || string.IsNullOrWhiteSpace(decision.player_message))
+ {
+ continue;
+ }
+
+ var brain = ResolveBrain(decision.actor_id);
+ if (brain == null)
+ {
+ continue;
+ }
+
+ brain.NotifyNearbyPlayerChat(
+ decision.player_message,
+ string.IsNullOrWhiteSpace(decision.target_actor_id) ? "player-body-local" : decision.target_actor_id,
+ string.IsNullOrWhiteSpace(decision.target_display_name) ? "Player" : decision.target_display_name,
+ playerPosition,
+ tickResponse.conversation_session?.session_id,
+ decision.trigger_event_id);
+ delivered++;
+ }
+
+ return delivered;
+ }
+
+ private static PrototypeAgentBrain ResolveBrain(string actorId)
+ {
+ if (string.IsNullOrWhiteSpace(actorId))
+ {
+ return null;
+ }
+
+ var normalized = actorId.Trim();
+ var brains = FindObjectsByType(FindObjectsInactive.Exclude);
+ foreach (var brain in brains)
+ {
+ if (brain != null && string.Equals(brain.AgentId, normalized, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return brain;
+ }
+ }
+
+ return null;
+ }
+
+ private static PrototypeAgentBrain ResolveNearestBrain(Vector3 position, List actorIds)
+ {
+ PrototypeAgentBrain nearest = null;
+ var nearestDistance = float.MaxValue;
+ foreach (var actorId in actorIds)
+ {
+ var brain = ResolveBrain(actorId);
+ if (brain == null)
+ {
+ continue;
+ }
+
+ var distance = Vector3.Distance(position, brain.transform.position);
+ if (distance >= nearestDistance)
+ {
+ continue;
+ }
+
+ nearest = brain;
+ nearestDistance = distance;
+ }
+
+ return nearest;
+ }
+
+ private PrototypeAgentBrain ResolveFocusedBrain()
+ {
+ if (string.IsNullOrWhiteSpace(_focusedNpcActorId))
+ {
+ return null;
+ }
+
+ if (_focusedNpcBrain != null &&
+ string.Equals(_focusedNpcBrain.AgentId, _focusedNpcActorId, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return _focusedNpcBrain;
+ }
+
+ _focusedNpcBrain = ResolveBrain(_focusedNpcActorId);
+ return _focusedNpcBrain;
+ }
+
+ private NetworkPlayer ResolveLocalPlayer()
+ {
+ if (_cachedLocalPlayer != null &&
+ _cachedLocalPlayer.isActiveAndEnabled &&
+ _cachedLocalPlayer.Object != null &&
+ _cachedLocalPlayer.Object.HasInputAuthority)
+ {
+ return _cachedLocalPlayer;
+ }
+
+ var players = FindObjectsByType(FindObjectsInactive.Exclude);
+ foreach (var player in players)
+ {
+ if (player != null && player.Object != null && player.Object.HasInputAuthority)
+ {
+ _cachedLocalPlayer = player;
+ return player;
+ }
+ }
+
+ _cachedLocalPlayer = players.Length > 0 ? players[0] : null;
+ return _cachedLocalPlayer;
+ }
+
+ private void DrawHistory(float height)
+ {
+ _historyScrollPosition = GUILayout.BeginScrollView(_historyScrollPosition, false, true, GUILayout.Height(height));
+ if (_history.Count == 0)
+ {
+ GUILayout.Label("Type a line near NPCs. They receive it as LLM context.", _mutedStyle);
+ GUILayout.EndScrollView();
+ return;
+ }
+
+ var start = Mathf.Max(0, _history.Count - Mathf.Max(1, _historyLimit));
+ for (var index = start; index < _history.Count; index++)
+ {
+ GUILayout.Label(_history[index], _labelStyle);
+ }
+ GUILayout.EndScrollView();
+ }
+
+ private void AddHistory(string speaker, string text, bool isPlayer)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return;
+ }
+
+ var safeSpeaker = string.IsNullOrWhiteSpace(speaker) ? (isPlayer ? "Player" : "NPC") : speaker.Trim();
+ var line = $"{safeSpeaker}: {text}";
+ _history.Add(Shorten(line, 160));
+ _dialogueLines.Add(new DialogueLine
+ {
+ speaker = safeSpeaker,
+ text = Shorten(text, 220),
+ is_player = isPlayer
+ });
+
+ while (_history.Count > Mathf.Max(1, _historyLimit))
+ {
+ _history.RemoveAt(0);
+ }
+
+ while (_dialogueLines.Count > Mathf.Max(1, _historyLimit))
+ {
+ _dialogueLines.RemoveAt(0);
+ }
+ }
+
+ private bool TryAddFocusedNpcSpeech(string actorId, string displayName, string text)
+ {
+ if (!IsFocusedNpcActive() ||
+ string.IsNullOrWhiteSpace(text) ||
+ string.IsNullOrWhiteSpace(actorId) ||
+ !string.Equals(actorId.Trim(), _focusedNpcActorId, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ FocusNpc(ResolveBrain(actorId));
+ AddHistory(string.IsNullOrWhiteSpace(displayName) ? FocusedNpcDisplayName : displayName.Trim(), text, false);
+ _status = $"Talking to {FocusedNpcDisplayName}.";
+ return true;
+ }
+
+ private static bool ConsumeSubmitEventForChatField()
+ {
+ var current = Event.current;
+ var shouldSubmit = current != null &&
+ current.type == EventType.KeyDown &&
+ (current.keyCode == KeyCode.Return || current.keyCode == KeyCode.KeypadEnter) &&
+ GUI.GetNameOfFocusedControl() == "NearbyNpcChatField";
+
+ if (shouldSubmit)
+ {
+ current.Use();
+ }
+
+ return shouldSubmit;
+ }
+
+ private static bool IsChatFieldFocused()
+ {
+ return IsChatControlFocused();
+ }
+
+ private static bool IsChatControlFocused()
+ {
+ var focusedControl = GUI.GetNameOfFocusedControl();
+ return !string.IsNullOrWhiteSpace(focusedControl) &&
+ focusedControl.Contains("NearbyNpcChat", System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void RefreshInputFocusGate(Rect panelRect)
+ {
+ var chatFocused = IsChatControlFocused();
+ PrototypeInputFocusGate.SetTextInputFocused(chatFocused);
+
+ var current = Event.current;
+ if (chatFocused || IsPointerInsidePanel(current, panelRect))
+ {
+ PrototypeInputFocusGate.SuppressForSeconds(InputFocusGraceSeconds);
+ }
+ }
+
+ private static bool IsPointerInsidePanel(Event current, Rect panelRect)
+ {
+ if (current == null || !current.isMouse)
+ {
+ return false;
+ }
+
+ var guiPosition = current.mousePosition;
+ return panelRect.Contains(guiPosition);
+ }
+
+ private void EnsureStyles()
+ {
+ _labelStyle ??= new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 14,
+ normal = { textColor = Color.white },
+ wordWrap = true
+ };
+ _mutedStyle ??= new GUIStyle(_labelStyle)
+ {
+ normal = { textColor = new Color(0.75f, 0.82f, 0.86f) }
+ };
+ _inputStyle ??= new GUIStyle(GUI.skin.textField)
+ {
+ fontSize = 14
+ };
+ }
+
+ private string SafeChannelId()
+ {
+ return string.IsNullOrWhiteSpace(_channelId) ? "prototype-hub" : _channelId.Trim();
+ }
+
+ private string SafeDisplayName()
+ {
+ return string.IsNullOrWhiteSpace(_displayName) ? (_gateway != null ? _gateway.PlayerId : "Player") : _displayName.Trim();
+ }
+
+ private static string Shorten(string value, int maxLength)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return "";
+ }
+
+ var trimmed = value.Trim().Replace("\r", " ").Replace("\n", " ");
+ return trimmed.Length <= maxLength ? trimmed : trimmed.Substring(0, Mathf.Max(0, maxLength - 3)) + "...";
+ }
+
+ private static bool IsRpcNotFound(string error)
+ {
+ return !string.IsNullOrWhiteSpace(error) &&
+ error.IndexOf("RPC function not found", System.StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+ }
+}
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs.meta
new file mode 100644
index 0000000..a2975bf
--- /dev/null
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 92dd8945477acda47ae7893cf674cbba
\ No newline at end of file
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs
index 7b4884f..ce829d6 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs
@@ -5,154 +5,141 @@ namespace SecondSpawn.AI
[DisallowMultipleComponent]
public sealed class PrototypeSpeechBubble : MonoBehaviour
{
- [SerializeField] private Vector3 _localOffset = new(0f, 2.45f, 0f);
+ [SerializeField] private Vector3 _worldOffset = new(0f, 3.15f, 0f);
[SerializeField] private float _visibleSeconds = 4f;
- [SerializeField] private int _fontSize = 38;
+ [SerializeField] private int _fontSize = 18;
[SerializeField] private Color _textColor = Color.white;
- [SerializeField] private Color _bubbleColor = new(0.05f, 0.07f, 0.08f, 0.92f);
- [SerializeField] private Color _borderColor = new(0.72f, 0.9f, 1f, 0.72f);
- [SerializeField] private int _maxLineCharacters = 30;
+ [SerializeField] private Color _bubbleColor = new(0.03f, 0.05f, 0.06f, 0.88f);
+ [SerializeField] private Color _borderColor = new(0.72f, 0.9f, 1f, 0.78f);
+ [SerializeField] private int _maxLineCharacters = 28;
[SerializeField] private int _maxLines = 3;
+ [SerializeField] private float _maxVisibleDistance = 42f;
+ [SerializeField] private Vector2 _screenOffset = new(0f, -48f);
- private GameObject _root;
- private Transform _bodyTransform;
- private Transform _borderTransform;
- private TextMesh _textMesh;
+ private string _displayText = "";
+ private int _lineCount = 1;
+ private int _longestLineLength = 1;
private float _hideAt;
+ private GUIStyle _bubbleStyle;
+ private GUIStyle _borderStyle;
+ private GUIStyle _textStyle;
+ private Texture2D _bubbleTexture;
+ private Texture2D _borderTexture;
- private void Awake()
+ private void OnDestroy()
{
- EnsureBubble();
- }
-
- private void LateUpdate()
- {
- if (_root == null)
- {
- return;
- }
-
- var rootTransform = _root.transform;
- rootTransform.localPosition = _localOffset;
-
- var cam = Camera.main;
- if (cam != null)
+ if (_bubbleTexture != null)
{
- rootTransform.rotation = Quaternion.LookRotation(rootTransform.position - cam.transform.position);
+ Destroy(_bubbleTexture);
+ _bubbleTexture = null;
}
- if (_root.activeSelf && Time.time >= _hideAt)
+ if (_borderTexture != null)
{
- _root.SetActive(false);
+ Destroy(_borderTexture);
+ _borderTexture = null;
}
}
public void Show(string text)
{
- EnsureBubble();
- if (_textMesh == null)
- {
- return;
- }
-
var wrapped = Wrap(Clamp(text), _maxLineCharacters, _maxLines);
- _textMesh.text = wrapped.text;
- ResizeBubble(wrapped.longestLineLength, wrapped.lineCount);
- _root.SetActive(true);
+ _displayText = wrapped.text;
+ _lineCount = wrapped.lineCount;
+ _longestLineLength = wrapped.longestLineLength;
_hideAt = Time.time + _visibleSeconds;
}
- private void EnsureBubble()
+ private void OnGUI()
{
- if (_textMesh != null)
+ GUI.depth = -20;
+ if (Time.time >= _hideAt || string.IsNullOrWhiteSpace(_displayText))
{
return;
}
- _root = new GameObject("PrototypeSpeechBubble");
- _root.transform.SetParent(transform, false);
- _root.transform.localPosition = _localOffset;
+ var cam = Camera.main;
+ if (cam == null)
+ {
+ return;
+ }
- _borderTransform = CreateQuad("BubbleBorder", _borderColor, -0.02f).transform;
- _borderTransform.SetParent(_root.transform, false);
+ var worldPosition = transform.position + _worldOffset;
+ var screenPoint = cam.WorldToScreenPoint(worldPosition);
+ if (screenPoint.z <= 0f || screenPoint.z > _maxVisibleDistance)
+ {
+ return;
+ }
- _bodyTransform = CreateQuad("BubbleBody", _bubbleColor, -0.01f).transform;
- _bodyTransform.SetParent(_root.transform, false);
+ EnsureStyles();
- var textObject = new GameObject("BubbleText");
- textObject.transform.SetParent(_root.transform, false);
- textObject.transform.localPosition = new Vector3(0f, 0f, -0.03f);
+ var width = Mathf.Clamp(_longestLineLength * (_fontSize * 0.6f) + 34f, 150f, 340f);
+ var height = Mathf.Clamp(_lineCount * (_fontSize + 5f) + 22f, 50f, 124f);
+ var x = screenPoint.x - width * 0.5f + _screenOffset.x;
+ var y = Screen.height - screenPoint.y - height + _screenOffset.y;
+ var borderRect = new Rect(x - 2f, y - 2f, width + 4f, height + 4f);
+ var bodyRect = new Rect(x, y, width, height);
+ var textRect = new Rect(x + 10f, y + 7f, width - 20f, height - 14f);
- _textMesh = textObject.AddComponent();
- _textMesh.anchor = TextAnchor.MiddleCenter;
- _textMesh.alignment = TextAlignment.Center;
- _textMesh.fontSize = _fontSize;
- _textMesh.characterSize = 0.035f;
- _textMesh.color = _textColor;
- _textMesh.text = string.Empty;
- _root.SetActive(false);
+ GUI.Box(borderRect, GUIContent.none, _borderStyle);
+ GUI.Box(bodyRect, GUIContent.none, _bubbleStyle);
+ GUI.Label(textRect, _displayText, _textStyle);
}
- private GameObject CreateQuad(string name, Color color, float localZ)
+ private static string Clamp(string text)
{
- var quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
- quad.name = name;
- quad.transform.localPosition = new Vector3(0f, 0f, localZ);
-
- var collider = quad.GetComponent();
- if (collider != null)
+ if (string.IsNullOrWhiteSpace(text))
{
- Destroy(collider);
+ return "...";
}
- var renderer = quad.GetComponent();
- renderer.sharedMaterial = CreateBubbleMaterial(name, color);
- return quad;
+ text = text.Trim();
+ return text.Length <= 96 ? text : text[..96] + "...";
}
- private static Material CreateBubbleMaterial(string name, Color color)
+ private void EnsureStyles()
{
- var shader = Shader.Find("Sprites/Default") ??
- Shader.Find("Universal Render Pipeline/Unlit") ??
- Shader.Find("Unlit/Color");
- var material = new Material(shader)
- {
- name = name + "Material",
- color = color
- };
- if (material.HasProperty("_Cull"))
+ if (_textStyle != null)
{
- material.SetInt("_Cull", 0);
+ return;
}
- material.renderQueue = 3000;
- return material;
- }
- private void ResizeBubble(int longestLineLength, int lineCount)
- {
- var width = Mathf.Clamp(longestLineLength * 0.075f + 0.48f, 1.25f, 3.25f);
- var height = Mathf.Clamp(lineCount * 0.27f + 0.28f, 0.55f, 1.35f);
-
- if (_bodyTransform != null)
+ _bubbleTexture = CreateTexture(_bubbleColor);
+ _borderTexture = CreateTexture(_borderColor);
+ _bubbleStyle = new GUIStyle(GUI.skin.box)
{
- _bodyTransform.localScale = new Vector3(width, height, 1f);
- }
-
- if (_borderTransform != null)
+ normal = { background = _bubbleTexture },
+ border = new RectOffset(4, 4, 4, 4),
+ margin = new RectOffset(),
+ padding = new RectOffset()
+ };
+ _borderStyle = new GUIStyle(GUI.skin.box)
{
- _borderTransform.localScale = new Vector3(width + 0.08f, height + 0.08f, 1f);
- }
+ normal = { background = _borderTexture },
+ border = new RectOffset(4, 4, 4, 4),
+ margin = new RectOffset(),
+ padding = new RectOffset()
+ };
+ _textStyle = new GUIStyle(GUI.skin.label)
+ {
+ alignment = TextAnchor.MiddleCenter,
+ fontSize = Mathf.Clamp(_fontSize, 12, 28),
+ wordWrap = true,
+ clipping = TextClipping.Clip,
+ richText = false
+ };
+ _textStyle.normal.textColor = _textColor;
}
- private static string Clamp(string text)
+ private static Texture2D CreateTexture(Color color)
{
- if (string.IsNullOrWhiteSpace(text))
+ var texture = new Texture2D(1, 1)
{
- return "...";
- }
-
- text = text.Trim();
- return text.Length <= 96 ? text : text[..96] + "...";
+ hideFlags = HideFlags.HideAndDontSave
+ };
+ texture.SetPixel(0, 0, color);
+ texture.Apply();
+ return texture;
}
private static WrappedText Wrap(string text, int maxLineCharacters, int maxLines)
diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs
index baabfa3..c1db3d7 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs
@@ -56,6 +56,8 @@ public sealed class SecondSpawnGatewayClient : MonoBehaviour
private IEnumerator Start()
{
+ ApplyCommandLineOverrides();
+
if (_authenticateOnStart)
{
yield return Authenticate();
@@ -216,6 +218,16 @@ public IEnumerator ListHubChatMessages(ChatListRequestDto request, Action onSuccess, Action onError = null)
+ {
+ yield return SendNakamaRpc("secondspawn_npc_player_chat_event", request, onSuccess, onError);
+ }
+
+ public IEnumerator TickNpcSociety(NpcSocietyTickRequestDto request, Action onSuccess, Action onError = null)
+ {
+ yield return SendNakamaRpc("secondspawn_npc_society_tick", request, onSuccess, onError);
+ }
+
public IEnumerator SeedPermanentNpcs(Action onSuccess, Action onError = null)
{
yield return SendNakamaRpc("secondspawn_npc_seed", new EmptyPayload(), onSuccess, onError);
@@ -241,6 +253,11 @@ public IEnumerator SubmitPermanentNpcIntent(NpcIntentSubmitRequestDto request, A
yield return SendNakamaRpc("secondspawn_npc_intent_submit", request, onSuccess, onError);
}
+ public IEnumerator ListPromptTraces(PromptTraceListRequestDto request, Action onSuccess, Action onError = null)
+ {
+ yield return SendNakamaRpc("secondspawn_prompt_trace_list", request, onSuccess, onError);
+ }
+
public IEnumerator Decide(AgentDecisionRequestDto request, Action onSuccess, Action onError = null)
{
yield return SendNakamaRpc("secondspawn_agent_decide", request, onSuccess, onError, _agentDecisionRequestTimeoutSeconds);
@@ -313,16 +330,61 @@ private IEnumerator SendNakamaRpc(
}
var json = JsonUtility.ToJson(payload);
- yield return Send(BuildNakamaRpcRequest(rpcId, json), onSuccess, error =>
+ RpcAttemptResult firstAttempt = null;
+ yield return SendNakamaRpcOnce(rpcId, json, timeoutSecondsOverride, result => firstAttempt = result);
+ if (firstAttempt != null && firstAttempt.Succeeded)
{
- if (IsNakamaAuthInvalid(error))
- {
- ClearNakamaSession();
- Debug.LogWarning($"[SecondSpawnGatewayClient] Nakama session rejected for RPC {rpcId}. Cleared stale session; the next required Nakama RPC will authenticate again.");
- }
+ onSuccess?.Invoke(firstAttempt.Response);
+ yield break;
+ }
- onError?.Invoke(error);
- }, timeoutSecondsOverride);
+ var firstError = firstAttempt?.Error ?? "";
+ if (!IsNakamaAuthInvalid(firstError))
+ {
+ onError?.Invoke(firstError);
+ yield break;
+ }
+
+ ClearNakamaSession();
+ Debug.Log($"[SecondSpawnGatewayClient] Nakama session rejected for RPC {rpcId}. Re-authenticating and retrying once.");
+ var authError = "";
+ yield return Authenticate(null, error => authError = error);
+ if (!HasNakamaSession)
+ {
+ onError?.Invoke(string.IsNullOrWhiteSpace(authError) ? firstError : authError);
+ yield break;
+ }
+
+ RpcAttemptResult secondAttempt = null;
+ yield return SendNakamaRpcOnce(rpcId, json, timeoutSecondsOverride, result => secondAttempt = result);
+ if (secondAttempt != null && secondAttempt.Succeeded)
+ {
+ onSuccess?.Invoke(secondAttempt.Response);
+ yield break;
+ }
+
+ var secondError = secondAttempt?.Error ?? "";
+ if (IsNakamaAuthInvalid(secondError))
+ {
+ ClearNakamaSession();
+ }
+
+ onError?.Invoke(secondError);
+ }
+
+ private IEnumerator SendNakamaRpcOnce(
+ string rpcId,
+ string json,
+ int timeoutSecondsOverride,
+ Action> onComplete)
+ {
+ var result = new RpcAttemptResult();
+ yield return Send(BuildNakamaRpcRequest(rpcId, json), value =>
+ {
+ result.Succeeded = true;
+ result.Response = value;
+ }, error => result.Error = error, timeoutSecondsOverride);
+ onComplete?.Invoke(result);
}
private UnityWebRequest BuildNakamaRpcRequest(string rpcId, string json)
@@ -377,7 +439,8 @@ private IEnumerator AuthenticateNakamaDeviceFallback(Action onSuccess, Action nakamaSession = session, error =>
{
@@ -498,6 +561,50 @@ private static string ResolveValue(string serializedValue, params string[] envNa
return "";
}
+ private void ApplyCommandLineOverrides()
+ {
+ var args = Environment.GetCommandLineArgs();
+ _playerId = ResolveArgValue(args, "-secondspawn-player-id", _playerId);
+ _nakamaBaseUrl = ResolveArgValue(args, "-secondspawn-nakama-url", _nakamaBaseUrl);
+ _nakamaServerKey = ResolveArgValue(args, "-secondspawn-nakama-key", _nakamaServerKey);
+
+ if (HasArg(args, "-secondspawn-no-supabase"))
+ {
+ _supabaseUrl = "";
+ _supabaseAnonKey = "";
+ }
+ }
+
+ private static string ResolveArgValue(string[] args, string name, string fallback)
+ {
+ for (var index = 0; index < args.Length - 1; index += 1)
+ {
+ if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase))
+ {
+ var value = args[index + 1];
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value.Trim();
+ }
+ }
+ }
+
+ return fallback;
+ }
+
+ private static bool HasArg(string[] args, string name)
+ {
+ foreach (var arg in args)
+ {
+ if (string.Equals(arg, name, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private static string TrimTrailingSlash(string value)
{
return string.IsNullOrWhiteSpace(value) ? "" : value.Trim().TrimEnd('/');
@@ -574,6 +681,14 @@ private static string StableSmallHash(string value)
}
}
+ [Serializable]
+ private sealed class RpcAttemptResult
+ {
+ public bool Succeeded;
+ public TResponse Response;
+ public string Error = "";
+ }
+
[Serializable]
private sealed class EmptyPayload
{
@@ -582,14 +697,14 @@ private sealed class EmptyPayload
[Serializable]
private sealed class SupabaseAnonymousSessionDto
{
- public string access_token;
- public SupabaseUserDto user;
+ public string access_token = "";
+ public SupabaseUserDto user = null;
}
[Serializable]
private sealed class SupabaseUserDto
{
- public string id;
+ public string id = "";
}
[Serializable]
@@ -607,14 +722,14 @@ private sealed class NakamaDeviceAuthRequest
[Serializable]
private sealed class NakamaSessionDto
{
- public string token;
- public string refresh_token;
+ public string token = "";
+ public string refresh_token = "";
}
[Serializable]
private sealed class NakamaJwtClaimsDto
{
- public string uid;
+ public string uid = "";
}
}
}
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/CharacterAnimationRegistry.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/CharacterAnimationRegistry.cs
new file mode 100644
index 0000000..82f0055
--- /dev/null
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/CharacterAnimationRegistry.cs
@@ -0,0 +1,202 @@
+using UnityEngine;
+
+namespace SecondSpawn.Networking
+{
+ ///
+ /// Describes how a high-level character action should be sent to Animator.
+ ///
+ public enum CharacterAnimationCommandKind
+ {
+ None = 0,
+ State = 1,
+ TriggerNumber = 2,
+ TriggerNumberAction = 3,
+ NamedTrigger = 4,
+ TalkingState = 5
+ }
+
+ ///
+ /// Cached Animator command data resolved from gameplay-level visual intent.
+ ///
+ public readonly struct CharacterAnimationCommand
+ {
+ public CharacterAnimationCommand(
+ CharacterAnimationCommandKind kind,
+ string stateName = "",
+ string triggerName = "",
+ int triggerNumber = 0,
+ int action = 0)
+ {
+ Kind = kind;
+ StateName = stateName;
+ TriggerName = triggerName;
+ TriggerNumber = triggerNumber;
+ Action = action;
+ StateHash = string.IsNullOrWhiteSpace(stateName) ? 0 : Animator.StringToHash(stateName);
+ ShortStateHash = string.IsNullOrWhiteSpace(stateName) ? 0 : Animator.StringToHash(GetShortStateName(stateName));
+ }
+
+ public CharacterAnimationCommandKind Kind { get; }
+ public string StateName { get; }
+ public string TriggerName { get; }
+ public int TriggerNumber { get; }
+ public int Action { get; }
+ public int StateHash { get; }
+ public int ShortStateHash { get; }
+
+ private static string GetShortStateName(string stateName)
+ {
+ var dotIndex = stateName.LastIndexOf('.');
+ return dotIndex >= 0 && dotIndex < stateName.Length - 1 ? stateName[(dotIndex + 1)..] : stateName;
+ }
+ }
+
+ ///
+ /// Central registry for translating game intents into the shared humanoid Animator contract.
+ ///
+ public static class CharacterAnimationRegistry
+ {
+ public static CharacterAnimationCommand Resolve(VisualAnimationIntent intent, int equipmentVisualId)
+ {
+ var weaponStyle = EquipmentVisualCatalog.GetWeaponStyle(equipmentVisualId);
+ return intent switch
+ {
+ VisualAnimationIntent.Jump => default,
+ VisualAnimationIntent.Talk => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TalkingState,
+ StateFor(weaponStyle, CharacterAnimationIntentSlot.Talk)),
+ VisualAnimationIntent.Agree => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.State,
+ StateFor(weaponStyle, CharacterAnimationIntentSlot.Agree)),
+ VisualAnimationIntent.Disagree => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.State,
+ StateFor(weaponStyle, CharacterAnimationIntentSlot.Disagree)),
+ VisualAnimationIntent.Interact => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TriggerNumberAction,
+ StateFor(weaponStyle, CharacterAnimationIntentSlot.Interact),
+ triggerNumber: 2,
+ action: 2),
+ VisualAnimationIntent.Attack => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TriggerNumberAction,
+ triggerNumber: 4,
+ action: 1),
+ VisualAnimationIntent.Cast => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TriggerNumberAction,
+ triggerNumber: 10,
+ action: 1),
+ VisualAnimationIntent.DodgeLeft => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TriggerNumberAction,
+ triggerNumber: 13,
+ action: 4),
+ VisualAnimationIntent.DodgeRight => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TriggerNumberAction,
+ triggerNumber: 13,
+ action: 2),
+ VisualAnimationIntent.DodgeBackward => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TriggerNumberAction,
+ triggerNumber: 13,
+ action: 3),
+ VisualAnimationIntent.Death => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TriggerNumber,
+ triggerNumber: 20),
+ VisualAnimationIntent.Revive => new CharacterAnimationCommand(
+ CharacterAnimationCommandKind.TriggerNumber,
+ triggerNumber: 21),
+ _ => ResolveNamedTrigger(intent)
+ };
+ }
+
+ private static CharacterAnimationCommand ResolveNamedTrigger(VisualAnimationIntent intent)
+ {
+ var triggerName = intent switch
+ {
+ VisualAnimationIntent.PickUpItem => "ItemPickupTrigger",
+ VisualAnimationIntent.TakeItem => "ItemTakeTrigger",
+ VisualAnimationIntent.ReceiveItem => "ItemRecieveTrigger",
+ VisualAnimationIntent.HandoffItem => "ItemHandoffTrigger",
+ VisualAnimationIntent.PutDownItem => "ItemPutdownTrigger",
+ VisualAnimationIntent.DropItem => "ItemDropTrigger",
+ VisualAnimationIntent.BeltItem => "ItemBeltTrigger",
+ VisualAnimationIntent.BackItem => "ItemBackTrigger",
+ VisualAnimationIntent.PullUpItem => "ItemPullUpTrigger",
+ VisualAnimationIntent.BeltAwayItem => "ItemBeltAwayTrigger",
+ VisualAnimationIntent.BackAwayItem => "ItemBackAwayTrigger",
+ VisualAnimationIntent.Eat => "ItemEatTrigger",
+ VisualAnimationIntent.Drink => "ItemDrinkTrigger",
+ VisualAnimationIntent.Water => "ItemWaterTrigger",
+ VisualAnimationIntent.Plant => "ItemPlantTrigger",
+ VisualAnimationIntent.Gather => "GatherTrigger",
+ VisualAnimationIntent.Bored => "Bored1Trigger",
+ VisualAnimationIntent.ChopStart => "ChoppingStartTrigger",
+ VisualAnimationIntent.ChopVertical => "ChopVerticalTrigger",
+ VisualAnimationIntent.ChopHorizontal => "ChopHorizontalTrigger",
+ VisualAnimationIntent.ChopDiagonal => "ChopDiagonalTrigger",
+ VisualAnimationIntent.ChopGround => "ChopGroundTrigger",
+ VisualAnimationIntent.ChopCeiling => "ChopCeilingTrigger",
+ VisualAnimationIntent.ChopFinish => "ChopFinishTrigger",
+ VisualAnimationIntent.DigStart => "DiggingStartTrigger",
+ VisualAnimationIntent.DigScoop => "DiggingScoopTrigger",
+ VisualAnimationIntent.DigFinish => "DiggingFinishTrigger",
+ VisualAnimationIntent.FishCast => "FishingCastTrigger",
+ VisualAnimationIntent.FishReel => "FishingReelTrigger",
+ VisualAnimationIntent.SawStart => "SawStartTrigger",
+ VisualAnimationIntent.SawFinish => "SawFinishTrigger",
+ VisualAnimationIntent.HammerWall => "HammerWallTrigger",
+ VisualAnimationIntent.HammerTable => "HammerTableTrigger",
+ VisualAnimationIntent.SickleUse => "ItemSickleUse",
+ VisualAnimationIntent.RakeUse => "ItemRakeUse",
+ VisualAnimationIntent.ChairSit => "ChairSitTrigger",
+ VisualAnimationIntent.ChairTalk => "ChairTalk1Trigger",
+ VisualAnimationIntent.ChairEat => "ChairEatTrigger",
+ VisualAnimationIntent.ChairDrink => "ChairDrinkTrigger",
+ VisualAnimationIntent.ChairStand => "ChairStandTrigger",
+ VisualAnimationIntent.ClimbStart => "ClimbStartTrigger",
+ VisualAnimationIntent.ClimbOffBottom => "ClimbOffBottomTrigger",
+ VisualAnimationIntent.ClimbUp => "ClimbUpTrigger",
+ VisualAnimationIntent.ClimbDown => "ClimbDownTrigger",
+ VisualAnimationIntent.ClimbOffTop => "ClimbOffTopTrigger",
+ VisualAnimationIntent.ClimbOnTop => "ClimbOnTopTrigger",
+ VisualAnimationIntent.PushPullStart => "PushPullStartTrigger",
+ VisualAnimationIntent.PushPullRelease => "PushPullReleaseTrigger",
+ VisualAnimationIntent.CarryPickup => "CarryPickupTrigger",
+ VisualAnimationIntent.CarryReceive => "CarryRecieveTrigger",
+ VisualAnimationIntent.CarryHandoff => "CarryHandoffTrigger",
+ VisualAnimationIntent.CarryPutdown => "CarryPutdownTrigger",
+ _ => ""
+ };
+
+ return string.IsNullOrWhiteSpace(triggerName)
+ ? default
+ : new CharacterAnimationCommand(CharacterAnimationCommandKind.NamedTrigger, triggerName: triggerName);
+ }
+
+ private static string StateFor(CharacterWeaponStyle weaponStyle, CharacterAnimationIntentSlot slot)
+ {
+ return slot switch
+ {
+ CharacterAnimationIntentSlot.Talk => "Base Layer.Relax.Conversation.Relax-Talk1",
+ CharacterAnimationIntentSlot.Agree => "Base Layer.Relax.Relax-Actions.Relax-Yes",
+ CharacterAnimationIntentSlot.Disagree => "Base Layer.Relax.Relax-Actions.Relax-No",
+ CharacterAnimationIntentSlot.Interact => weaponStyle switch
+ {
+ CharacterWeaponStyle.TwoHandSword => "Base Layer.2Hand-Sword.2Hand-Sword-Interact.2Hand-Sword-Pickup",
+ CharacterWeaponStyle.TwoHandSpear => "Base Layer.2Hand-Spear.2Hand-Spear-Interact.2Hand-Spear-Pickup",
+ CharacterWeaponStyle.TwoHandAxe => "Base Layer.2Hand-Axe.2Hand-Axe-Interact.2Hand-Axe-Pickup",
+ CharacterWeaponStyle.TwoHandBow => "Base Layer.2Hand-Bow.2Hand-Bow-Interact.2Hand-Bow-Pickup",
+ CharacterWeaponStyle.TwoHandCrossbow => "Base Layer.Crossbow.Crossbow-Interact.Crossbow-Pickup",
+ CharacterWeaponStyle.Staff => "Base Layer.Staff.Staff-Interact.Staff-Pickup",
+ _ => "Base Layer.Unarmed.Unarmed-Interact.Unarmed-Pickup"
+ },
+ _ => ""
+ };
+ }
+
+ private enum CharacterAnimationIntentSlot
+ {
+ Talk,
+ Agree,
+ Disagree,
+ Interact
+ }
+ }
+}
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/CharacterAnimationRegistry.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/CharacterAnimationRegistry.cs.meta
new file mode 100644
index 0000000..cdaeda4
--- /dev/null
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/CharacterAnimationRegistry.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 33638ed7a57c79b4d89ee01f26baad08
\ No newline at end of file
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs
index ac4975c..c2e72e6 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs
@@ -2,6 +2,22 @@
namespace SecondSpawn.Networking
{
+ ///
+ /// Animator weapon style values used by the shared ExplosiveLLC humanoid controller contract.
+ ///
+ public enum CharacterWeaponStyle
+ {
+ Relax = -1,
+ Unarmed = 0,
+ TwoHandSword = 1,
+ TwoHandSpear = 2,
+ TwoHandAxe = 3,
+ TwoHandBow = 4,
+ TwoHandCrossbow = 5,
+ Staff = 6,
+ OneHandSword = 7
+ }
+
public static class EquipmentVisualCatalog
{
public const int None = 0;
@@ -40,19 +56,24 @@ public static int GetDefaultForVisualVariant(int visualVariant)
}
public static int GetAnimatorWeaponValue(int equipmentVisualId)
+ {
+ return (int)GetWeaponStyle(equipmentVisualId);
+ }
+
+ public static CharacterWeaponStyle GetWeaponStyle(int equipmentVisualId)
{
return equipmentVisualId switch
{
- Unarmed => 0,
- TwoHandSword => 1,
- TwoHandSpear => 2,
- TwoHandAxe => 3,
- TwoHandBow => 4,
- TwoHandCrossbow => 5,
- Staff => 6,
- OneHandSword => 7,
- Hammer => 3,
- _ => -1
+ Unarmed => CharacterWeaponStyle.Unarmed,
+ TwoHandSword => CharacterWeaponStyle.TwoHandSword,
+ TwoHandSpear => CharacterWeaponStyle.TwoHandSpear,
+ TwoHandAxe => CharacterWeaponStyle.TwoHandAxe,
+ TwoHandBow => CharacterWeaponStyle.TwoHandBow,
+ TwoHandCrossbow => CharacterWeaponStyle.TwoHandCrossbow,
+ Staff => CharacterWeaponStyle.Staff,
+ OneHandSword => CharacterWeaponStyle.OneHandSword,
+ Hammer => CharacterWeaponStyle.TwoHandAxe,
+ _ => CharacterWeaponStyle.Relax
};
}
@@ -119,7 +140,7 @@ private static Transform FindWeaponPropRoot(Transform transform, Transform root)
Transform lastMatch = null;
while (current != null && current != root)
{
- if (IsWeaponPropName(current.name))
+ if (current.GetComponent() == null && IsWeaponPropName(current.name))
{
lastMatch = current;
}
@@ -178,7 +199,11 @@ private static bool IsWeaponPropName(string objectName)
return name is "pistol" or "dagger" or "knife" or "sword" or "swordl" or "swordr" or
"shield" or "mace" or "staff" or "spear" or "axe" or "bow" or "rifle" or
- "gun" or "wand" or "club" or "arrow" or "quiver" or "buckler";
+ "gun" or "wand" or "club" or "arrow" or "quiver" or "buckler" or
+ "cart" or "saw" or "lumber" or "pickaxe" or "sickle" or
+ "food" or "rake" or "box" or "drink" or "pushpull" or "shovel" or
+ "paintbrush" or "fishingpole" or "hammer" or "ladder" or "sphere" or
+ "chair" or "hatchet";
}
private static bool IsSameOrChildOf(Transform transform, Transform possibleParent)
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs
index f852811..ab38e7d 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs
@@ -38,13 +38,13 @@ public sealed class NetworkAnimatorBridge : NetworkBehaviour
[SerializeField, Tooltip("Animator float parameter used by RPG Character Mecanim Animation Pack.")]
private string _velocityZParameter = "Velocity Z";
- [SerializeField, Tooltip("Animator float parameter used by ExplosiveLLC free Warrior/Fighter controllers.")]
+ [SerializeField, Tooltip("Animator float parameter used by ExplosiveLLC Warrior/Fighter controllers.")]
private string _velocityParameter = "Velocity";
[SerializeField, Tooltip("Animator float parameter used by RPG Character Mecanim Animation Pack.")]
private string _animationSpeedParameter = "AnimationSpeed";
- [SerializeField, Tooltip("Animator float parameter used by ExplosiveLLC free Warrior/Fighter controllers.")]
+ [SerializeField, Tooltip("Animator float parameter used by ExplosiveLLC Warrior/Fighter controllers.")]
private string _animationSpeedSpacedParameter = "Animation Speed";
[SerializeField, Tooltip("Animator int parameter used by RPG Character Mecanim Animation Pack.")]
@@ -59,7 +59,7 @@ public sealed class NetworkAnimatorBridge : NetworkBehaviour
[SerializeField, Tooltip("Animator int parameter used by RPG Character Mecanim Animation Pack.")]
private string _triggerNumberParameter = "TriggerNumber";
- [SerializeField, Tooltip("Animator int parameter used by ExplosiveLLC free Warrior/Fighter controllers.")]
+ [SerializeField, Tooltip("Animator int parameter used by ExplosiveLLC Warrior/Fighter controllers.")]
private string _triggerNumberSpacedParameter = "Trigger Number";
[SerializeField, Tooltip("Animator trigger parameter used by RPG Character Mecanim Animation Pack.")]
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs
index 631909e..e8841d5 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs
@@ -57,7 +57,7 @@ private void OnDestroy()
private void Update()
{
var kb = Keyboard.current;
- if (kb == null)
+ if (kb == null || IsTextInputFocused())
{
return;
}
@@ -81,6 +81,13 @@ public void OnInput(NetworkRunner runner, NetworkInput input)
var kb = Keyboard.current;
if (kb == null) return;
+ if (IsTextInputFocused())
+ {
+ _jumpQueued = false;
+ input.Set(new NetworkInputData());
+ return;
+ }
+
var data = new NetworkInputData
{
HorizontalAxis = (kb.dKey.isPressed ? 1f : 0f) - (kb.aKey.isPressed ? 1f : 0f),
@@ -95,6 +102,23 @@ public void OnInput(NetworkRunner runner, NetworkInput input)
input.Set(data);
}
+ private static bool IsTextInputFocused()
+ {
+ if (PrototypeInputFocusGate.IsTextInputFocused)
+ {
+ return true;
+ }
+
+ if (GUIUtility.keyboardControl != 0)
+ {
+ return true;
+ }
+
+ var focusedControl = GUI.GetNameOfFocusedControl();
+ return !string.IsNullOrWhiteSpace(focusedControl) &&
+ focusedControl.Contains("Chat", System.StringComparison.OrdinalIgnoreCase);
+ }
+
// Unused callbacks - implement so INetworkRunnerCallbacks is satisfied.
// Real handlers land as slice phase 2+ features need them.
#pragma warning disable UNT0006 // Fusion callbacks intentionally share names with Unity messages but use Fusion-specific signatures.
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs
index e69abaa..72384a1 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs
@@ -1,5 +1,6 @@
using Fusion;
using Fusion.Addons.SimpleKCC;
+using System.Collections.Generic;
using UnityEngine;
namespace SecondSpawn.Networking
@@ -65,12 +66,20 @@ public sealed class NetworkPlayer : NetworkBehaviour
private NetworkInputData _prototypeAgentInput;
private bool _hasPrototypeAgentInput;
private VisualAnimationIntentDriver _visualIntentDriver;
+ private readonly Dictionary _dialogHiddenWeaponRenderers = new Dictionary();
+ private float _dialogVisualUntil;
+ private float _nextDialogTalkAt;
private void Awake()
{
_kcc = GetComponent();
}
+ private void Update()
+ {
+ TickDialogVisual();
+ }
+
public override void Spawned()
{
_kcc ??= GetComponent();
@@ -167,6 +176,121 @@ public bool TryPlayVisualIntent(VisualAnimationIntent intent)
return _visualIntentDriver != null && _visualIntentDriver.TryPlay(intent);
}
+ public void BeginDialogVisual(float seconds)
+ {
+ _dialogVisualUntil = Mathf.Max(_dialogVisualUntil, Time.time + Mathf.Max(0.1f, seconds));
+ TickDialogVisual(force: true);
+ }
+
+ public void EndDialogVisual()
+ {
+ _dialogVisualUntil = 0f;
+ _nextDialogTalkAt = 0f;
+ _visualIntentDriver ??= GetComponentInChildren(includeInactive: true);
+ _visualIntentDriver?.StopTalkingState();
+ RestoreDialogWeaponProps();
+ }
+
+ public void FaceWorldPosition(Vector3 worldPosition)
+ {
+ var direction = worldPosition - transform.position;
+ direction.y = 0f;
+ if (direction.sqrMagnitude <= 0.0001f)
+ {
+ return;
+ }
+
+ var rotation = Quaternion.LookRotation(direction.normalized, Vector3.up);
+ if (_kcc != null && HasStateAuthority)
+ {
+ _kcc.SetLookRotation(rotation, preservePitch: false, preserveYaw: false);
+ }
+
+ transform.rotation = rotation;
+ }
+
+ private void TickDialogVisual(bool force = false)
+ {
+ if (_dialogVisualUntil <= 0f)
+ {
+ return;
+ }
+
+ if (Time.time > _dialogVisualUntil)
+ {
+ EndDialogVisual();
+ return;
+ }
+
+ HideDialogWeaponProps();
+ if (!force && Time.time < _nextDialogTalkAt)
+ {
+ return;
+ }
+
+ TryPlayVisualIntent(VisualAnimationIntent.Talk);
+ _nextDialogTalkAt = Time.time + 2.1f;
+ }
+
+ private void HideDialogWeaponProps()
+ {
+ var visualRoot = ResolveVisualRoot();
+ if (visualRoot == null)
+ {
+ return;
+ }
+
+ foreach (var renderer in visualRoot.GetComponentsInChildren(includeInactive: true))
+ {
+ if (renderer == null || !EquipmentVisualCatalog.IsWeaponProp(renderer.transform, visualRoot))
+ {
+ continue;
+ }
+
+ if (!_dialogHiddenWeaponRenderers.ContainsKey(renderer))
+ {
+ _dialogHiddenWeaponRenderers.Add(renderer, renderer.enabled);
+ }
+
+ renderer.enabled = false;
+ }
+ }
+
+ private void RestoreDialogWeaponProps()
+ {
+ foreach (var entry in _dialogHiddenWeaponRenderers)
+ {
+ if (entry.Key != null)
+ {
+ entry.Key.enabled = entry.Value;
+ }
+ }
+
+ _dialogHiddenWeaponRenderers.Clear();
+ var visualRoot = ResolveVisualRoot();
+ if (visualRoot != null)
+ {
+ EquipmentVisualCatalog.ApplyEquipmentVisual(visualRoot.gameObject, EquipmentVisualId);
+ }
+ }
+
+ private Transform ResolveVisualRoot()
+ {
+ _visualIntentDriver ??= GetComponentInChildren(includeInactive: true);
+ if (_visualIntentDriver == null)
+ {
+ return transform;
+ }
+
+ var root = _visualIntentDriver.transform;
+ while (root.parent != null && root.parent != transform)
+ {
+ root = root.parent;
+ }
+
+ return root;
+ }
+
private bool TryGetAuthoritativeInput(out NetworkInputData input)
{
if (_hasPrototypeAgentInput && IsAgentControlled)
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeInputFocusGate.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeInputFocusGate.cs
new file mode 100644
index 0000000..7165aab
--- /dev/null
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeInputFocusGate.cs
@@ -0,0 +1,53 @@
+using UnityEngine;
+
+namespace SecondSpawn.Networking
+{
+ ///
+ /// Prototype bridge for IMGUI panels that need to suppress gameplay input
+ /// while the local player is typing into a text field.
+ ///
+ public static class PrototypeInputFocusGate
+ {
+ private static int _textInputLocks;
+ private static float _suppressGameplayInputUntil;
+ private static float _dialogInputLockUntil;
+
+ public static bool IsTextInputFocused =>
+ _textInputLocks > 0 ||
+ Time.unscaledTime < _suppressGameplayInputUntil ||
+ Time.unscaledTime < _dialogInputLockUntil;
+
+ public static bool IsDialogLocked => Time.unscaledTime < _dialogInputLockUntil;
+
+ public static void SetTextInputFocused(bool focused)
+ {
+ _textInputLocks = focused ? 1 : 0;
+ }
+
+ public static void SuppressForSeconds(float seconds)
+ {
+ _suppressGameplayInputUntil = Mathf.Max(
+ _suppressGameplayInputUntil,
+ Time.unscaledTime + Mathf.Max(0f, seconds));
+ }
+
+ public static void LockForDialog(float seconds)
+ {
+ _dialogInputLockUntil = Mathf.Max(
+ _dialogInputLockUntil,
+ Time.unscaledTime + Mathf.Max(0f, seconds));
+ }
+
+ public static void ClearDialogLock()
+ {
+ _dialogInputLockUntil = 0f;
+ }
+
+ public static void Clear()
+ {
+ _textInputLocks = 0;
+ _suppressGameplayInputUntil = 0f;
+ _dialogInputLockUntil = 0f;
+ }
+ }
+}
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeInputFocusGate.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeInputFocusGate.cs.meta
new file mode 100644
index 0000000..59bbed1
--- /dev/null
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeInputFocusGate.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 29408acb8513444aa2f3d2f80d03b037
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs
index 2fc434a..af4461f 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs
@@ -95,11 +95,6 @@ public sealed class VisualAnimationIntentDriver : MonoBehaviour
[SerializeField] private string _animationSpeedParameter = "AnimationSpeed";
[SerializeField] private string _animationSpeedSpacedParameter = "Animation Speed";
- [SerializeField] private string _talkState = "Base Layer.Relax.Conversation.Relax-Talk1";
- [SerializeField] private string _agreeState = "Base Layer.Relax.Relax-Actions.Relax-Yes";
- [SerializeField] private string _disagreeState = "Base Layer.Relax.Relax-Actions.Relax-No";
- [SerializeField] private string _interactState = "Base Layer.Unarmed.Unarmed-Interact.Unarmed-Pickup";
-
private bool _hasTriggerParameter;
private bool _hasTriggerNumberParameter;
private bool _hasTriggerNumberSpacedParameter;
@@ -111,6 +106,7 @@ public sealed class VisualAnimationIntentDriver : MonoBehaviour
private NetworkPlayer _networkPlayer;
private RuntimeAnimatorController _cachedController;
private readonly HashSet _availableTriggerNames = new HashSet();
+ private float _talkingUntil;
public bool TryPlay(VisualAnimationIntent intent)
{
@@ -137,11 +133,56 @@ public void Play(string intentName)
}
}
+ public void StopTalkingState()
+ {
+ _talkingUntil = 0f;
+ ResolveAnimator();
+ if (_animator == null)
+ {
+ return;
+ }
+
+ if (_hasTalkingParameter)
+ {
+ _animator.SetInteger(_talkingParameter, 0);
+ }
+
+ if (_hasWeaponParameter)
+ {
+ _animator.SetInteger(_weaponParameter, GetAnimatorWeaponValue());
+ }
+ }
+
private void Awake()
{
ResolveAnimator();
}
+ private void Update()
+ {
+ if (_talkingUntil <= 0f || Time.time < _talkingUntil)
+ {
+ return;
+ }
+
+ _talkingUntil = 0f;
+ ResolveAnimator();
+ if (_animator == null)
+ {
+ return;
+ }
+
+ if (_hasTalkingParameter)
+ {
+ _animator.SetInteger(_talkingParameter, 0);
+ }
+
+ if (_hasWeaponParameter)
+ {
+ _animator.SetInteger(_weaponParameter, GetAnimatorWeaponValue());
+ }
+ }
+
private void ResolveAnimator()
{
if (_animator == null)
@@ -164,81 +205,16 @@ private void ResolveAnimator()
private bool TryPlayThroughAnimatorContract(VisualAnimationIntent intent)
{
- return intent switch
+ var command = CharacterAnimationRegistry.Resolve(intent, GetEquipmentVisualId());
+ return command.Kind switch
{
- VisualAnimationIntent.Jump => false,
- VisualAnimationIntent.Talk => TryPlayTalking(),
- VisualAnimationIntent.Agree => TryCrossFadeState(_agreeState),
- VisualAnimationIntent.Disagree => TryCrossFadeState(_disagreeState),
- VisualAnimationIntent.Interact => TryFireActionTrigger(triggerNumber: 2, action: 2) || TryCrossFadeState(_interactState),
- VisualAnimationIntent.Attack => TryFireActionTrigger(triggerNumber: 4, action: 1),
- VisualAnimationIntent.Cast => TryFireActionTrigger(triggerNumber: 10, action: 1),
- VisualAnimationIntent.DodgeLeft => TryFireActionTrigger(triggerNumber: 13, action: 4),
- VisualAnimationIntent.DodgeRight => TryFireActionTrigger(triggerNumber: 13, action: 2),
- VisualAnimationIntent.DodgeBackward => TryFireActionTrigger(triggerNumber: 13, action: 3),
- VisualAnimationIntent.Death => TryFireTrigger(20),
- VisualAnimationIntent.Revive => TryFireTrigger(21),
- _ => TryFireNamedTrigger(GetNamedTrigger(intent)),
- };
- }
-
- private static string GetNamedTrigger(VisualAnimationIntent intent)
- {
- return intent switch
- {
- VisualAnimationIntent.PickUpItem => "ItemPickupTrigger",
- VisualAnimationIntent.TakeItem => "ItemTakeTrigger",
- VisualAnimationIntent.ReceiveItem => "ItemRecieveTrigger",
- VisualAnimationIntent.HandoffItem => "ItemHandoffTrigger",
- VisualAnimationIntent.PutDownItem => "ItemPutdownTrigger",
- VisualAnimationIntent.DropItem => "ItemDropTrigger",
- VisualAnimationIntent.BeltItem => "ItemBeltTrigger",
- VisualAnimationIntent.BackItem => "ItemBackTrigger",
- VisualAnimationIntent.PullUpItem => "ItemPullUpTrigger",
- VisualAnimationIntent.BeltAwayItem => "ItemBeltAwayTrigger",
- VisualAnimationIntent.BackAwayItem => "ItemBackAwayTrigger",
- VisualAnimationIntent.Eat => "ItemEatTrigger",
- VisualAnimationIntent.Drink => "ItemDrinkTrigger",
- VisualAnimationIntent.Water => "ItemWaterTrigger",
- VisualAnimationIntent.Plant => "ItemPlantTrigger",
- VisualAnimationIntent.Gather => "GatherTrigger",
- VisualAnimationIntent.Bored => "Bored1Trigger",
- VisualAnimationIntent.ChopStart => "ChoppingStartTrigger",
- VisualAnimationIntent.ChopVertical => "ChopVerticalTrigger",
- VisualAnimationIntent.ChopHorizontal => "ChopHorizontalTrigger",
- VisualAnimationIntent.ChopDiagonal => "ChopDiagonalTrigger",
- VisualAnimationIntent.ChopGround => "ChopGroundTrigger",
- VisualAnimationIntent.ChopCeiling => "ChopCeilingTrigger",
- VisualAnimationIntent.ChopFinish => "ChopFinishTrigger",
- VisualAnimationIntent.DigStart => "DiggingStartTrigger",
- VisualAnimationIntent.DigScoop => "DiggingScoopTrigger",
- VisualAnimationIntent.DigFinish => "DiggingFinishTrigger",
- VisualAnimationIntent.FishCast => "FishingCastTrigger",
- VisualAnimationIntent.FishReel => "FishingReelTrigger",
- VisualAnimationIntent.SawStart => "SawStartTrigger",
- VisualAnimationIntent.SawFinish => "SawFinishTrigger",
- VisualAnimationIntent.HammerWall => "HammerWallTrigger",
- VisualAnimationIntent.HammerTable => "HammerTableTrigger",
- VisualAnimationIntent.SickleUse => "ItemSickleUse",
- VisualAnimationIntent.RakeUse => "ItemRakeUse",
- VisualAnimationIntent.ChairSit => "ChairSitTrigger",
- VisualAnimationIntent.ChairTalk => "ChairTalk1Trigger",
- VisualAnimationIntent.ChairEat => "ChairEatTrigger",
- VisualAnimationIntent.ChairDrink => "ChairDrinkTrigger",
- VisualAnimationIntent.ChairStand => "ChairStandTrigger",
- VisualAnimationIntent.ClimbStart => "ClimbStartTrigger",
- VisualAnimationIntent.ClimbOffBottom => "ClimbOffBottomTrigger",
- VisualAnimationIntent.ClimbUp => "ClimbUpTrigger",
- VisualAnimationIntent.ClimbDown => "ClimbDownTrigger",
- VisualAnimationIntent.ClimbOffTop => "ClimbOffTopTrigger",
- VisualAnimationIntent.ClimbOnTop => "ClimbOnTopTrigger",
- VisualAnimationIntent.PushPullStart => "PushPullStartTrigger",
- VisualAnimationIntent.PushPullRelease => "PushPullReleaseTrigger",
- VisualAnimationIntent.CarryPickup => "CarryPickupTrigger",
- VisualAnimationIntent.CarryReceive => "CarryRecieveTrigger",
- VisualAnimationIntent.CarryHandoff => "CarryHandoffTrigger",
- VisualAnimationIntent.CarryPutdown => "CarryPutdownTrigger",
- _ => "",
+ CharacterAnimationCommandKind.TalkingState => TryPlayTalking(command),
+ CharacterAnimationCommandKind.State => TryCrossFadeState(command),
+ CharacterAnimationCommandKind.TriggerNumber => TryFireTrigger(command.TriggerNumber),
+ CharacterAnimationCommandKind.TriggerNumberAction => TryFireActionTrigger(command.TriggerNumber, command.Action) ||
+ TryCrossFadeState(command),
+ CharacterAnimationCommandKind.NamedTrigger => TryFireNamedTrigger(command.TriggerName),
+ _ => false
};
}
@@ -277,14 +253,20 @@ private bool TryFireActionTrigger(int triggerNumber, int action)
return TryFireTrigger(triggerNumber);
}
- private bool TryPlayTalking()
+ private bool TryPlayTalking(CharacterAnimationCommand command)
{
if (_hasTalkingParameter)
{
_animator.SetInteger(_talkingParameter, 1);
}
- return TryCrossFadeState(_talkState);
+ if (_hasWeaponParameter)
+ {
+ _animator.SetInteger(_weaponParameter, (int)CharacterWeaponStyle.Relax);
+ }
+
+ _talkingUntil = Time.time + 2.8f;
+ return TryCrossFadeState(command);
}
private bool TryFireTrigger(int triggerNumber)
@@ -310,20 +292,23 @@ private bool TryFireNamedTrigger(string triggerName)
return true;
}
- private bool TryCrossFadeState(string stateName)
+ private bool TryCrossFadeState(CharacterAnimationCommand command)
{
+ if (command.StateHash == 0)
+ {
+ return false;
+ }
+
var layerIndex = 0;
- var stateHash = Animator.StringToHash(stateName);
+ var stateHash = command.StateHash;
if (!_animator.HasState(layerIndex, stateHash))
{
- var shortStateName = GetShortStateName(stateName);
- var shortStateHash = Animator.StringToHash(shortStateName);
- if (!_animator.HasState(layerIndex, shortStateHash))
+ if (command.ShortStateHash == 0 || !_animator.HasState(layerIndex, command.ShortStateHash))
{
return false;
}
- stateHash = shortStateHash;
+ stateHash = command.ShortStateHash;
}
SetAnimationSpeed(1f);
@@ -410,20 +395,14 @@ private void CacheParameters()
}
}
- private static string GetShortStateName(string stateName)
+ private int GetAnimatorWeaponValue()
{
- var dotIndex = stateName.LastIndexOf('.');
- return dotIndex >= 0 && dotIndex < stateName.Length - 1 ? stateName[(dotIndex + 1)..] : stateName;
+ return EquipmentVisualCatalog.GetAnimatorWeaponValue(GetEquipmentVisualId());
}
- private int GetAnimatorWeaponValue()
+ private int GetEquipmentVisualId()
{
- if (_networkPlayer == null || _networkPlayer.EquipmentVisualId == EquipmentVisualCatalog.None)
- {
- return 0;
- }
-
- return EquipmentVisualCatalog.GetAnimatorWeaponValue(_networkPlayer.EquipmentVisualId);
+ return _networkPlayer == null ? EquipmentVisualCatalog.None : _networkPlayer.EquipmentVisualId;
}
private bool HasAnyTriggerNumberParameter()
diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs
index 94f39a7..b591c21 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs
@@ -5,33 +5,63 @@ public static class VisualPrefabCatalog
public const string CleanVisualFolder = "Assets/_SecondSpawn/Prefabs/Characters/GeneratedVisualsV2";
public const string CleanMaterialFolder = "Assets/_SecondSpawn/Materials/GeneratedVisualsV2";
- public static readonly string[] SourceAssetPaths =
+ private static readonly VisualPrefabEntry[] Entries =
{
- "Assets/ExplosiveLLC/RPG Character Mecanim Animation Pack/Prefabs/Character/RPG-Character.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Brute Warrior Mecanim Animation Pack/Prefabs/Brute Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Karate Warrior Mecanim Animation Pack/Prefabs/Karate Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Ninja Warrior Mecanim Animation Pack/Prefabs/Ninja Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Sorceress Warrior Mecanim Animation Pack/Prefabs/Sorceress Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 2 FREE/2 Handed Warrior Mecanim Animation Pack/Prefabs/2Handed Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 2 FREE/Archer Warrior Mecanim Animation Pack/Prefabs/Archer Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 2 FREE/Knight Warrior Mecanim Animation Pack/Prefabs/Knight Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 2 FREE/Mage Warrior Mecanim Animation Pack/Prefabs/Mage Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 3 FREE/Crossbow Warrior Mecanim Animation Pack/Prefabs/Crossbow Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 3 FREE/Hammer Warrior Mecanim Animation Pack/Prefabs/Hammer Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 3 FREE/Spearman Warrior Mecanim Animation Pack/Prefabs/Spearman Warrior.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 3 FREE/Swordsman Warrior Mecanim Animation Pack/Prefabs/Swordsman Warrior.prefab",
- "Assets/ExplosiveLLC/Fighter Pack Bundle FREE/Fighters/Berserker Fighter Mecanim Animation Pack FREE/Prefabs/Berserker.prefab",
- "Assets/ExplosiveLLC/Fighter Pack Bundle FREE/Fighters/Female Fighter Mecanim Animation Pack FREE/Prefabs/Female.prefab",
- "Assets/ExplosiveLLC/Fighter Pack Bundle FREE/Fighters/Heavy Fighter Mecanim Animation Pack FREE/Prefabs/Heavy.prefab",
- "Assets/ExplosiveLLC/Fighter Pack Bundle FREE/Fighters/Male Fighter Mecanim Animation Pack FREE/Prefabs/Male.prefab",
- "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Sorceress Warrior Mecanim Animation Pack/Prefabs/Crafter FREE.prefab",
+ new("RPG-Character",
+ "Assets/ExplosiveLLC/RPG Character Mecanim Animation Pack/Prefabs/Character/RPG-Character.prefab"),
+ new("Brute_Warrior",
+ "Assets/ExplosiveLLC/Brute Warrior Mecanim Animation Pack/Prefabs/Brute Warrior.prefab"),
+ new("Karate_Warrior",
+ "Assets/ExplosiveLLC/Karate Warrior Mecanim Animation Pack/Prefabs/Karate Warrior.prefab"),
+ new("Ninja_Warrior",
+ "Assets/ExplosiveLLC/Ninja Warrior Mecanim Animation Pack/Prefabs/Ninja Warrior.prefab"),
+ new("Sorceress_Warrior",
+ "Assets/ExplosiveLLC/Sorceress Warrior Mecanim Animation Pack/Prefabs/Sorceress Warrior.prefab"),
+ new("2Handed_Warrior",
+ "Assets/ExplosiveLLC/2 Handed Warrior Mecanim Animation Pack/Prefabs/2Handed Warrior.prefab"),
+ new("Archer_Warrior",
+ "Assets/ExplosiveLLC/Archer Warrior Mecanim Animation Pack/Prefabs/Archer Warrior.prefab"),
+ new("Knight_Warrior",
+ "Assets/ExplosiveLLC/Knight Warrior Mecanim Animation Pack/Prefabs/Knight Warrior.prefab"),
+ new("Mage_Warrior",
+ "Assets/ExplosiveLLC/Mage Warrior Mecanim Animation Pack/Prefabs/Mage Warrior.prefab"),
+ new("Crossbow_Warrior",
+ "Assets/ExplosiveLLC/Crossbow Warrior Mecanim Animation Pack/Prefabs/Crossbow Warrior.prefab"),
+ new("Hammer_Warrior",
+ "Assets/ExplosiveLLC/Hammer Warrior Mecanim Animation Pack/Prefabs/Hammer Warrior.prefab"),
+ new("Spearman_Warrior",
+ "Assets/ExplosiveLLC/Spearman Warrior Mecanim Animation Pack/Prefabs/Spearman Warrior.prefab"),
+ new("Swordsman_Warrior",
+ "Assets/ExplosiveLLC/Swordsman Warrior Mecanim Animation Pack/Prefabs/Swordsman Warrior.prefab"),
+ new("Berserker",
+ "Assets/ExplosiveLLC/Berserker Fighter Mecanim Animation Pack/Prefabs/Berserker.prefab"),
+ new("Female",
+ "Assets/ExplosiveLLC/Female Fighter Mecanim Animation Pack/Prefabs/Female.prefab"),
+ new("Heavy",
+ "Assets/ExplosiveLLC/Heavy Fighter Mecanim Animation Pack/Prefabs/Heavy.prefab",
+ "Assets/ExplosiveLLC/Heavy Fighter Mecanim Animation Pack/Heavy Fighter/Prefabs/Heavy-Fighter.prefab"),
+ new("Male",
+ "Assets/ExplosiveLLC/Male Fighter Mecanim Animation Pack/Prefabs/Male.prefab"),
+ new("Crafter",
+ "Assets/ExplosiveLLC/Sorceress Warrior Mecanim Animation Pack/Prefabs/Crafter.prefab"),
};
- public static int Count => SourceAssetPaths.Length;
+ public static int Count => Entries.Length;
public static string GetSourceAssetPath(int variant)
{
- return SourceAssetPaths[NormalizeVariant(variant)];
+ var entry = Entries[NormalizeVariant(variant)];
+#if UNITY_EDITOR
+ foreach (var sourceAssetPath in entry.SourceAssetPaths)
+ {
+ if (UnityEditor.AssetDatabase.LoadAssetAtPath(sourceAssetPath) != null)
+ {
+ return sourceAssetPath;
+ }
+ }
+#endif
+
+ return entry.SourceAssetPaths.Length > 0 ? entry.SourceAssetPaths[0] : string.Empty;
}
public static string GetCleanAssetPath(int variant)
@@ -42,14 +72,7 @@ public static string GetCleanAssetPath(int variant)
public static string GetCleanPrefabName(int variant)
{
var index = NormalizeVariant(variant);
- var sourcePath = SourceAssetPaths[index];
- var fileNameStart = sourcePath.LastIndexOf('/') + 1;
- var fileNameEnd = sourcePath.LastIndexOf('.');
- var sourceName = fileNameEnd > fileNameStart
- ? sourcePath[fileNameStart..fileNameEnd]
- : $"Visual{index:00}";
-
- return $"Visual_{index:00}_{SanitizeFileName(CleanGeneratedAssetName(sourceName))}.prefab";
+ return $"Visual_{index:00}_{Entries[index].CleanName}.prefab";
}
public static int NormalizeVariant(int variant)
@@ -78,13 +101,17 @@ private static string SanitizeFileName(string value)
return new string(chars);
}
- private static string CleanGeneratedAssetName(string value)
+ private readonly struct VisualPrefabEntry
{
- var normalized = value.Trim();
- const string freeSuffix = " FREE";
- return normalized.EndsWith(freeSuffix, System.StringComparison.OrdinalIgnoreCase)
- ? normalized[..^freeSuffix.Length]
- : normalized;
+ public VisualPrefabEntry(string cleanName, params string[] sourceAssetPaths)
+ {
+ CleanName = SanitizeFileName(cleanName);
+ SourceAssetPaths = sourceAssetPaths;
+ }
+
+ public string CleanName { get; }
+
+ public string[] SourceAssetPaths { get; }
}
}
}
diff --git a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs
index a6ee9dd..7c5f4b9 100644
--- a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs
+++ b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs
@@ -1,6 +1,9 @@
+using System.Collections;
+using System.Text;
using SecondSpawn.AI;
using SecondSpawn.Networking;
using UnityEngine;
+using UnityEngine.UI;
namespace SecondSpawn.UI
{
@@ -17,20 +20,26 @@ namespace SecondSpawn.UI
///
public sealed class HUDController : MonoBehaviour
{
+ private const float PromptTraceAuthRetryBackoffSeconds = 30f;
+
[SerializeField] private bool _showPrototypeStats = true;
[SerializeField] private bool _showFpsCounter = true;
[SerializeField] private bool _showFrameIdentity = true;
[SerializeField] private bool _showAgentActivity = true;
+ [SerializeField] private bool _showPromptTrace = true;
+ [SerializeField] private bool _useLegacyImguiHud;
[SerializeField] private Vector2 _panelPosition = new Vector2(16f, 16f);
- [SerializeField] private Vector2 _panelSize = new Vector2(440f, 420f);
+ [SerializeField] private Vector2 _panelSize = new Vector2(480f, 500f);
[SerializeField] private Vector2 _fpsOffset = new Vector2(16f, 16f);
[SerializeField, Min(0.05f)] private float _fpsRefreshSeconds = 0.25f;
+ [SerializeField, Min(1f)] private float _promptTraceRefreshSeconds = 4f;
[SerializeField] private int _maxStoryCharacters = 110;
[SerializeField] private int _maxActivityRows = 4;
[SerializeField] private int _maxActivitySummaryCharacters = 92;
private NetworkPlayer _cachedPlayer;
private CharacterMemorySync _cachedMemorySync;
+ private SecondSpawnGatewayClient _cachedGateway;
private GUIStyle _labelStyle;
private GUIStyle _headingStyle;
private GUIStyle _mutedStyle;
@@ -40,41 +49,46 @@ public sealed class HUDController : MonoBehaviour
private float _nextPlayerRefreshAt;
private float _nextMemorySyncRefreshAt;
private float _nextFpsRefreshAt;
- private float _smoothedDeltaTime;
+ private int _fpsFrameCount;
+ private float _fpsElapsedSeconds;
private string _fpsText = "FPS --";
private Color _fpsColor = Color.white;
private Vector2 _scrollPosition;
+ private float _nextPromptTraceRefreshAt;
+ private bool _promptTraceRefreshInFlight;
+ private string _promptTraceSummary = "No prompt trace yet.";
+ private Text _hudTitleText;
+ private Text _hudBodyText;
+ private Text _hudFrameText;
+ private Text _hudAgentText;
+ private Text _hudTraceText;
+ private Text _fpsTextComponent;
+ private Image _fpsBackground;
+ private string _lastHudState = "";
+
+ private void Start()
+ {
+ BuildRuntimeHud();
+ }
private void Update()
{
- if (!_showFpsCounter)
- {
- return;
- }
-
- var deltaTime = Time.unscaledDeltaTime;
- if (deltaTime <= 0f)
+ if (_showFpsCounter)
{
- return;
+ TickFpsCounter();
}
- _smoothedDeltaTime = _smoothedDeltaTime <= 0f
- ? deltaTime
- : Mathf.Lerp(_smoothedDeltaTime, deltaTime, 0.08f);
+ TickPromptTraceRefresh();
+ RenderRuntimeHud();
+ }
- if (Time.unscaledTime < _nextFpsRefreshAt)
+ private void OnGUI()
+ {
+ if (!_useLegacyImguiHud)
{
return;
}
- _nextFpsRefreshAt = Time.unscaledTime + _fpsRefreshSeconds;
- var fps = Mathf.RoundToInt(1f / Mathf.Max(0.0001f, _smoothedDeltaTime));
- _fpsText = $"FPS {fps}";
- _fpsColor = FpsColor(fps);
- }
-
- private void OnGUI()
- {
EnsureStyles();
DrawFpsCounter();
@@ -99,10 +113,225 @@ private void OnGUI()
DrawAgentActivity();
}
+ if (_showPromptTrace)
+ {
+ DrawPromptTrace();
+ }
+
GUILayout.EndScrollView();
GUILayout.EndArea();
}
+ private void BuildRuntimeHud()
+ {
+ if (GetComponent