1#ifndef YAZE_APP_EDITOR_ORACLE_PANELS_STORY_EVENT_GRAPH_PANEL_H
2#define YAZE_APP_EDITOR_ORACLE_PANELS_STORY_EVENT_GRAPH_PANEL_H
11#include <unordered_map>
27#include "imgui/imgui.h"
28#include "imgui/misc/cpp/imgui_stdlib.h"
44class StoryEventGraphPanel :
public WindowContent {
54 std::string
GetId()
const override {
return "oracle.story_event_graph"; }
60 return "Inspect narrative and progression dependencies for the active "
66 return project !=
nullptr && project->project_opened() &&
70 return "Story graph data is not available for the active hack project";
77 void Draw(
bool* )
override {
87 manifest_ = backend->ResolveManifest(project);
88 }
else if (project && project->hack_manifest.loaded()) {
94 ImGui::TextDisabled(tr(
"No hack project loaded"));
96 tr(
"Open a project with a hack manifest to view story events."));
105 if (graph ==
nullptr || !graph->loaded()) {
106 ImGui::TextDisabled(tr(
"No story events data available"));
111 if (ImGui::Button(tr(
"Reset View"))) {
117 ImGui::SliderFloat(tr(
"Zoom"), &
zoom_, 0.3f, 2.0f,
"%.1f");
119 ImGui::Text(tr(
"Nodes: %zu Edges: %zu"), graph->nodes().size(),
120 graph->edges().size());
122 const auto prog_opt =
126 if (prog_opt.has_value()) {
127 ImGui::TextDisabled(tr(
"Crystals: %d State: %s"),
128 prog_opt->GetCrystalCount(),
129 prog_opt->GetGameStateName().c_str());
131 ImGui::TextDisabled(tr(
"No SRAM loaded"));
135 if (ImGui::SmallButton(tr(
"Import .srm..."))) {
139 if (ImGui::SmallButton(tr(
"Clear SRAM"))) {
147 ImGui::TextDisabled(tr(
"SRM: %s"), p.filename().string().c_str());
148 if (ImGui::IsItemHovered()) {
154 ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), tr(
"SRM error: %s"),
166 ImVec2 canvas_pos = ImGui::GetCursorScreenPos();
167 ImVec2 canvas_size = ImGui::GetContentRegionAvail();
171 canvas_size.x -= sidebar_width;
173 if (canvas_size.x < 100 || canvas_size.y < 100)
176 ImGui::InvisibleButton(
177 "story_canvas", canvas_size,
178 ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight);
180 bool is_hovered = ImGui::IsItemHovered();
181 bool is_active = ImGui::IsItemActive();
184 if (is_active && ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
185 ImVec2 delta = ImGui::GetIO().MouseDelta;
192 float wheel = ImGui::GetIO().MouseWheel;
194 zoom_ *= (wheel > 0) ? 1.1f : 0.9f;
202 ImDrawList* draw_list = ImGui::GetWindowDrawList();
205 draw_list->PushClipRect(
207 ImVec2(canvas_pos.x + canvas_size.x, canvas_pos.y + canvas_size.y),
211 float cx = canvas_pos.x + canvas_size.x * 0.5f +
scroll_x_;
212 float cy = canvas_pos.y + canvas_size.y * 0.5f +
scroll_y_;
215 for (
const auto& edge : graph->edges()) {
216 const auto* from_node = graph->GetNode(edge.from);
217 const auto* to_node = graph->GetNode(edge.to);
218 if (!from_node || !to_node)
226 cy + from_node->pos_y *
zoom_);
228 cy + to_node->pos_y *
zoom_);
231 float ctrl_dx = (p2.x - p1.x) * 0.4f;
232 ImVec2 cp1(p1.x + ctrl_dx, p1.y);
233 ImVec2 cp2(p2.x - ctrl_dx, p2.y);
235 draw_list->AddBezierCubic(p1, cp1, cp2, p2, IM_COL32(150, 150, 150, 180),
239 ImVec2 dir(p2.x - cp2.x, p2.y - cp2.y);
240 float len = sqrtf(dir.x * dir.x + dir.y * dir.y);
244 float arrow_size = 8.0f *
zoom_;
245 ImVec2 arrow1(p2.x - dir.x * arrow_size + dir.y * arrow_size * 0.4f,
246 p2.y - dir.y * arrow_size - dir.x * arrow_size * 0.4f);
247 ImVec2 arrow2(p2.x - dir.x * arrow_size - dir.y * arrow_size * 0.4f,
248 p2.y - dir.y * arrow_size + dir.x * arrow_size * 0.4f);
249 draw_list->AddTriangleFilled(p2, arrow1, arrow2,
250 IM_COL32(150, 150, 150, 200));
255 ImVec2 mouse_pos = ImGui::GetIO().MousePos;
257 for (
const auto& node : graph->nodes()) {
267 ImVec2 node_min(nx, ny);
268 ImVec2 node_max(nx + nw, ny + nh);
273 const bool query_match =
275 ImU32 border_color = selected
276 ? IM_COL32(255, 255, 100, 255)
277 : (query_match ? IM_COL32(220, 220, 220, 255)
278 : IM_COL32(60, 60, 60, 255));
280 draw_list->AddRectFilled(node_min, node_max, fill_color, 8.0f *
zoom_);
281 draw_list->AddRect(node_min, node_max, border_color, 8.0f *
zoom_, 0,
285 float font_size = 11.0f *
zoom_;
286 if (font_size >= 6.0f) {
288 draw_list->AddText(
nullptr, font_size,
290 IM_COL32(200, 200, 200, 255), node.id.c_str());
292 std::string display_name = node.name;
293 if (display_name.length() > 25) {
294 display_name = display_name.substr(0, 22) +
"...";
296 draw_list->AddText(
nullptr, font_size,
298 IM_COL32(255, 255, 255, 255), display_name.c_str());
302 if (is_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
303 if (mouse_pos.x >= node_min.x && mouse_pos.x <= node_max.x &&
304 mouse_pos.y >= node_min.y && mouse_pos.y <= node_max.y) {
310 draw_list->PopClipRect();
328 return IM_COL32(40, 120, 40, 220);
330 return IM_COL32(180, 160, 40, 220);
332 return IM_COL32(160, 40, 40, 220);
335 return IM_COL32(60, 60, 60, 220);
344 ImGui::BeginChild(
"node_detail", ImVec2(240, 0), ImGuiChildFlags_Borders);
346 ImGui::TextWrapped(
"%s", node->name.c_str());
347 ImGui::TextDisabled(
"%s", node->id.c_str());
350 if (!node->flags.empty()) {
351 ImGui::Text(tr(
"Flags:"));
352 for (
const auto& flag : node->flags) {
353 if (!flag.value.empty()) {
354 ImGui::BulletText(
"%s = %s", flag.name.c_str(), flag.value.c_str());
356 ImGui::BulletText(
"%s", flag.name.c_str());
361 if (!node->locations.empty()) {
363 ImGui::Text(tr(
"Locations:"));
364 for (
size_t i = 0; i < node->locations.size(); ++i) {
365 const auto& loc = node->locations[i];
368 const auto special_world_id =
ParseIntLoose(loc.special_world_id);
369 const auto jump_map_id =
371 ImGui::PushID(
static_cast<int>(i));
373 ImGui::BulletText(
"%s", loc.name.c_str());
377 if (ImGui::SmallButton(tr(
"Room"))) {
383 if (ImGui::SmallButton(tr(
"Map"))) {
386 }
else if (overworld_id || special_world_id) {
388 ImGui::BeginDisabled();
389 ImGui::SmallButton(tr(
"Map"));
390 ImGui::EndDisabled();
391 if (ImGui::IsItemHovered()) {
393 tr(
"Map target is outside the supported overworld range."));
397 if (!loc.room_id.empty() || !loc.overworld_id.empty() ||
398 !loc.special_world_id.empty() || !loc.entrance_id.empty()) {
400 tr(
"room=%s map=%s special=%s entrance=%s"),
401 loc.room_id.empty() ?
"-" : loc.room_id.c_str(),
402 loc.overworld_id.empty() ?
"-" : loc.overworld_id.c_str(),
403 loc.special_world_id.empty() ?
"-" : loc.special_world_id.c_str(),
404 loc.entrance_id.empty() ?
"-" : loc.entrance_id.c_str());
411 if (!node->text_ids.empty()) {
413 ImGui::Text(tr(
"Text IDs:"));
414 for (
size_t i = 0; i < node->text_ids.size(); ++i) {
415 const auto& tid = node->text_ids[i];
416 ImGui::PushID(
static_cast<int>(i));
418 ImGui::BulletText(
"%s", tid.c_str());
420 if (ImGui::SmallButton(tr(
"Open"))) {
426 if (ImGui::SmallButton(tr(
"Copy"))) {
427 ImGui::SetClipboardText(tid.c_str());
434 if (!node->scripts.empty()) {
436 ImGui::Text(tr(
"Scripts:"));
437 for (
size_t i = 0; i < node->scripts.size(); ++i) {
438 const auto& script = node->scripts[i];
439 ImGui::PushID(
static_cast<int>(i));
440 ImGui::BulletText(
"%s", script.c_str());
442 if (ImGui::SmallButton(tr(
"Open"))) {
446 if (ImGui::SmallButton(tr(
"Copy"))) {
447 ImGui::SetClipboardText(script.c_str());
453 if (!node->notes.empty()) {
455 ImGui::TextWrapped(tr(
"Notes: %s"), node->notes.c_str());
463 size_t start = input.find_first_not_of(
" \t\r\n");
464 if (start == std::string::npos)
466 size_t end = input.find_last_not_of(
" \t\r\n");
467 std::string trimmed = input.substr(start, end - start + 1);
471 int value = std::stoi(trimmed, &idx, 0);
472 if (idx != trimmed.size())
507 const auto prog_opt =
511 if (!prog_opt.has_value())
514 const auto& s = *prog_opt;
515 return static_cast<uint64_t
>(s.crystal_bitfield) |
516 (
static_cast<uint64_t
>(s.game_state) << 8) |
517 (
static_cast<uint64_t
>(s.oosprog) << 16) |
518 (
static_cast<uint64_t
>(s.oosprog2) << 24) |
519 (
static_cast<uint64_t
>(s.side_quest) << 32) |
520 (
static_cast<uint64_t
>(s.pendants) << 40);
526 ImGui::Text(tr(
"Filter"));
528 ImGui::SetNextItemWidth(260.0f);
529 if (ImGui::InputTextWithHint(
"##story_graph_filter",
530 "Search id/name/text/script/flag/room...",
535 if (ImGui::SmallButton(tr(
"Clear"))) {
549 bool toggles_changed =
false;
554 toggles_changed |= ImGui::Checkbox(tr(
"Locked"), &
show_locked_);
556 toggles_changed |= ImGui::Checkbox(tr(
"Blocked"), &
show_blocked_);
557 if (toggles_changed) {
562 static uint8_t
StatusMask(
bool completed,
bool available,
bool locked,
593 const size_t node_count = graph.
nodes().size();
618 for (
const auto& node : graph.
nodes()) {
619 const bool query_match =
641 {
"SRAM (.srm)",
"srm"},
645 std::string file_path =
647 if (file_path.empty()) {
655 if (!state_or.ok()) {
661 backend->SetProgressionState(*
manifest_, *state_or);
676 backend->ClearProgressionState(*
manifest_);
687 if (ImGui::SmallButton(tr(
"Sync Mesen"))) {
691 if (ImGui::IsItemHovered()) {
692 ImGui::SetTooltip(tr(
"Read Oracle SRAM directly from connected Mesen2"));
699 ImGui::SetNextItemWidth(70.0f);
700 ImGui::SliderFloat(
"##StoryGraphLiveInterval",
706 ImGui::TextDisabled(tr(
"Mesen: connected"));
708 ImGui::TextDisabled(tr(
"Mesen: disconnected"));
713 ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.35f, 1.0f),
714 tr(
"Live sync error"));
715 if (ImGui::IsItemHovered()) {
749 if (event.
type ==
"frame_complete" ||
750 event.
type ==
"breakpoint_hit" || event.
type ==
"all") {
751 live_refresh_pending_.store(true);
760 const double now = ImGui::GetTime();
766 auto status =
live_client_->Subscribe({
"frame_complete",
"breakpoint_hit"});
784 const double now = ImGui::GetTime();
804 if (backend ==
nullptr) {
809 auto state_or = backend->ReadProgressionStateFromLiveSram(*
live_client_);
810 if (!state_or.ok()) {
815 backend->SetProgressionState(*
manifest_, *state_or);
848 return backend->GetStoryGraph(*
manifest_);
879 std::shared_ptr<emu::mesen::MesenSocketClient>
live_client_;
Loads and queries the hack manifest JSON for yaze-ASM integration.
void ClearOracleProgressionState()
const ProjectRegistry & project_registry() const
bool HasProjectRegistry() const
std::optional< OracleProgressionState > oracle_progression_state() const
void SetOracleProgressionState(const OracleProgressionState &state)
The complete Oracle narrative progression graph.
const std::vector< StoryEventNode > & nodes() const
const StoryEventNode * GetNode(const std::string &id) const
std::string selected_node_
workflow::HackWorkflowBackend * GetWorkflowBackend() const
float live_refresh_interval_seconds_
WindowLifecycle GetWindowLifecycle() const override
Get the lifecycle category for this window.
core::HackManifest * manifest_
static constexpr float kNodeHeight
bool IsNodeQueryMatch(const std::string &id) const
const core::StoryEventGraph * GetStoryGraph() const
void ImportOracleSramFromFileDialog()
std::unordered_map< std::string, bool > node_visible_by_id_
std::atomic< bool > live_refresh_pending_
void ProcessPendingLiveRefresh()
bool IsNodeVisible(const std::string &id) const
std::string GetId() const override
Unique identifier for this panel.
void DrawFilterControls(const core::StoryEventGraph &graph)
void PublishJumpToRoom(int room_id) const
void DetachLiveListener()
bool HasNonEmptyQuery() const
void ClearOracleSramState()
std::string loaded_srm_path_
std::string last_srm_error_
float GetPreferredWidth() const override
Get preferred width for this panel (optional)
double last_live_refresh_time_
uint8_t last_status_mask_
void SetManifest(core::HackManifest *manifest)
Inject manifest pointer (called by host editor or lazy-resolved).
std::string GetEditorCategory() const override
Editor category this panel belongs to.
void PublishJumpToAssemblySymbol(const std::string &symbol) const
uint64_t last_progress_fp_
bool RefreshStateFromLiveSram()
~StoryEventGraphPanel() override
std::string last_filter_query_
std::string GetIcon() const override
Material Design icon for this panel.
StoryEventGraphPanel()=default
std::string GetWorkflowGroup() const override
Optional workflow group for hack-centric actions.
void RefreshLiveClientBinding()
workflow::PlanningCapability * GetPlanningBackend() const
void PublishJumpToMessage(int message_id) const
emu::mesen::EventListenerId live_listener_id_
std::string GetDisplayName() const override
Human-readable name shown in menus and title bars.
std::shared_ptr< emu::mesen::MesenSocketClient > live_client_
workflow::ProgressionCapability * GetProgressionBackend() const
std::string filter_query_
void DrawNodeDetail(const core::StoryEventGraph &graph)
void Draw(bool *) override
Draw the panel content.
static ImU32 GetStatusColor(core::StoryNodeStatus status)
static uint8_t StatusMask(bool completed, bool available, bool locked, bool blocked)
void EnsureLiveSubscription()
void DrawLiveSyncControls()
bool IsEnabled() const override
Check if this panel is currently enabled.
void PublishJumpToMap(int map_id) const
static std::optional< int > ParseIntLoose(const std::string &input)
static constexpr float kNodeWidth
void UpdateFilterCache(const core::StoryEventGraph &graph)
std::unordered_map< std::string, bool > node_query_match_by_id_
std::string GetWorkflowDescription() const override
Optional workflow description for menus/command palette.
std::string live_sync_error_
bool live_subscription_active_
std::string GetDisabledTooltip() const override
Get tooltip text when panel is disabled.
double last_subscribe_attempt_time_
uint64_t ComputeProgressionFingerprint() const
virtual std::optional< core::OracleProgressionState > GetProgressionState(const core::HackManifest &manifest) const =0
virtual absl::StatusOr< core::OracleProgressionState > LoadProgressionStateFromFile(const std::string &filepath) const =0
static std::shared_ptr< MesenSocketClient > & GetClient()
static std::string ShowOpenFileDialog()
ShowOpenFileDialog opens a file dialog and returns the selected filepath. Uses global feature flag to...
#define ICON_MD_ACCOUNT_TREE
absl::StatusOr< OracleProgressionState > LoadOracleProgressionFromSrmFile(const std::string &srm_path)
bool StoryEventNodeMatchesQuery(const StoryEventNode &node, std::string_view query)
bool StoryNodeStatusAllowed(StoryNodeStatus status, const StoryEventNodeFilter &filter)
StoryNodeStatus
Completion status of a story event node for rendering.
::yaze::EventBus * event_bus()
Get the current EventBus instance.
workflow::PlanningCapability * hack_planning_backend()
::yaze::project::YazeProject * current_project()
Get the current project instance.
workflow::HackWorkflowBackend * hack_workflow_backend()
workflow::ProgressionCapability * hack_progression_backend()
Editors are the view controllers for the application.
WindowLifecycle
Defines lifecycle behavior for editor windows.
@ CrossEditor
User can pin to persist across editors.
std::optional< int > ResolveStoryLocationMapJumpTarget(const std::optional< int > &overworld_id, const std::optional< int > &special_world_id)
StoryEventGraph story_events
Filter options for StoryEventGraph node search in UI.
static JumpToAssemblySymbolRequestEvent Create(std::string sym, size_t session=0)
static JumpToMapRequestEvent Create(int map, size_t session=0)
static JumpToMessageRequestEvent Create(int message, size_t session=0)
static JumpToRoomRequestEvent Create(int room, size_t session=0)
Event from Mesen2 subscription.
std::vector< FileDialogFilter > filters