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"; }
63 return "Inspect narrative and progression dependencies for the active "
69 return project !=
nullptr && project->project_opened() &&
73 return "Story graph data is not available for the active hack project";
80 void Draw(
bool* )
override {
90 manifest_ = backend->ResolveManifest(project);
91 }
else if (project && project->hack_manifest.loaded()) {
97 ImGui::TextDisabled(tr(
"No hack project loaded"));
99 tr(
"Open a project with a hack manifest to view story events."));
108 if (graph ==
nullptr || !graph->loaded()) {
109 ImGui::TextDisabled(tr(
"No story events data available"));
114 if (ImGui::Button(tr(
"Reset View"))) {
120 ImGui::SliderFloat(tr(
"Zoom"), &
zoom_, 0.3f, 2.0f,
"%.1f");
122 ImGui::Text(tr(
"Nodes: %zu Edges: %zu"), graph->nodes().size(),
123 graph->edges().size());
125 const auto prog_opt =
129 if (prog_opt.has_value()) {
130 ImGui::TextDisabled(tr(
"Crystals: %d State: %s"),
131 prog_opt->GetCrystalCount(),
132 prog_opt->GetGameStateName().c_str());
134 ImGui::TextDisabled(tr(
"No SRAM loaded"));
138 if (ImGui::SmallButton(tr(
"Import .srm..."))) {
142 if (ImGui::SmallButton(tr(
"Clear SRAM"))) {
150 ImGui::TextDisabled(tr(
"SRM: %s"), p.filename().string().c_str());
151 if (ImGui::IsItemHovered()) {
157 ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), tr(
"SRM error: %s"),
169 ImVec2 canvas_pos = ImGui::GetCursorScreenPos();
170 ImVec2 canvas_size = ImGui::GetContentRegionAvail();
174 canvas_size.x -= sidebar_width;
176 if (canvas_size.x < 100 || canvas_size.y < 100)
179 ImGui::InvisibleButton(
180 "story_canvas", canvas_size,
181 ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight);
183 bool is_hovered = ImGui::IsItemHovered();
184 bool is_active = ImGui::IsItemActive();
187 if (is_active && ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
188 ImVec2 delta = ImGui::GetIO().MouseDelta;
195 float wheel = ImGui::GetIO().MouseWheel;
197 zoom_ *= (wheel > 0) ? 1.1f : 0.9f;
205 ImDrawList* draw_list = ImGui::GetWindowDrawList();
208 draw_list->PushClipRect(
210 ImVec2(canvas_pos.x + canvas_size.x, canvas_pos.y + canvas_size.y),
214 float cx = canvas_pos.x + canvas_size.x * 0.5f +
scroll_x_;
215 float cy = canvas_pos.y + canvas_size.y * 0.5f +
scroll_y_;
218 for (
const auto& edge : graph->edges()) {
219 const auto* from_node = graph->GetNode(edge.from);
220 const auto* to_node = graph->GetNode(edge.to);
221 if (!from_node || !to_node)
229 cy + from_node->pos_y *
zoom_);
231 cy + to_node->pos_y *
zoom_);
234 float ctrl_dx = (p2.x - p1.x) * 0.4f;
235 ImVec2 cp1(p1.x + ctrl_dx, p1.y);
236 ImVec2 cp2(p2.x - ctrl_dx, p2.y);
238 draw_list->AddBezierCubic(p1, cp1, cp2, p2, IM_COL32(150, 150, 150, 180),
242 ImVec2 dir(p2.x - cp2.x, p2.y - cp2.y);
243 float len = sqrtf(dir.x * dir.x + dir.y * dir.y);
247 float arrow_size = 8.0f *
zoom_;
248 ImVec2 arrow1(p2.x - dir.x * arrow_size + dir.y * arrow_size * 0.4f,
249 p2.y - dir.y * arrow_size - dir.x * arrow_size * 0.4f);
250 ImVec2 arrow2(p2.x - dir.x * arrow_size - dir.y * arrow_size * 0.4f,
251 p2.y - dir.y * arrow_size + dir.x * arrow_size * 0.4f);
252 draw_list->AddTriangleFilled(p2, arrow1, arrow2,
253 IM_COL32(150, 150, 150, 200));
258 ImVec2 mouse_pos = ImGui::GetIO().MousePos;
260 for (
const auto& node : graph->nodes()) {
270 ImVec2 node_min(nx, ny);
271 ImVec2 node_max(nx + nw, ny + nh);
276 const bool query_match =
278 ImU32 border_color = selected
279 ? IM_COL32(255, 255, 100, 255)
280 : (query_match ? IM_COL32(220, 220, 220, 255)
281 : IM_COL32(60, 60, 60, 255));
283 draw_list->AddRectFilled(node_min, node_max, fill_color, 8.0f *
zoom_);
284 draw_list->AddRect(node_min, node_max, border_color, 8.0f *
zoom_, 0,
288 float font_size = 11.0f *
zoom_;
289 if (font_size >= 6.0f) {
291 draw_list->AddText(
nullptr, font_size,
293 IM_COL32(200, 200, 200, 255), node.id.c_str());
295 std::string display_name = node.name;
296 if (display_name.length() > 25) {
297 display_name = display_name.substr(0, 22) +
"...";
299 draw_list->AddText(
nullptr, font_size,
301 IM_COL32(255, 255, 255, 255), display_name.c_str());
305 if (is_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
306 if (mouse_pos.x >= node_min.x && mouse_pos.x <= node_max.x &&
307 mouse_pos.y >= node_min.y && mouse_pos.y <= node_max.y) {
313 draw_list->PopClipRect();
331 return IM_COL32(40, 120, 40, 220);
333 return IM_COL32(180, 160, 40, 220);
335 return IM_COL32(160, 40, 40, 220);
338 return IM_COL32(60, 60, 60, 220);
347 ImGui::BeginChild(
"node_detail", ImVec2(240, 0), ImGuiChildFlags_Borders);
349 ImGui::TextWrapped(
"%s", node->name.c_str());
350 ImGui::TextDisabled(
"%s", node->id.c_str());
353 if (!node->flags.empty()) {
354 ImGui::Text(tr(
"Flags:"));
355 for (
const auto& flag : node->flags) {
356 if (!flag.value.empty()) {
357 ImGui::BulletText(
"%s = %s", flag.name.c_str(), flag.value.c_str());
359 ImGui::BulletText(
"%s", flag.name.c_str());
364 if (!node->locations.empty()) {
366 ImGui::Text(tr(
"Locations:"));
367 for (
size_t i = 0; i < node->locations.size(); ++i) {
368 const auto& loc = node->locations[i];
371 const auto special_world_id =
ParseIntLoose(loc.special_world_id);
372 const auto jump_map_id =
374 ImGui::PushID(
static_cast<int>(i));
376 ImGui::BulletText(
"%s", loc.name.c_str());
380 if (ImGui::SmallButton(tr(
"Room"))) {
386 if (ImGui::SmallButton(tr(
"Map"))) {
389 }
else if (overworld_id || special_world_id) {
391 ImGui::BeginDisabled();
392 ImGui::SmallButton(tr(
"Map"));
393 ImGui::EndDisabled();
394 if (ImGui::IsItemHovered()) {
396 tr(
"Map target is outside the supported overworld range."));
400 if (!loc.room_id.empty() || !loc.overworld_id.empty() ||
401 !loc.special_world_id.empty() || !loc.entrance_id.empty()) {
403 tr(
"room=%s map=%s special=%s entrance=%s"),
404 loc.room_id.empty() ?
"-" : loc.room_id.c_str(),
405 loc.overworld_id.empty() ?
"-" : loc.overworld_id.c_str(),
406 loc.special_world_id.empty() ?
"-" : loc.special_world_id.c_str(),
407 loc.entrance_id.empty() ?
"-" : loc.entrance_id.c_str());
414 if (!node->text_ids.empty()) {
416 ImGui::Text(tr(
"Text IDs:"));
417 for (
size_t i = 0; i < node->text_ids.size(); ++i) {
418 const auto& tid = node->text_ids[i];
419 ImGui::PushID(
static_cast<int>(i));
421 ImGui::BulletText(
"%s", tid.c_str());
423 if (ImGui::SmallButton(tr(
"Open"))) {
429 if (ImGui::SmallButton(tr(
"Copy"))) {
430 ImGui::SetClipboardText(tid.c_str());
437 if (!node->scripts.empty()) {
439 ImGui::Text(tr(
"Scripts:"));
440 for (
size_t i = 0; i < node->scripts.size(); ++i) {
441 const auto& script = node->scripts[i];
442 ImGui::PushID(
static_cast<int>(i));
443 ImGui::BulletText(
"%s", script.c_str());
445 if (ImGui::SmallButton(tr(
"Open"))) {
449 if (ImGui::SmallButton(tr(
"Copy"))) {
450 ImGui::SetClipboardText(script.c_str());
456 if (!node->notes.empty()) {
458 ImGui::TextWrapped(tr(
"Notes: %s"), node->notes.c_str());
466 size_t start = input.find_first_not_of(
" \t\r\n");
467 if (start == std::string::npos)
469 size_t end = input.find_last_not_of(
" \t\r\n");
470 std::string trimmed = input.substr(start, end - start + 1);
474 int value = std::stoi(trimmed, &idx, 0);
475 if (idx != trimmed.size())
510 const auto prog_opt =
514 if (!prog_opt.has_value())
517 const auto& s = *prog_opt;
518 return static_cast<uint64_t
>(s.crystal_bitfield) |
519 (
static_cast<uint64_t
>(s.game_state) << 8) |
520 (
static_cast<uint64_t
>(s.oosprog) << 16) |
521 (
static_cast<uint64_t
>(s.oosprog2) << 24) |
522 (
static_cast<uint64_t
>(s.side_quest) << 32) |
523 (
static_cast<uint64_t
>(s.pendants) << 40);
529 ImGui::Text(tr(
"Filter"));
531 ImGui::SetNextItemWidth(260.0f);
532 if (ImGui::InputTextWithHint(
"##story_graph_filter",
533 "Search id/name/text/script/flag/room...",
538 if (ImGui::SmallButton(tr(
"Clear"))) {
552 bool toggles_changed =
false;
557 toggles_changed |= ImGui::Checkbox(tr(
"Locked"), &
show_locked_);
559 toggles_changed |= ImGui::Checkbox(tr(
"Blocked"), &
show_blocked_);
560 if (toggles_changed) {
565 static uint8_t
StatusMask(
bool completed,
bool available,
bool locked,
596 const size_t node_count = graph.
nodes().size();
621 for (
const auto& node : graph.
nodes()) {
622 const bool query_match =
644 {
"SRAM (.srm)",
"srm"},
648 std::string file_path =
650 if (file_path.empty()) {
658 if (!state_or.ok()) {
664 backend->SetProgressionState(*
manifest_, *state_or);
679 backend->ClearProgressionState(*
manifest_);
690 if (ImGui::SmallButton(tr(
"Sync Mesen"))) {
694 if (ImGui::IsItemHovered()) {
695 ImGui::SetTooltip(tr(
"Read Oracle SRAM directly from connected Mesen2"));
702 ImGui::SetNextItemWidth(70.0f);
703 ImGui::SliderFloat(
"##StoryGraphLiveInterval",
709 ImGui::TextDisabled(tr(
"Mesen: connected"));
711 ImGui::TextDisabled(tr(
"Mesen: disconnected"));
716 ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.35f, 1.0f),
717 tr(
"Live sync error"));
718 if (ImGui::IsItemHovered()) {
752 if (event.
type ==
"frame_complete" ||
753 event.
type ==
"breakpoint_hit" || event.
type ==
"all") {
754 live_refresh_pending_.store(true);
763 const double now = ImGui::GetTime();
769 auto status =
live_client_->Subscribe({
"frame_complete",
"breakpoint_hit"});
787 const double now = ImGui::GetTime();
807 if (backend ==
nullptr) {
812 auto state_or = backend->ReadProgressionStateFromLiveSram(*
live_client_);
813 if (!state_or.ok()) {
818 backend->SetProgressionState(*
manifest_, *state_or);
851 return backend->GetStoryGraph(*
manifest_);
882 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_
WindowPresentationPolicy GetPresentationPolicy() const override
How this content is hosted and advertised by workspace chrome.
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)
static WindowPresentationPolicy OptionalPopOut()
Event from Mesen2 subscription.
std::vector< FileDialogFilter > filters