diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ab3a43..48f62c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,10 +68,11 @@ Write idiomatic Zig 0.17, as OpenReliant's guide describes: name every number, g `enum` and switch over it exhaustively, use tagged unions for variants and optionals for "none", check invariants with `comptime`, and share code rather than copy it. -C and C++ come in through two shims and one header: the text editor's -([`src/ui/text_editor`](src/ui/text_editor)), Dear ImGui's internal calls -([`src/ui/imgui_extras`](src/ui/imgui_extras)), and [`src/platform/c.h`](src/platform/c.h), which -the build translates into the `c` module with SDL3 and Dear Bindings' C API for Dear ImGui. +C and C++ come in through three shims and one header: the text editor's +([`src/ui/text_editor`](src/ui/text_editor)), ImPlot's for the map ([`src/ui/plot`](src/ui/plot)), +Dear ImGui's internal calls ([`src/ui/imgui_extras`](src/ui/imgui_extras)), and +[`src/platform/c.h`](src/platform/c.h), which the build translates into the `c` module with SDL3 +and Dear Bindings' C API for Dear ImGui. Add `test` blocks next to the code in the same change, with inputs built by hand so that they run without the game's files. `--check-missions` opens each of a game's own missions as source and diff --git a/README.md b/README.md index 19a217f..15b64cd 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ A mission editor, script debugger and mod editor for StarLancer's engine. It links to the running game, so that a change to a mission or a mod shows in the game as it's made. -**Status:** Mission mode's Script view works. It opens a mod's missions, a mission file or one of -the game's own missions as source, colours it with the game's commands, checks it as it's typed, -offers the game's commands and conditions as you type, explains them on hover and in the Reference -panel, builds the mission, and plays it in OpenReliant, from its start or from a part. The inspector -edits the records as forms, and the outline adds new ones ([Editing records](docs/editing.md)). -Linked to the game, it holds the mission's script at breakpoints, steps through it, and shows its -threads and values ([Debugging a mission](docs/debugging.md)). The rest of the [design](design/README.md) is in -place and greyed out, each part with the issue that plans it. +**Status:** Mission mode's Script and Scene views work. The Script view opens a mod's missions, a +mission file or one of the game's own missions as source, colours it with the game's commands, +checks it as it's typed, offers the game's commands and conditions as you type, explains them on +hover and in the Reference panel, builds the mission, and plays it in OpenReliant, from its start or +from a part. The inspector edits the records as forms, the outline adds new ones, and the Scene +view's map shows the ships and markers and moves them ([Editing records](docs/editing.md)). Linked +to the game, it holds the mission's script at breakpoints, steps through it, and shows its threads +and values ([Debugging a mission](docs/debugging.md)). The rest of the [design](design/README.md) is +in place and greyed out, each part with the issue that plans it. The plan is OpenReliant's [#1126](https://github.com/OpenReliant/openreliant/issues/1126). ![The Script view of a mission, held at a breakpoint](design/images/mission-script.png) diff --git a/build.zig b/build.zig index 4946127..129279a 100644 --- a/build.zig +++ b/build.zig @@ -159,12 +159,14 @@ fn addWindowsResources(b: *std.Build, exe: *std.Build.Step.Compile, target: std. } /// Dear ImGui with its docking branch, Dear Bindings' C API over it, its SDL3 and SDL_GPU backends, -/// and the text editor with its C shim, as one static library. It is built optimized whatever the -/// editor's own mode, so that typing in a long mission stays smooth in a debug build too. +/// and the text editor and ImPlot with their C shims, as one static library. It is built optimized +/// whatever the editor's own mode, so that typing in a long mission stays smooth in a debug build +/// too. fn interfaceLibrary(b: *std.Build, target: std.Build.ResolvedTarget, sdl_library: *std.Build.Step.Compile) *std.Build.Step.Compile { const imgui = b.dependency("imgui", .{}); const bindings = b.dependency("dear_bindings", .{}); const text_editor = b.dependency("text_editor", .{}); + const implot = b.dependency("implot", .{}); const library = b.addLibrary(.{ .name = "interface", .linkage = .static, @@ -190,17 +192,20 @@ fn interfaceLibrary(b: *std.Build, target: std.Build.ResolvedTarget, sdl_library library.root_module.addCSourceFile(.{ .file = text_editor.path("TextEditor.cpp"), .flags = flags }); library.root_module.addCSourceFile(.{ .file = b.path("src/ui/text_editor/shim.cpp"), .flags = flags }); library.root_module.addCSourceFile(.{ .file = b.path("src/ui/imgui_extras/extras.cpp"), .flags = flags }); + library.root_module.addCSourceFiles(.{ .root = implot.path("."), .files = &.{ "implot.cpp", "implot_items.cpp" }, .flags = flags }); + library.root_module.addCSourceFile(.{ .file = b.path("src/ui/plot/plot_shim.cpp"), .flags = flags }); // The libraries' licences go with the program. for ([_]struct { std.Build.LazyPath, []const u8 }{ .{ imgui.path("LICENSE.txt"), "LICENSE-imgui.txt" }, .{ bindings.path("LICENSE.txt"), "LICENSE-dear-bindings.txt" }, .{ text_editor.path("LICENSE"), "LICENSE-text-editor.txt" }, + .{ implot.path("LICENSE"), "LICENSE-implot.txt" }, }) |notice| b.getInstallStep().dependOn(&b.addInstallFile(notice[0], notice[1]).step); return library; } /// The folders the interface's headers are in. -fn interfaceIncludes(b: *std.Build) [7]std.Build.LazyPath { +fn interfaceIncludes(b: *std.Build) [9]std.Build.LazyPath { const imgui = b.dependency("imgui", .{}); const bindings = b.dependency("dear_bindings", .{}); return .{ @@ -211,6 +216,8 @@ fn interfaceIncludes(b: *std.Build) [7]std.Build.LazyPath { b.dependency("text_editor", .{}).path("."), b.path("src/ui/text_editor"), b.path("src/ui/imgui_extras"), + b.dependency("implot", .{}).path("."), + b.path("src/ui/plot"), }; } diff --git a/build.zig.zon b/build.zig.zon index 1f42ec2..62bd639 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -44,6 +44,10 @@ .url = "git+https://codeberg.org/ziglang/rc?ref=zig-0.17.x#a8a3b3cebbe6e414fa12d6ad028c56a3a349c75e", .hash = "rc-0.0.0-rMftL71EAAAxhe4swpM5rrfcmC9k_j7ApwJJPRssBiDL", }, + .implot = .{ + .url = "git+https://github.com/epezent/implot.git?ref=v1.0#524f9fcd48d76c13fdf94c5ffbba8787a1ff7e39", + .hash = "N-V-__8AAKSTCwA_ALBxWjOi20O0c9_WB3Y1COzRQxwZLpLy", + }, }, .paths = .{ "build.zig", diff --git a/design/images/mission-scene.png b/design/images/mission-scene.png index 6dbd9a0..281bea5 100644 Binary files a/design/images/mission-scene.png and b/design/images/mission-scene.png differ diff --git a/design/screens/mission-scene.html b/design/screens/mission-scene.html index 4d537d6..df3b833 100644 --- a/design/screens/mission-scene.html +++ b/design/screens/mission-scene.html @@ -274,6 +274,10 @@ Ranges + diff --git a/docs/architecture.md b/docs/architecture.md index 8353907..0c8d8d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,10 +24,12 @@ own library, which `build.zig.zon` pins, so it reads every mission exactly as th | [`panels.zig`](../src/panels.zig) | The dockable panels: the outline, problems, the inspector and the others | | [`forms.zig`](../src/forms.zig) | The inspector's forms: a record's fields as controls that write the source | | [`views/script.zig`](../src/views/script.zig) | The Script view: the source, its gutter, help and completion, and the listing | +| [`views/scene.zig`](../src/views/scene.zig) | The Scene view: the map of the mission's ships and markers, its ranges, curves and routes, and moving them | +| [`views/scene/drawing.zig`](../src/views/scene/drawing.zig) | How the map draws its parts: the grid, rings, legs, marks, names and notes | | [`palette.zig`](../src/palette.zig) | The command palette, Play from part, and the game's missions | | [`screens/`](../src/screens) | The start screen and the settings | | [`toasts.zig`](../src/toasts.zig) | Short messages that fade out | -| [`ui/`](../src/ui) | The interface kit: colours, fonts, icons, widgets, the text editor | +| [`ui/`](../src/ui) | The interface kit: colours, fonts, icons, widgets, the text editor, and the map's plot (ImPlot, through a C shim) | | [`platform/`](../src/platform) | The window, SDL_GPU, ImGui's backends, and the C header the build translates | ## The game profile @@ -41,6 +43,8 @@ profile, a `game.Profile`: parameters, the game's variables, and the names of its values; - the records a mission's source declares, for the outline and the inspector, the fields each record's table can give, and the changes that edit them or declare a new record; +- what the map shows of a mission: where its records start and which way its ships point, the + ranges its triggers watch, and its curves; - building source into a mission file, with the map of its script's statements to the source's lines, decompiling a mission file into source, and comparing two missions and their scripts; - the game's messages on the editor link, and the script's flags for a set of offsets; @@ -80,6 +84,34 @@ are each on one. While drawing, the interface only queues the change (`Document. makes it once the frame is drawn, since making it checks the text again, which replaces the outline the frame drew from. +## The map + +The Scene view's map draws on ImPlot, which gives it equal axes, the pointer's pan and dragging; the +editor reaches it through a C shim (`ui/plot/plot_shim.h`), as it reaches the text editor. The +editor zooms the map itself, around the pointer, and switches ImPlot's zoom off, since ImPlot +doesn't zoom while the pointer is over a marker. + +The profile says what the map shows (`Profile.scene`). StarLancer's places each ship and marker by +its `position`, and says which are scenery: the planets of the original's table +(`create.planets.original`) and the sun's and nebulae's markers. It points a ship as OpenReliant +turns it by its angles (`mission.recordOrientation`), rings each ShipReached trigger's nav point or +waypoint at the 4000 the game watches within, rings each ship the script's `SetFlybackMarker` +names at its range, and draws each curve as the director's camera runs it +(`executor.curves.point`). It reads the routes from the script's syntax tree, in the order the +source gives it: the orders that `SetAI` gives, found by the command catalogue's parameters and +sorted by OpenReliant's order table (`ai.orders.Order`) into flying, jumping and coming in, and the +patrol routes `SetPatrolRoute` sets. A branch that leaves a ship in different places stops its +route. The document keeps the scene until its text or its outline changes, since reading it parses +the whole script. + +The map fits the mission without its scenery, which lies far off. Each frame it gathers the things +that would overlap on the screen into one mark, the most important first: the selection, the player, +the ships, the markers, the camera's points and the scenery. Names then go in the same order, each +on the first side of its mark where it covers no mark and no name, and crosses no route where a side +is free. The map draws its own grid, so that its scale is always a cell, and draws only the part of +a range's ring or a route's leg that shows. A dragged ship or marker is queued as an edit of its +place, as the forms' edits are, when it's let go. + ## The debugger The debugger links to the game over OpenReliant's editor link ([Debugging a mission](debugging.md)). @@ -99,8 +131,7 @@ profile (`Profile.link`), so that it never names StarLancer's tags or opcodes. Each shows in the interface, greyed out, with its issue: -- the Scene view ([#2](https://github.com/OpenReliant/openreliant-editor/issues/2)) and the Blocks - view ([#3](https://github.com/OpenReliant/openreliant-editor/issues/3)); +- the Blocks view ([#3](https://github.com/OpenReliant/openreliant-editor/issues/3)); - Mod mode, with Reload scripts and Play with this mod only ([#4](https://github.com/OpenReliant/openreliant-editor/issues/4)); - the debugger's conditions, log points, Watch, values inline and Debugging screen diff --git a/docs/editing.md b/docs/editing.md index f534ae1..aec82dc 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -32,3 +32,59 @@ A name the source uses without declaring it shows in the Problems panel with **D a record under that name: choose what kind it is. Squads, formations and curves list other records, and are written in the source. + +## The map + +The Scene view (Ctrl+2, or Cmd+2 on macOS) shows the mission's map. **Top**, **Side** and +**Front** look at it from above, from the side and from the front; the note in the map's corner +says which way its axes point. Ships show as arrows that point the way they start, and nav points, +waypoints and points, such as where ships jump in, as diamonds, in their flight groups' colours. +Planets, the sun and nebulae show as faint discs. The scale in the corner is one cell of the grid. + +A mission opens fitted to its ships and markers, leaving out the planets and the sun, which lie far +off. Many of the game's missions take place in several areas millions of units apart, so they open +as an overview: things that would overlap on the screen show as one round mark with how many it +holds, named after its most important thing. + +- Drag the map to move it, and scroll to zoom in and out around the pointer. **Fit**, or a double + click on an empty part of the map, shows the whole mission again. +- Point at a numbered mark to list what it holds, and click it to zoom in to them. Where its + things lie on the same spot, the click lists them instead, to pick one. +- Names go where they have room, the selection's and the player's first, and keep off the routes + where they can. Point at a mark to see a name that has no room. +- Click a ship or a marker to select it: the inspector shows it, and the map marks it with its + place. Drag it to move where it starts: its `position` changes in the source when you let go, as + one step of Undo. Hold Shift to snap to 100. A click on an empty part of the map clears the + selection. +- **Ranges** rings each nav point or waypoint a ShipReached trigger watches, at the 4000 the game + watches within, and dots a ring where the script sets a flyback marker, at its range. + **Curves** shows the curves the director's camera flies, with their points. +- **Routes** shows where the script sends each ship and flight group (see below). +- **Follow** keeps a ship in the middle of the map. +- Where the map doesn't show the player's start, a note at its edge points to it. + +**Live** and **Orders** show where the ships are and what they do in the running game, and come +with the live scene ([#21](https://github.com/OpenReliant/openreliant-editor/issues/21)). + +### Routes + +A route links the places the script sends a ship or a flight group to, in its group's colour. A +dotted leg is one the ship flies (Fly and Escort), and a dashed leg one it jumps (Jump Out and Warp +Out). An arrowhead shows where each leg goes, and a small ring where the route starts. In an +overview, the player's route shows the order the mission visits its areas in. + +- A route starts where its ship starts, or a group's where its first ship starts. A ship the script + brings in by a jump or a warp waits far off until then, so its route starts where it comes in. +- When a ship reaches a marker that a ShipReached trigger watches, the trigger's orders continue + its route from there. +- A patrol route (`SetPatrolRoute`) goes round its flight group's waypoints in the order the mission + lists them, and back to the first. +- Where an `if` sends a ship to different places, the route stops there, since either could be + where it is next. +- A leg to or from a place off the map is a short stub that points the way. +- Select a ship, a group or a marker to make the routes it's on stand out, and click an empty part + of the map to show them all alike again. + +Routes read the script in the order the source gives its orders. A trigger runs when its condition +holds, so in the game a ship may take a route's legs in another order. The live scene will show +where ships really go. diff --git a/src/app.zig b/src/app.zig index 45ef76f..3fb90be 100644 --- a/src/app.zig +++ b/src/app.zig @@ -317,7 +317,14 @@ pub const App = struct { if (commands.info(command).planned) |plan| return .{ .reason = gui.print("Planned · {f}", .{plan}) }; const needs_document = "Open a mission first"; return switch (command) { - .add_record, .save, .build, .show_source, .show_listing, .toggle_breakpoint, .clear_breakpoints, .go_to_line, .comment_line, .find, .find_next, .replace, .undo, .redo, .cut, .copy, .paste, .select_all => if (app.document == null) .{ .reason = needs_document } else null, + .add_record, .save, .build, .show_source, .show_listing, .clear_breakpoints, .go_to_line, .find, .find_next, .replace, .undo, .redo => if (app.document == null) .{ .reason = needs_document } else null, + // These act on the source's cursor and selection, which only the Script view shows. + .toggle_breakpoint, .comment_line, .cut, .copy, .paste, .select_all => if (app.document == null) + .{ .reason = needs_document } + else if (app.view != .script) + .{ .reason = "Script view only" } + else + null, .play, .play_from_part => if (app.document == null) .{ .reason = needs_document } else if (app.settings.game_folder == null) .{ .reason = "Set the game's folder in Game settings" } else null, .new_mission => if (app.settings.game_folder == null and app.project == null) .{ .reason = "Set the game's folder in Game settings" } else null, .edit_game_mission => if (app.settings.game_folder == null) .{ .reason = "Set the game's folder in Game settings" } else null, @@ -352,7 +359,6 @@ pub const App = struct { fn viewUnavailable(app: *const App, command: Command) ?widgets.Disabled { return switch (app.mode) { .mission => switch (command) { - .view_2 => .{ .reason = gui.print("Planned · {f}", .{commands.plans.scene}) }, .view_3 => .{ .reason = gui.print("Planned · {f}", .{commands.plans.blocks}) }, .view_4 => .{ .reason = "Mission mode has three views" }, else => null, @@ -423,24 +429,28 @@ pub const App = struct { .paste => if (app.document) |document| document.editor.paste(), .select_all => if (app.document) |document| document.editor.selectAll(), .find, .replace => if (app.document) |document| { - app.source_tab = .source; + app.showSource(.source); document.editor.openFind(); }, - .find_next => if (app.document) |document| document.editor.findNext(), + .find_next => if (app.document) |document| { + app.showSource(.source); + document.editor.findNext(); + }, .comment_line => if (app.document) |document| document.editor.toggleComments(), .go_to_line => app.palette.show(.line), .palette => app.palette.show(.commands), .go_to => app.palette.show(.records), .view_1 => app.view = .script, - .view_2, .view_3, .view_4 => {}, + .view_2 => app.view = .scene, + .view_3, .view_4 => {}, .layout_full, .reset_layout => { app.open_panels = .initFill(true); app.layout_due = true; }, .full_screen => app.window.toggleFullScreen(), .build => app.build(), - .show_source => app.source_tab = .source, - .show_listing => app.source_tab = .listing, + .show_source => app.showSource(.source), + .show_listing => app.showSource(.listing), .toggle_breakpoint => if (app.document) |document| app.toggleBreakpoint(document.editor.cursor().line), .clear_breakpoints => if (app.document) |document| { document.clearBreakpoints(); @@ -1012,23 +1022,47 @@ pub const App = struct { .added => |added| { const line = added.line orelse return app.toasts.push(.info, gui.print("Can't add {s}", .{asked.add.name orelse asked.add.kind.label}), reason); app.selection = .{ .section = added.section, .line = line }; - app.goTo(line, 0); + // The map shows the new record selected, and the Script view its line. + if (app.view == .script) app.goTo(line, 0) else app.placeCursor(line, 0); }, } } + /// The record the inspector shows and the map marks: the one selected, or else the one the + /// source's cursor is in. + pub fn inspected(app: *const App) ?*const game.Record { + const document = app.document orelse return null; + const known = document.outline(); + if (app.selection) |chosen| return known.find(chosen.section, chosen.line); + return known.at(@intCast(document.editor.cursor().line)); + } + pub fn select(app: *App, record: game.Record) void { app.selection = .{ .section = record.section, .line = record.first_line }; } - /// Moves the source's cursor to `line` and `column`, counting from 0, and shows it. + /// Moves the source's cursor to `line` and `column`, counting from 0, and shows it in the + /// Script view, switching to it from another view. pub fn goTo(app: *App, line: u32, column: u32) void { const document = app.document orelse return; - app.source_tab = .source; - app.screen = .workspace; + app.showSource(.source); + app.placeCursor(line, column); + document.editor.focus(); + } + + /// Moves the source's cursor to `line` and `column`, counting from 0, and scrolls the source + /// to it, without switching views. + fn placeCursor(app: *App, line: u32, column: u32) void { + const document = app.document orelse return; document.editor.setCursor(.{ .line = line, .index = column }); document.editor.scrollToLine(line); - document.editor.focus(); + } + + /// Shows the source panel's `tab` in the Script view, switching to it from another view. + fn showSource(app: *App, tab: SourceTab) void { + app.screen = .workspace; + app.view = .script; + app.source_tab = tab; } fn askQuit(app: *App) void { diff --git a/src/commands.zig b/src/commands.zig index ba26772..42b8993 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -193,7 +193,6 @@ fn defaults(comptime windows_linux: []const []const u8, comptime macos: []const /// Issues of what doesn't work yet, by what each plans. pub const plans = struct { - pub const scene: Plan = .{ .issue = 2 }; pub const blocks: Plan = .{ .issue = 3 }; /// Mod mode's views, Reload scripts, and Play with this mod only. pub const mod_mode: Plan = .{ .issue = 4 }; diff --git a/src/forms.zig b/src/forms.zig index a928c07..067855e 100644 --- a/src/forms.zig +++ b/src/forms.zig @@ -92,7 +92,7 @@ pub fn trigger(app: *App, document: *Document, known: game.Outline, shown: game. } } - const repeats = fieldOf(shown, "repeats"); + const repeats = shown.given("repeats"); const repeat_index: usize = if (repeats) |given| (if (std.mem.eql(u8, given.value, "\"always\"")) 1 else if (std.mem.eql(u8, given.value, "\"once\"")) 0 else 2) else 0; form.label("Repeats", false); if (widgets.segmented("repeats", &.{ "Once", "Always", "Counted" }, repeat_index, off)) |chosen| if (chosen != repeat_index) { @@ -105,7 +105,7 @@ pub fn trigger(app: *App, document: *Document, known: game.Outline, shown: game. }; for (document.profile.fields(shown)) |spec| if (std.mem.eql(u8, spec.name, "component")) form.field(spec, labelOf(spec.name), false); form.label("Deferred", false); - const deferred = fieldOf(shown, "deferred"); + const deferred = shown.given("deferred"); const checked = if (deferred) |given| std.mem.eql(u8, given.value, "true") else false; if (widgets.checkbox("deferred", "Run on scheduler", checked, off)) form.set("deferred", if (checked) null else "true"); } @@ -139,7 +139,7 @@ const Form = struct { gui.tooltip(spec.about); gui.pushId(spec.name); defer gui.popId(); - const given = fieldOf(form.shown, spec.name); + const given = form.shown.given(spec.name); switch (spec.kind) { .record => |sections| { const target = if (given) |found| (if (found.names_record) form.known.named(found.value) else null) else null; @@ -178,7 +178,9 @@ const Form = struct { /// A vector's three boxes, X, Y and Z in their colours. A vector left out shows the origin. fn vector(form: Form, spec: game.FieldForm, given: ?game.Field) void { var values: [3]f64 = @splat(0); - if (given) |found| parseVector(found.value, &values); + if (given) |found| if (found.vector()) |vector_given| { + values = vector_given; + }; const tags = [3]widgets.Tag{ .{ .letter = "X", .color = theme.axis_x }, .{ .letter = "Y", .color = theme.axis_y }, @@ -211,7 +213,7 @@ const Form = struct { gui.setCursor(.{ .x = at.x + @as(f32, @floatFromInt(place)) * (each + px(row_gap)), .y = at.y }); gui.pushId(spec.name); defer gui.popId(); - const given = fieldOf(form.shown, spec.name); + const given = form.shown.given(spec.name); const tag: widgets.Tag = .{ .letter = spec.letter, .color = theme.axis_off, .text = theme.text }; if (widgets.textField("number", if (given) |found| found.value else "0", .{ .number = true, .tag = tag, .width = each, .disabled = form.off })) |typed| { if (form.numberText(typed)) |value| form.set(spec.name, value); @@ -292,12 +294,6 @@ fn markOf(shown: game.Record) widgets.RowMark { return .{ .icon = .{ .icon = panels.sectionIcon(shown.section), .color = panels.groupColor(shown) } }; } -/// The field `name` of `shown`'s table, if it gives one. -fn fieldOf(shown: game.Record, name: []const u8) ?game.Field { - for (shown.fields) |given| if (std.mem.eql(u8, given.name, name)) return given; - return null; -} - /// A field's label from its name: `component` as `Component`, and `_` as a space. fn labelOf(name: []const u8) []const u8 { const made = gui.frameAllocator().dupe(u8, name) catch return name; @@ -306,24 +302,6 @@ fn labelOf(name: []const u8) []const u8 { return made; } -/// The numbers of a vector as the outline shows it, `{ -2000, 0, 42000 }`, into `values`; the ones -/// it doesn't give stay as they are. -fn parseVector(text: []const u8, values: *[3]f64) void { - var parts = std.mem.tokenizeAny(u8, text, "{}, "); - for (values) |*value| { - const part = parts.next() orelse return; - value.* = std.fmt.parseFloat(f64, part) catch return; - } -} - test labelOf { try std.testing.expectEqualStrings("Formation point", labelOf("formation_point")); } - -test parseVector { - var values: [3]f64 = @splat(0); - parseVector("{ -2000, 0, 42000.5 }", &values); - try std.testing.expectEqual([3]f64{ -2000, 0, 42000.5 }, values); - parseVector("…", &values); - try std.testing.expectEqual([3]f64{ -2000, 0, 42000.5 }, values); -} diff --git a/src/game.zig b/src/game.zig index ab32d23..3129533 100644 --- a/src/game.zig +++ b/src/game.zig @@ -46,6 +46,8 @@ pub const Profile = struct { /// The kind of value a type of mission source takes, such as `FlightGroup?` or `ShipType`, as /// the language's parameters and fields name them. fieldKind: *const fn (type_name: []const u8) FieldKind, + /// What the map shows of the mission whose source is `text`, as `known` outlines it. + scene: *const fn (arena: Allocator, text: []const u8, known: Outline) Allocator.Error!Scene, /// The kinds of record the outline's + adds, in the order its menu lists them. new_kinds: []const NewKind, /// The change to `text`, whose outline is `known`, that declares a new record of `kind` after @@ -204,6 +206,85 @@ pub const FieldKind = union(enum) { source, }; +/// What the map shows of a mission (`Profile.scene`), in the game's coordinates, Y down: where its +/// records start, the ranges its triggers watch, its curves, and where its script sends its ships. +pub const Scene = struct { + things: []const Thing, + /// The player's ship, by its place in `things`, where the mission has one. + player: ?usize = null, + ranges: []const Range, + /// Each curve, as the points it passes. + curves: []const []const [3]f64, + routes: []const Route = &.{}, + /// The field the map writes a dragged thing's place to, such as `position`. + place_field: []const u8, +}; + +/// A record the map places. +pub const Thing = struct { + /// The record, by its section and its place among the section's (`Outline.nth`). + section: Section, + index: usize, + kind: ThingKind, + /// Where it starts. + place: [3]f64, + /// The way it points, as a unit vector; null for what points nowhere, such as a marker. + heading: ?[3]f64 = null, +}; + +/// What a thing on the map is, which picks how it's drawn. +pub const ThingKind = enum { + /// A ship, a capital ship or a station: an arrow that points the way it heads. + ship, + /// A nav point, a waypoint or a point, such as where a ship jumps in: a diamond. + marker, + /// A point of the director's camera's curves: shown with the curves. + camera, + /// Scenery far off, such as a planet or the sun: a ring, which the map's fit leaves out. + scenery, +}; + +/// Where the script sends a ship or a group: a leg from each place it sends it to the next, as its +/// orders read in the order the source gives them. It's a reading of the script, not a run of it: +/// triggers run when their conditions hold, which may be in another order. +pub const Route = struct { + /// The ship it's for, by its place in `Scene.things`; none for a group's. + ship: ?usize, + /// The flight group whose colour it takes: the ship's, or the group's own (`Record.group`). + group: ?usize, + legs: []const Leg, +}; + +/// A leg of a route, between two things by their place in `Scene.things`. +pub const Leg = struct { + from: usize, + to: usize, + way: Way, + /// Whether the route starts at `from`: where its ship starts, or where it comes in. + starts: bool = false, +}; + +/// How a ship goes along a leg. +pub const Way = enum { fly, jump }; + +/// A distance the mission watches within, around one of its records. +pub const Range = struct { + /// The record it's around, by its section and its place among the section's. + section: Section, + index: usize, + radius: f64, + /// What watches it, such as a trigger's condition. + label: []const u8, + style: RangeStyle, +}; + +pub const RangeStyle = enum { + /// A trigger fires within it. + trigger, + /// A hint of the game's, such as the display's flyback marker. + hint, +}; + /// A kind of record the outline's + adds. pub const NewKind = struct { /// The word that declares it, such as `ship`. @@ -490,6 +571,15 @@ pub const Field = struct { value: []const u8, /// Whether the value names another record, such as `group = alpha`. names_record: bool = false, + + /// The value as a place or a direction, where the source writes one as numbers: + /// `{ -2000, 0, 42000 }`. + pub fn vector(field: Field) ?[3]f64 { + var parts = std.mem.tokenizeAny(u8, field.value, "{}, "); + var made: [3]f64 = undefined; + for (&made) |*value| value.* = std.fmt.parseFloat(f64, parts.next() orelse return null) catch return null; + return made; + } }; pub const Record = struct { @@ -521,6 +611,12 @@ pub const Record = struct { first_line: u32, last_line: u32, column: u32 = 0, + + /// The field `name` its table gives, if it gives one. + pub fn given(record: Record, name: []const u8) ?Field { + for (record.fields) |field| if (std.mem.eql(u8, field.name, name)) return field; + return null; + } }; /// The number of characters to add, remove or change to turn `a` into `b`, for names up to 64 @@ -560,6 +656,11 @@ test Mission { try std.testing.expectEqual(20, built.statementOn(8).?.offset); } +test Field { + try std.testing.expectEqual([3]f64{ -2000, 0, 42000.5 }, (Field{ .name = "position", .value = "{ -2000, 0, 42000.5 }" }).vector().?); + try std.testing.expectEqual(null, (Field{ .name = "position", .value = "…" }).vector()); +} + test editDistance { try std.testing.expectEqual(3, editDistance("enemy", "enemies")); try std.testing.expectEqual(0, editDistance("nav", "nav")); diff --git a/src/game/starlancer.zig b/src/game/starlancer.zig index 07b46e6..8d9e23d 100644 --- a/src/game/starlancer.zig +++ b/src/game/starlancer.zig @@ -12,6 +12,12 @@ const dte = openreliant.dte; const source = dte.source; const syntax = source.syntax; const commands = openreliant.engine.game.executor.commands; +const orders = openreliant.engine.game.ai.orders; +const curves = openreliant.engine.game.executor.curves; +const math = openreliant.engine.surrender.math; +const game_mission = openreliant.engine.game.mission; +const gameobj = openreliant.engine.game.gameobj; +const planets = openreliant.engine.game.create.planets; const conditions = openreliant.engine.vm.conditions; const vm = openreliant.engine.vm; const mission_editor = openreliant.engine.game.mission.editor; @@ -31,6 +37,7 @@ pub const profile: game.Profile = .{ .edit = edit, .fields = fieldForms, .fieldKind = fieldKind, + .scene = scene, .new_kinds = &new_kinds, .addRecord = addRecord, .listing = listing, @@ -650,6 +657,417 @@ const wings = names: { break :names final; }; +/// What the map shows of a mission: each ship and marker where its `position` places it, a ship +/// pointing as the game turns it by its yaw, pitch and roll (`mission.recordOrientation`), its +/// planets and the sun's and nebulae's markers as scenery, the range of each ShipReached trigger +/// and of each flyback marker the script sets, each curve as the director's camera runs it +/// (`executor.curves.point`), and the routes the script sends ships along (`RouteWalk`). +fn scene(arena: Allocator, text: []const u8, known: game.Outline) Allocator.Error!game.Scene { + const tree = try source_parser.parse(arena, text); + var things: std.ArrayList(game.Thing) = .empty; + var ranges: std.ArrayList(game.Range) = .empty; + var paths: std.ArrayList([]const [3]f64) = .empty; + var walk: RouteWalk = .{ .arena = arena, .tree = tree, .known = known }; + var player: ?usize = null; + for (known.records) |record| switch (record.section) { + .ships => { + // The first ship is the player's. + if (record.index == 0) player = things.items.len; + const scenery = if (record.given("type")) |given| isScenery(std.mem.trim(u8, given.value, "\"")) else false; + try walk.addThing(record, things.items.len); + try things.append(arena, .{ + .section = record.section, + .index = record.index, + .kind = if (scenery) .scenery else .ship, + .place = vectorOf(record, "position"), + .heading = if (scenery) null else headingOf(record), + }); + }, + .markers => { + try walk.addThing(record, things.items.len); + try things.append(arena, .{ + .section = record.section, + .index = record.index, + .kind = markerKind(record), + .place = vectorOf(record, "position"), + }); + }, + .flight_groups => if (record.group) |group| try walk.groups.put(arena, record.name, group), + .squads => try walk.squads.put(arena, record.name, {}), + .triggers => if (std.meta.stringToEnum(dte.Condition, record.condition) == .ship_reached) { + const subject = known.named(record.subject) orelse continue; + try ranges.append(arena, .{ .section = subject.section, .index = subject.index, .radius = reached_reach, .label = record.condition, .style = .trigger }); + }, + .curves => try paths.append(arena, try curvePath(arena, record)), + else => {}, + }; + // The flyback markers the script sets: `SetFlybackMarker(nav, 5000)`, anywhere in its code. + for (0..tree.nodes.len) |index| { + const call = callOf(tree, @intCast(index)) orelse continue; + if (!std.mem.eql(u8, call.callee, set_flyback_marker.name) or call.arguments.len < set_flyback_marker.params.len) continue; + const name = globalName(tree, call.arguments[0]) orelse continue; + const radius = switch (tree.get(call.arguments[1])) { + .number => |value| value, + else => continue, + }; + const subject = known.named(name) orelse continue; + try ranges.append(arena, .{ .section = subject.section, .index = subject.index, .radius = radius, .label = "flyback", .style = .hint }); + } + return .{ .things = things.items, .player = player, .ranges = ranges.items, .curves = paths.items, .routes = try walk.read(), .place_field = "position" }; +} + +/// How the map shows a marker: a point that marks a place on a curve, and a curve point, with the +/// curves; a nav point, a waypoint and the other points, such as where ships jump in, always. +fn markerKind(record: game.Record) game.ThingKind { + return switch (std.meta.stringToEnum(source.names.Word, record.word) orelse return .marker) { + .curve_point => .camera, + .point => if (record.given("marker") != null) .camera else .marker, + else => .marker, + }; +} + +/// The commands the map reads: `SetAI`, whose order sends the entity it names to its target +/// (command `0x0B`), `SetPatrolRoute`, which sends it round a flight group's waypoints (`0x0D`), +/// and `SetFlybackMarker`, which sets the display's flyback marker on it within a range (`0x46`). +const set_ai = commands.table[0x0B]; +const set_patrol_route = commands.table[0x0D]; +const set_flyback_marker = commands.table[0x46]; +/// `SetAI`'s parameters: the entity it sends, its order, and its target, which may be none. +const set_ai_entity = 0; +const set_ai_order = for (set_ai.params, 0..) |param, index| { + if (source.vocabulary.of(param) == .order) break index; +} else @compileError("SetAI takes no order"); +const set_ai_target = set_ai.params.len - 1; + +comptime { + std.debug.assert(std.mem.eql(u8, set_ai.name, "SetAI") and std.mem.eql(u8, set_patrol_route.name, "SetPatrolRoute")); + std.debug.assert(std.mem.eql(u8, set_flyback_marker.name, "SetFlybackMarker")); + std.debug.assert(set_ai.params[set_ai_target].kinds.ship and set_ai.params[set_ai_target].extra.nullable); + std.debug.assert(set_patrol_route.params[1].kinds.flight_group); +} + +/// How an order sends a ship to its target, where it does (OpenReliant's +/// [orders](https://github.com/OpenReliant/openreliant/blob/main/docs/engine/orders.md#the-orders)): +/// Fly and Escort fly it there, Jump Out and Warp Out jump it there, and Jump In and Warp In bring +/// it in there. +const Move = enum { fly, jump, arrive }; + +fn moveOf(order: orders.Order) ?Move { + return switch (order) { + .fly, .escort => .fly, + .jump_out, .jump_out_spread, .warp_out, .fixed_gate_jump_out => .jump, + .jump_in, .jump_in_spread, .warp_in, .fixed_gate_jump_in => .arrive, + else => null, + }; +} + +/// The order the argument at `index` gives: by its name, or by its number. +fn orderOf(tree: syntax.Tree, index: syntax.Index) ?orders.Order { + const number: u32 = switch (tree.get(index)) { + .string => |name| source.vocabulary.value(.order, name) orelse return null, + .number => |value| if (value >= 0 and value <= std.math.maxInt(i16) and @floor(value) == value) @intFromFloat(value) else return null, + else => return null, + }; + return @fromBackingInt(std.math.cast(i16, number) orelse return null); +} + +fn globalName(tree: syntax.Tree, index: syntax.Index) ?[]const u8 { + return switch (tree.get(index)) { + .global_name => |name| name, + else => null, + }; +} + +/// Reads where the script sends its ships and groups (`game.Route`): each order that sends one to +/// a marker, in the order the source gives them, through branches and triggers. +const RouteWalk = struct { + arena: Allocator, + tree: syntax.Tree, + known: game.Outline, + /// The ships and markers of the scene, by their records' names. + things: std.StringHashMapUnmanaged(Placed) = .empty, + /// The flight groups, by name, each with its place among them (`Record.group`), and the + /// squads. + groups: std.StringHashMapUnmanaged(usize) = .empty, + squads: std.StringHashMapUnmanaged(void) = .empty, + /// Each flight group's first ship, where a group's route starts. + first_ships: std.AutoHashMapUnmanaged(usize, []const u8) = .empty, + /// The ships and groups the script brings in by a jump or a warp, by name, and the groups by + /// their place among the groups: such a ship waits far off until it comes in, so its route + /// starts where it comes in, not where it starts. + arriving: std.StringHashMapUnmanaged(void) = .empty, + arriving_groups: std.AutoHashMapUnmanaged(usize, void) = .empty, + /// Where the orders read so far send each ship or group. + whereabouts: Whereabouts = .empty, + /// The legs so far, by the name of the ship or group they're for. + legs: std.StringArrayHashMapUnmanaged(std.ArrayList(game.Leg)) = .empty, + + /// A ship or a marker of the scene: its place among the scene's things, its section, and its + /// flight group. + const Placed = struct { thing: usize, section: game.Section, group: ?usize }; + + /// A place a route reaches: the thing, and whether the route starts there. + const Spot = struct { thing: usize, starts: bool }; + + /// Where the orders send each ship or group, by name: at a spot, or lost where the script + /// leaves it unknown, such as after branches that send it to different places. + const Whereabouts = std.StringHashMapUnmanaged(union(enum) { at: Spot, lost }); + + fn addThing(walk: *RouteWalk, record: game.Record, thing: usize) Allocator.Error!void { + try walk.things.put(walk.arena, record.name, .{ .thing = thing, .section = record.section, .group = record.group }); + if (record.section != .ships) return; + const group = record.group orelse return; + const first = try walk.first_ships.getOrPut(walk.arena, group); + if (!first.found_existing) first.value_ptr.* = record.name; + } + + /// The routes, from the whole script. + fn read(walk: *RouteWalk) Allocator.Error![]const game.Route { + for (0..walk.tree.nodes.len) |index| { + const found = callOf(walk.tree, @intCast(index)) orelse continue; + if (!std.mem.eql(u8, found.callee, set_ai.name) or found.arguments.len < set_ai.params.len) continue; + const given = orderOf(walk.tree, found.arguments[set_ai_order]) orelse continue; + if (moveOf(given) != .arrive) continue; + const subject = globalName(walk.tree, found.arguments[set_ai_entity]) orelse continue; + try walk.arriving.put(walk.arena, subject, {}); + if (walk.groups.get(subject)) |group| try walk.arriving_groups.put(walk.arena, group, {}); + } + try walk.visit(walk.tree.root); + var routes: std.ArrayList(game.Route) = .empty; + var each = walk.legs.iterator(); + while (each.next()) |entry| { + const ship = walk.things.get(entry.key_ptr.*); + try routes.append(walk.arena, .{ + .ship = if (ship) |placed| placed.thing else null, + .group = if (ship) |placed| placed.group else walk.groups.get(entry.key_ptr.*), + .legs = entry.value_ptr.items, + }); + } + return routes.items; + } + + /// Reads the orders of the code at `index`, statement by statement. + fn visit(walk: *RouteWalk, index: syntax.Index) Allocator.Error!void { + switch (walk.tree.get(index)) { + .block => |statements| for (statements) |statement| try walk.visit(statement), + .@"if" => |both| try walk.branch(both.then, both.@"else"), + .call_statement => |statement| try walk.visit(statement), + .call => |found| try walk.call(found.function, found.arguments), + .function => |function| try walk.visit(function.body), + .local, .assign => |binding| for (binding.values) |value| try walk.visit(value), + .@"return" => |values| for (values) |value| try walk.visit(value), + .table => |items| for (items) |item| try walk.visit(item), + .item => |item| try walk.visit(switch (item) { + .list => |value| value, + .record => |record| record.value, + .general => |general| general.value, + }), + else => {}, + } + } + + fn call(walk: *RouteWalk, function: syntax.Index, arguments: []const syntax.Index) Allocator.Error!void { + if (globalName(walk.tree, function)) |callee| { + if (std.mem.eql(u8, callee, set_ai.name)) return walk.order(arguments); + if (std.mem.eql(u8, callee, set_patrol_route.name)) return walk.patrol(arguments); + if (std.meta.stringToEnum(source.names.Word, callee) == .on) try walk.reached(arguments); + } + for (arguments) |argument| try walk.visit(argument); + } + + /// `SetAI`: an order that sends the ship or group to a marker adds a leg from where it is. + fn order(walk: *RouteWalk, arguments: []const syntax.Index) Allocator.Error!void { + if (arguments.len < set_ai.params.len) return; + const subject = walk.subjectOf(arguments[set_ai_entity]) orelse return; + const move = moveOf(orderOf(walk.tree, arguments[set_ai_order]) orelse return) orelse return; + // Sent out of the mission, or after a ship: where it is next isn't known. + const target = walk.markerOf(arguments[set_ai_target]) orelse return walk.whereabouts.put(walk.arena, subject, .lost); + const from = walk.whereIs(subject); + if (from) |spot| if (spot.thing == target) return; + switch (move) { + .arrive => return walk.whereabouts.put(walk.arena, subject, .{ .at = .{ .thing = target, .starts = true } }), + .fly, .jump => if (from) |spot| { + try walk.addLeg(subject, .{ .from = spot.thing, .to = target, .way = if (move == .jump) .jump else .fly, .starts = spot.starts }); + }, + } + try walk.whereabouts.put(walk.arena, subject, .{ .at = .{ .thing = target, .starts = false } }); + } + + /// `SetPatrolRoute`: the ship or group flies to the route's first waypoint, then round its + /// waypoints in the order the mission lists them, back to the first (OpenReliant's + /// `ai/formations.zig`, `nextWaypoint`). Where it is when the patrol starts isn't known, the + /// route starts at the first waypoint. + fn patrol(walk: *RouteWalk, arguments: []const syntax.Index) Allocator.Error!void { + if (arguments.len < set_patrol_route.params.len) return; + const subject = walk.subjectOf(arguments[0]) orelse return; + const route = walk.groups.get(globalName(walk.tree, arguments[1]) orelse return) orelse return; + var at = walk.whereIs(subject); + var first: ?usize = null; + for (walk.known.records) |record| { + if (record.group != route or std.meta.stringToEnum(source.names.Word, record.word) != .waypoint) continue; + const thing = (walk.things.get(record.name) orelse continue).thing; + if (first == null) first = thing; + if (at) |spot| if (spot.thing != thing) { + try walk.addLeg(subject, .{ .from = spot.thing, .to = thing, .way = .fly, .starts = spot.starts }); + }; + const starts = at == null; + at = .{ .thing = thing, .starts = starts }; + } + if (first) |back| if (at) |spot| if (spot.thing != back) { + try walk.addLeg(subject, .{ .from = spot.thing, .to = back, .way = .fly }); + }; + // Where the patrol has it when another order comes isn't known. + try walk.whereabouts.put(walk.arena, subject, .lost); + } + + /// A ShipReached trigger: as its code runs, the ship its value names is at the marker it + /// watches. + fn reached(walk: *RouteWalk, arguments: []const syntax.Index) Allocator.Error!void { + if (arguments.len < 3) return; + const condition = stringOf(walk.tree, arguments[1]) orelse return; + if (std.meta.stringToEnum(dte.Condition, condition) != .ship_reached) return; + const marker = walk.markerOf(arguments[0]) orelse return; + const items = switch (walk.tree.get(arguments[2])) { + .table => |items| items, + else => return, + }; + for (items) |item| switch (walk.tree.get(item)) { + .item => |kind| switch (kind) { + .record => |record| if (walk.subjectOf(record.value)) |ship| { + try walk.whereabouts.put(walk.arena, ship, .{ .at = .{ .thing = marker, .starts = false } }); + }, + else => {}, + }, + else => {}, + }; + } + + /// Reads both ways of a branch from where the walk is. Where they leave a ship or a group in + /// different places, it could be in either: lost. + fn branch(walk: *RouteWalk, then: syntax.Index, otherwise: ?syntax.Index) Allocator.Error!void { + const before = try walk.whereabouts.clone(walk.arena); + try walk.visit(then); + const after_then = walk.whereabouts; + walk.whereabouts = before; + if (otherwise) |other| try walk.visit(other); + var each = after_then.iterator(); + while (each.next()) |entry| { + const other = walk.whereabouts.get(entry.key_ptr.*) orelse { + try walk.whereabouts.put(walk.arena, entry.key_ptr.*, .lost); + continue; + }; + if (!std.meta.eql(other, entry.value_ptr.*)) try walk.whereabouts.put(walk.arena, entry.key_ptr.*, .lost); + } + var others = walk.whereabouts.iterator(); + while (others.next()) |entry| if (!after_then.contains(entry.key_ptr.*)) { + entry.value_ptr.* = .lost; + }; + } + + /// Where `subject` is as the walk reaches an order for it: where the orders before sent it, or + /// else where it starts, unless the script brings it in by a jump or a warp. Null where that + /// isn't known. + fn whereIs(walk: *const RouteWalk, subject: []const u8) ?Spot { + if (walk.whereabouts.get(subject)) |found| return switch (found) { + .at => |spot| spot, + .lost => null, + }; + const start = walk.startOf(subject) orelse return null; + return .{ .thing = start, .starts = true }; + } + + /// Where `subject` starts: a ship where it is, a flight group where its first ship is; none + /// for a ship the script brings in by a jump or a warp, by itself or with its group, or for a + /// squad. + fn startOf(walk: *const RouteWalk, subject: []const u8) ?usize { + const ship = if (walk.groups.get(subject)) |group| walk.first_ships.get(group) orelse return null else subject; + const placed = walk.things.get(ship) orelse return null; + if (placed.section != .ships or walk.arriving.contains(ship)) return null; + if (placed.group) |group| if (walk.arriving_groups.contains(group)) return null; + return placed.thing; + } + + /// The name of the ship, flight group or squad the argument at `index` names. + fn subjectOf(walk: *const RouteWalk, index: syntax.Index) ?[]const u8 { + const name = globalName(walk.tree, index) orelse return null; + if (walk.things.get(name)) |placed| return if (placed.section == .ships) name else null; + if (walk.groups.contains(name) or walk.squads.contains(name)) return name; + return null; + } + + /// The thing of the marker the argument at `index` names. + fn markerOf(walk: *const RouteWalk, index: syntax.Index) ?usize { + const placed = walk.things.get(globalName(walk.tree, index) orelse return null) orelse return null; + return if (placed.section == .markers) placed.thing else null; + } + + /// Adds a leg to `subject`'s route, once. + fn addLeg(walk: *RouteWalk, subject: []const u8, leg: game.Leg) Allocator.Error!void { + const found = try walk.legs.getOrPut(walk.arena, subject); + if (!found.found_existing) found.value_ptr.* = .empty; + for (found.value_ptr.items) |*known| if (known.from == leg.from and known.to == leg.to and known.way == leg.way) { + known.starts = known.starts or leg.starts; + return; + }; + try found.value_ptr.append(walk.arena, leg); + } +}; + +/// Whether a ship of the type `type_name` is scenery: a planet, as the original's table of planets +/// has it (`create.planets.original`), or the sun's or a nebula's marker. +fn isScenery(type_name: []const u8) bool { + const number = source.vocabulary.value(.ship_type, type_name) orelse return false; + const ship_type: gameobj.Type = @fromBackingInt(std.math.cast(@typeInfo(gameobj.Type).@"enum".tag_type, number) orelse return false); + for (planets.original) |planet| if (planet.type == ship_type) return true; + return ship_type == gameobj.Type.of(.sun_marker) or ship_type == gameobj.Type.of(.nebula_marker); +} + +/// How far ShipReached's triggers look around a nav point or a waypoint, as OpenReliant's mission +/// events watch (`reached_reach`, `0x0052A5D0`). +const reached_reach = 4000; + +/// The points a curve's path passes on the map. +const curve_points = 48; + +/// The vector a record's field `name` gives; the origin where it gives none. +fn vectorOf(record: game.Record, name: []const u8) [3]f64 { + const given = record.given(name) orelse return @splat(0); + return given.vector() orelse @splat(0); +} + +/// The way a ship's nose points, as the game turns it by its angles. An angle left out, or unset +/// as -1, is 0. +fn headingOf(record: game.Record) [3]f64 { + var ship = std.mem.zeroes(dte.Ship); + ship.yaw = angleOf(record, "yaw"); + ship.pitch = angleOf(record, "pitch"); + ship.roll = angleOf(record, "roll"); + const nose = math.forward(game_mission.recordOrientation(ship)); + return .{ nose[0], nose[1], nose[2] }; +} + +fn angleOf(record: game.Record, name: []const u8) i16 { + const given = record.given(name) orelse return 0; + const degrees = std.fmt.parseFloat(f64, given.value) catch return 0; + if (degrees == -1) return 0; + return std.math.lossyCast(i16, degrees); +} + +/// The points a curve record's path passes, from its `from` to its `to`, along its tangents. +fn curvePath(arena: Allocator, record: game.Record) Allocator.Error![]const [3]f64 { + var curve = dte.Curve.blank; + for ([_]struct { *[3]f32, []const u8 }{ .{ &curve.from, "from" }, .{ &curve.to, "to" }, .{ &curve.leaving, "leaving" }, .{ &curve.arriving, "arriving" } }) |part| { + const value = vectorOf(record, part[1]); + part[0].* = .{ @floatCast(value[0]), @floatCast(value[1]), @floatCast(value[2]) }; + } + const points = try arena.alloc([3]f64, curve_points); + for (points, 0..) |*point, at| { + const on = curves.point(curve, @as(f32, @floatFromInt(at)) / (curve_points - 1)); + point.* = .{ on[0], on[1], on[2] }; + } + return points; +} + /// The records the outline's + adds. Squads, formations and curves list other records, and are /// written in the source. const new_kinds = [_]game.NewKind{ @@ -1026,6 +1444,121 @@ test addRecord { try std.testing.expect(source.vocabulary.value(.condition, new_condition) != null); } +test scene { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const made = try outline(arena.allocator(), first_mission); + const shown = try scene(arena.allocator(), first_mission, made); + // The player, the nav point and the two Sabres, each where it starts. + try std.testing.expectEqual(4, shown.things.len); + try std.testing.expectEqual(made.named("player").?.index, shown.things[shown.player.?].index); + const sabre = made.named("sabre_1").?; + for (shown.things) |thing| if (thing.section == sabre.section and thing.index == sabre.index) { + try std.testing.expectEqual([3]f64{ -2000, 0, 42000 }, thing.place); + // Turned half round, it points back along Z. + try std.testing.expectApproxEqAbs(-1, thing.heading.?[2], 1e-6); + }; + // The nav point's ShipReached trigger watches within 4000 of it, and the start part sets the + // flyback marker on it within 5000. + try std.testing.expectEqual(2, shown.ranges.len); + try std.testing.expectEqual(made.named("nav").?.index, shown.ranges[0].index); + try std.testing.expectEqual(4000, shown.ranges[0].radius); + try std.testing.expectEqual(5000, shown.ranges[1].radius); + try std.testing.expectEqual(game.RangeStyle.hint, shown.ranges[1].style); + // A planet's type is scenery; a Sabre's isn't. + try std.testing.expect(isScenery("neptune_hi") and !isScenery("sabre")); +} + +test RouteWalk { + const text = + \\alpha = flight_group("(FG)Alpha", { wing = "player" }) + \\raiders = flight_group("(FG)Raiders") + \\round = flight_group("(FG)Round") + \\player = ship("Player", { type = "predator", group = alpha }) + \\wingman = ship("Wingman", { type = "predator", group = alpha, position = { 500, 0, 0 } }) + \\raider = ship("Raider", { type = "sabre", group = raiders, position = { 9000000, 0, 0 } }) + \\nav = nav_point("Nav 1", { position = { 0, 0, 40000 } }) + \\entry = point("Raider in", { position = { 5000, 0, 30000 } }) + \\target = waypoint("Raider fly to", { position = { 2000, 0, 35000 } }) + \\out = point("Raider out", { position = { 9000, 0, 35000 } }) + \\left = waypoint("Left", { position = { -3000, 0, 1000 } }) + \\right = waypoint("Right", { position = { 3000, 0, 1000 } }) + \\later = waypoint("Later", { position = { 0, 0, 9000 } }) + \\one = waypoint("One", { group = round, position = { 0, 0, 1 } }) + \\two = waypoint("Two", { group = round, position = { 0, 0, 2 } }) + \\three = waypoint("Three", { group = round, position = { 0, 0, 3 } }) + \\shot = point("Shot", { marker = { curve = path, at = 0.5 } }) + \\start = part("(F)Start", function() + \\ SetAI(raider, "jump_in", 1, entry) + \\ SetAI(raider, "fly", 1, target) + \\ SetAI(alpha, "jump_out", 1, nav) + \\ if game.mission_success == 1 then + \\ SetAI(wingman, "fly", 1, left) + \\ else + \\ SetAI(wingman, "fly", 1, right) + \\ end + \\ SetAI(wingman, "fly", 1, later) + \\ SetPatrolRoute(raiders, round) + \\ return 1 + \\end, { start = true }) + \\on(target, "ship_reached", { reached_ship = raider }, function(reached_ship) + \\ SetAI(raider, "jump_out", 1, out) + \\ return 1 + \\end) + \\ + ; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const made = try outline(arena.allocator(), text); + const shown = try scene(arena.allocator(), text, made); + const at = struct { + fn thing(things: []const game.Thing, known: game.Outline, name: []const u8) usize { + const record = known.named(name).?; + for (things, 0..) |each, index| if (each.section == record.section and each.index == record.index) return index; + unreachable; + } + }; + const legsOf = struct { + fn route(routes: []const game.Route, things: []const game.Thing, known: game.Outline, name: []const u8) []const game.Leg { + const record = known.named(name).?; + for (routes) |each| { + if (each.ship) |ship| if (things[ship].section == record.section and things[ship].index == record.index) return each.legs; + if (each.ship == null and record.section == .flight_groups and each.group == record.group) return each.legs; + } + return &.{}; + } + }; + const things = shown.things; + // The raider waits far off until it jumps in, so its route starts where it comes in: it flies + // to its waypoint, and on reaching it jumps out. + try std.testing.expectEqualSlices(game.Leg, &.{ + .{ .from = at.thing(things, made, "entry"), .to = at.thing(things, made, "target"), .way = .fly, .starts = true }, + .{ .from = at.thing(things, made, "target"), .to = at.thing(things, made, "out"), .way = .jump }, + }, legsOf.route(shown.routes, things, made, "raider")); + // The player's group jumps to the nav point from where its first ship starts. + try std.testing.expectEqualSlices(game.Leg, &.{ + .{ .from = at.thing(things, made, "player"), .to = at.thing(things, made, "nav"), .way = .jump, .starts = true }, + }, legsOf.route(shown.routes, things, made, "alpha")); + // Either branch flies the wingman from its start; after them it could be at either, so no leg + // leads to the next waypoint. + try std.testing.expectEqualSlices(game.Leg, &.{ + .{ .from = at.thing(things, made, "wingman"), .to = at.thing(things, made, "left"), .way = .fly, .starts = true }, + .{ .from = at.thing(things, made, "wingman"), .to = at.thing(things, made, "right"), .way = .fly, .starts = true }, + }, legsOf.route(shown.routes, things, made, "wingman")); + // The raiders' patrol route goes round its waypoints in the order they're listed, back to the + // first. The group comes in nowhere and starts at its first ship, the raider, which jumps in, + // so the route starts at the first waypoint. + try std.testing.expectEqualSlices(game.Leg, &.{ + .{ .from = at.thing(things, made, "one"), .to = at.thing(things, made, "two"), .way = .fly, .starts = true }, + .{ .from = at.thing(things, made, "two"), .to = at.thing(things, made, "three"), .way = .fly }, + .{ .from = at.thing(things, made, "three"), .to = at.thing(things, made, "one"), .way = .fly }, + }, legsOf.route(shown.routes, things, made, "raiders")); + // A point where a ship comes in is a marker; one that marks a place on a curve shows with the + // curves. + try std.testing.expectEqual(game.ThingKind.marker, things[at.thing(things, made, "entry")].kind); + try std.testing.expectEqual(game.ThingKind.camera, things[at.thing(things, made, "shot")].kind); +} + test fieldForms { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); diff --git a/src/main.zig b/src/main.zig index 468b0b5..6c0fb89 100644 --- a/src/main.zig +++ b/src/main.zig @@ -21,6 +21,7 @@ pub const ui = @import("ui.zig"); pub const document = @import("mission/document.zig"); pub const edit = @import("mission/edit.zig"); pub const forms = @import("forms.zig"); +pub const scene_view = @import("views/scene.zig"); pub const script_view = @import("views/script.zig"); pub const start_screen = @import("screens/start.zig"); pub const settings_screen = @import("screens/settings.zig"); @@ -48,7 +49,7 @@ const Options = struct { check_missions: bool = false, print_version: bool = false, - const Show = enum { start, settings, palette, keys, link, problems }; + const Show = enum { start, settings, palette, keys, link, problems, scene }; const usage = \\Usage: openreliant-editor [] [options] @@ -59,7 +60,7 @@ const Options = struct { \\ --screenshot draw without a window and save a frame as a PNG file \\ --frames the frames drawn before the picture; 10 by default \\ --size x the window's size; 1440x900 by default - \\ --show start, settings, keys, link, palette or problems + \\ --show start, settings, keys, link, palette, problems or scene \\ --check-missions open each of the game's missions as source, build it again, \\ and check it's the same mission \\ @@ -137,6 +138,8 @@ pub fn main(init: std.process.Init) !u8 { const ini_path: ?[:0]const u8 = if (editor_settings.folder) |folder| try std.fmt.allocPrintSentinel(arena, "{s}layout.ini", .{folder}, 0) else null; var window = try Window.open("OpenReliant Editor", options.width, options.height, if (ini_path) |path| path.ptr else null, options.screenshot != null); defer window.close(); + ui.plot.create(); + defer ui.plot.destroy(); ui.fonts.load(); ui.theme.apply(window.scale); ui.gui.scale = window.scale; @@ -164,6 +167,7 @@ pub fn main(init: std.process.Init) !u8 { }, .palette => editor.palette.show(.commands), .problems => editor.showPanel(.problems), + .scene => editor.run(.view_2), }; var frame: u32 = 0; diff --git a/src/mission/document.zig b/src/mission/document.zig index 720461a..6513fc3 100644 --- a/src/mission/document.zig +++ b/src/mission/document.zig @@ -65,6 +65,8 @@ pub const Document = struct { good: Check, /// Whether the outline is from older source than the text's. outline_stale: bool = false, + /// A number that changes whenever `good` does. + outline_generation: usize = 0, built: ?Build = null, /// The listing of the last build's script, in its own arena. listing: ?[]const u8 = null, @@ -78,6 +80,10 @@ pub const Document = struct { /// from. Its strings are in `queue_arena`. queued: ?Queued = null, queue_arena: std.heap.ArenaAllocator, + /// What the map shows of the text (`scene`), in its own arena, with the text's version and the + /// outline's generation it was read from. + scene_read: ?struct { version: usize, generation: usize, scene: game.Scene } = null, + scene_arena: std.heap.ArenaAllocator, /// Opens the source file at `path`. pub fn open(gpa: Allocator, io: Io, path: []const u8, profile: *const game.Profile, language: ?text_editor.Language) !*Document { @@ -104,6 +110,7 @@ pub const Document = struct { .good = .{ .arena = .init(gpa) }, .listing_arena = .init(gpa), .queue_arena = .init(gpa), + .scene_arena = .init(gpa), }; document.recheck() catch {}; return document; @@ -115,6 +122,7 @@ pub const Document = struct { document.good.deinit(); document.listing_arena.deinit(); document.queue_arena.deinit(); + document.scene_arena.deinit(); document.gpa.free(document.path); document.gpa.destroy(document); } @@ -133,6 +141,21 @@ pub const Document = struct { return document.good.outline; } + /// What the map shows of the mission (`Profile.scene`), read again only once the text or its + /// outline changed, since reading it parses the whole script. + pub fn scene(document: *Document) Allocator.Error!game.Scene { + const version = document.editor.version(); + if (document.scene_read) |read| { + if (read.version == version and read.generation == document.outline_generation) return read.scene; + } + document.scene_read = null; + _ = document.scene_arena.reset(.retain_capacity); + const arena = document.scene_arena.allocator(); + const made = try document.profile.scene(arena, try arena.dupe(u8, document.editor.text()), document.outline()); + document.scene_read = .{ .version = version, .generation = document.outline_generation, .scene = made }; + return made; + } + /// The build of the text as last checked, with the map of its script's statements; null while /// the text has problems. pub fn mission(document: *const Document) ?game.Mission { @@ -186,6 +209,7 @@ pub const Document = struct { good.outline = try copyOutline(good.arena.allocator(), check.outline); document.good.deinit(); document.good = good; + document.outline_generation +%= 1; document.outline_stale = false; } else { document.outline_stale = true; diff --git a/src/panels.zig b/src/panels.zig index 23bc179..6025c2b 100644 --- a/src/panels.zig +++ b/src/panels.zig @@ -15,6 +15,7 @@ const commands = @import("commands.zig"); const game = @import("game.zig"); const Document = @import("mission/document.zig").Document; const forms = @import("forms.zig"); +const scene_view = @import("views/scene.zig"); const script_view = @import("views/script.zig"); const ui = @import("ui.zig"); const fonts = ui.fonts; @@ -36,7 +37,10 @@ pub fn draw(app: *App) void { switch (panel) { .mission => outline(app), .find => find(app), - .source => script_view.draw(app), + .source => switch (app.view) { + .scene => scene_view.draw(app), + .script, .blocks => script_view.draw(app), + }, .threads => threads(app), .breakpoints => breakpoints(app), .output => output(app), @@ -737,12 +741,7 @@ fn liveScene() void { fn inspector(app: *App) void { const document = app.document orelse return waiting(.sliders_horizontal, "No mission open", null); const known = document.outline(); - const chosen = app.selection orelse blk: { - // Without a selection, the record the cursor is in. - const record = known.at(@intCast(document.editor.cursor().line)) orelse return waiting(.sliders_horizontal, "Select a record in the outline or the source", null); - break :blk app_mod.Selection{ .section = record.section, .line = record.first_line }; - }; - const record = known.find(chosen.section, chosen.line) orelse return waiting(.sliders_horizontal, "Select a record in the outline or the source", null); + const record = app.inspected() orelse return waiting(.sliders_horizontal, "Select a record in the outline, the source or the map", null); gui.pushVar2(c.ImGuiStyleVar_WindowPadding, .{ .x = px(12), .y = px(14) }); // The forms take the panel's whole width, as the design's do: it scrolls without a bar. _ = c.ImGui_BeginChild("inspector", .{ .x = 0, .y = 0 }, c.ImGuiChildFlags_AlwaysUseWindowPadding, c.ImGuiWindowFlags_NoScrollbar); diff --git a/src/platform/c.h b/src/platform/c.h index 4d8092c..9ae2e1d 100644 --- a/src/platform/c.h +++ b/src/platform/c.h @@ -1,6 +1,6 @@ /* The C interfaces the editor uses, which the build translates into one Zig module: SDL3, Dear * ImGui through Dear Bindings with its SDL3 and SDL_GPU backends, the few internal calls the - * editor makes, and the text editor's shim. */ + * editor makes, and the shims of the text editor and of ImPlot. */ #include #include "dcimgui.h" @@ -8,4 +8,5 @@ #include "dcimgui_impl_sdlgpu3.h" #include "extras.h" +#include "plot_shim.h" #include "shim.h" diff --git a/src/screens/settings.zig b/src/screens/settings.zig index 4103acb..2a785fb 100644 --- a/src/screens/settings.zig +++ b/src/screens/settings.zig @@ -186,7 +186,7 @@ fn linkPage(app: *App) void { defer row.end(); const labels = [_][]const u8{ "Off", "1 Hz", "4 Hz" }; row.centre(choice_height); - if (choice("state rate", &labels, @backingInt(link.state_rate))) |chosen| { + if (widgets.choice("state rate", &labels, @backingInt(link.state_rate), .{})) |chosen| { link.state_rate = @fromBackingInt(@intCast(chosen)); app.settings.save(app.io); } @@ -310,38 +310,6 @@ fn portField(app: *App) void { } } -/// The design's choice of a few, the settings' `.seg`: each option as wide as its label, the one -/// chosen lit. The option clicked, if one is. -fn choice(id: []const u8, options: []const []const u8, current: usize) ?usize { - gui.pushId(id); - defer gui.popId(); - gui.sans(.regular, fonts.size.ui); - defer gui.popFont(); - var width = px(3) * 2 + px(2) * @as(f32, @floatFromInt(options.len - 1)); - for (options) |option| width += gui.textSize(option).x + px(12) * 2; - const height = px(30); - const at = gui.cursor(); - const list = gui.drawList(); - gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.field, 5); - gui.outline(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.border, 5); - var chosen: ?usize = null; - var x = at.x + px(3); - for (options, 0..) |option, index| { - const item_width = gui.textSize(option).x + px(12) * 2; - gui.setCursor(.{ .x = x, .y = at.y + px(3) }); - gui.pushIdInt(index); - if (c.ImGui_InvisibleButton("option", .{ .x = item_width, .y = px(24) }, 0)) chosen = index; - gui.popId(); - const on = index == current; - if (on) gui.rect(list, .{ .x = x, .y = at.y + px(3) }, .{ .x = x + item_width, .y = at.y + px(27) }, theme.border, 3); - gui.drawText(list, .{ .x = x + px(12), .y = gui.textTop(at.y + height / 2) }, if (on) theme.white else theme.text_muted, option); - x += item_width + px(2); - } - gui.setCursor(at); - c.ImGui_Dummy(.{ .x = width, .y = height }); - return chosen; -} - /// The design's `.plan`: what's planned, in a small grey pill, its middle at the cursor. fn planTag(text: []const u8) void { gui.sans(.semibold, 11); diff --git a/src/shell.zig b/src/shell.zig index 16fc910..18f1a40 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -12,6 +12,7 @@ const commands = @import("commands.zig"); const Command = commands.Command; const keys = @import("keys.zig"); const panels = @import("panels.zig"); +const scene_view = @import("views/scene.zig"); const ui = @import("ui.zig"); const version = @import("version.zig"); const fonts = ui.fonts; @@ -538,7 +539,9 @@ fn statusBar(app: *App) void { x += gui.textSize(text).x + gap; } const cursor = document.editor.cursor(); - for ([_][]const u8{ document.file, gui.print("Ln {d}, Col {d}", .{ cursor.line + 1, cursor.index + 1 }), keyHints(app) }) |part| { + // The Scene view shows the map and the place under the pointer instead of the cursor. + const where = if (app.view == .scene) scene_view.status() else gui.print("Ln {d}, Col {d}", .{ cursor.line + 1, cursor.index + 1 }); + for ([_][]const u8{ document.file, where, keyHints(app) }) |part| { gui.drawText(list, .{ .x = x, .y = text_y }, theme.text_muted, part); x += gui.textSize(part).x + gap; } diff --git a/src/ui.zig b/src/ui.zig index e2672ae..5c939b0 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1,11 +1,12 @@ //! The editor's interface kit on Dear ImGui: the design's colours, fonts and icons, the widgets -//! the screens are made of, and the text editor. +//! the screens are made of, the text editor, and the map's plot. const std = @import("std"); pub const fonts = @import("ui/fonts.zig"); pub const gui = @import("ui/gui.zig"); pub const icons = @import("ui/icons.zig"); +pub const plot = @import("ui/plot.zig"); pub const text_editor = @import("ui/text_editor.zig"); pub const theme = @import("ui/theme.zig"); pub const widgets = @import("ui/widgets.zig"); diff --git a/src/ui/fonts.zig b/src/ui/fonts.zig index 7e1fd45..36c1943 100644 --- a/src/ui/fonts.zig +++ b/src/ui/fonts.zig @@ -127,11 +127,15 @@ pub fn iconFont() *c.ImFont { var icon_font: *c.ImFont = undefined; -/// A font's height above and below its baseline, in ems, from its `hhea` and `head` tables. +/// A font's height above and below its baseline, and its capitals' height, in ems, from its `hhea`, +/// `head` and `OS/2` tables. pub const Metrics = struct { /// The height above the baseline and below it, as a stylesheet lays out a line of text. ascent: f32, descent: f32, + /// The capitals' height above the baseline, which the figures share in the editor's fonts; 0 + /// where the font doesn't give it. + cap_height: f32, /// How much larger ImGui's size of the font is than its em. ImGui sizes a font by its ascent /// less its descent as the file gives them, a stylesheet by its em, so a font pushed at the /// design's size times this draws as large as in the design. @@ -143,6 +147,7 @@ pub const Metrics = struct { var units: u16 = 0; var ascent: i16 = 0; var descent: i16 = 0; + var cap_height: i16 = 0; for (0..tables) |index| { const record = 12 + index * 16; const offset = std.mem.readInt(u32, data[record + 8 ..][0..4], .big); @@ -151,6 +156,10 @@ pub const Metrics = struct { ascent = std.mem.readInt(i16, data[offset + 4 ..][0..2], .big); descent = std.mem.readInt(i16, data[offset + 6 ..][0..2], .big); } + // `sCapHeight` came in the table's second version. + if (std.mem.eql(u8, data[record..][0..4], "OS/2") and std.mem.readInt(u16, data[offset..][0..2], .big) >= 2) { + cap_height = std.mem.readInt(i16, data[offset + 88 ..][0..2], .big); + } } const em_units: f32 = @floatFromInt(units); return .{ @@ -158,6 +167,7 @@ pub const Metrics = struct { // Newtown gives its descent as a positive number; browsers take it as below the // baseline all the same. .descent = @as(f32, @floatFromInt(@abs(descent))) / em_units, + .cap_height = @as(f32, @floatFromInt(cap_height)) / em_units, .em = @as(f32, @floatFromInt(@as(i32, ascent) - descent)) / em_units, }; } @@ -186,6 +196,9 @@ test Metrics { try std.testing.expectEqual(13, Family.sans.metrics().baselineInLine(13, 13 * line_height)); try std.testing.expectApproxEqAbs(0.19, Family.brand.metrics().descent, 1e-6); try std.testing.expectEqual(16, Family.brand.metrics().baselineInLine(16, 16 * line_height)); + // Plex's capitals and figures are 0.698 of its em high, JetBrains Mono's 0.73. + try std.testing.expectApproxEqAbs(0.698, Family.sans.metrics().cap_height, 1e-6); + try std.testing.expectApproxEqAbs(0.73, Family.mono.metrics().cap_height, 1e-6); } test face { diff --git a/src/ui/gui.zig b/src/ui/gui.zig index 6c64614..d2847c9 100644 --- a/src/ui/gui.zig +++ b/src/ui/gui.zig @@ -80,6 +80,23 @@ pub fn textTopIn(middle: f32, line_pixels: ?f32) f32 { return @round(middle - line_height / 2 + face.family.metrics().baselineInLine(size, line_height)) - ascent(); } +/// Where a line of text in the current font goes for its capitals and figures to sit in the middle +/// of a box whose middle is at `middle`, such as a count in a badge, which has no small letters to +/// centre. The baseline goes on a whole pixel, since ImGui draws text from whole pixels. +pub fn capitalsTop(middle: f32) f32 { + return @round(middle + capitals() / 2) - ascent(); +} + +/// How high the current font's capitals and figures are; for a font that isn't one of the faces, +/// as high as Plex's would be at its size. +pub fn capitals() f32 { + const face = fonts.current() orelse { + const plex = fonts.Family.sans.metrics(); + return plex.cap_height / plex.em * c.ImGui_GetFontSize(); + }; + return face.family.metrics().cap_height * px(face.size); +} + /// How far below the top of a line of the current font its baseline is, as ImGui draws it. pub fn ascent() f32 { return c.or_imgui_font_ascent(); @@ -200,6 +217,11 @@ pub fn popVar(count: c_int) void { c.ImGui_PopStyleVarEx(count); } +/// Where the last item ends at its right. +pub fn lastItemRight() f32 { + return c.ImGui_GetItemRectMax().x; +} + pub fn cursor() Vec2 { return c.ImGui_GetCursorScreenPos(); } diff --git a/src/ui/icons.zig b/src/ui/icons.zig index efeab6f..8b1eae3 100644 --- a/src/ui/icons.zig +++ b/src/ui/icons.zig @@ -7,9 +7,13 @@ const std = @import("std"); pub const Icon = enum(u21) { archive = 0xe041, + arrow_down = 0xe042, arrow_down_to_dot = 0xe44d, arrow_left = 0xe048, + arrow_right = 0xe049, arrow_right_to_line = 0xe459, + arrow_up = 0xe04a, + arrow_up_right = 0xe04d, audio_waveform = 0xe55b, blocks = 0xe4fa, book = 0xe05e, @@ -26,6 +30,7 @@ pub const Icon = enum(u21) { chevron_up = 0xe070, circle = 0xe076, circle_alert = 0xe077, + circle_dashed = 0xe4b0, circle_dot = 0xe345, circle_help = 0xe082, clipboard_paste = 0xe3e8, @@ -73,6 +78,7 @@ pub const Icon = enum(u21) { pencil = 0xe1f9, play = 0xe13c, plus = 0xe13d, + radio = 0xe142, redo_2 = 0xe2a0, redo_dot = 0xe450, refresh_cw = 0xe145, @@ -80,10 +86,12 @@ pub const Icon = enum(u21) { route = 0xe53e, save = 0xe14d, save_all = 0xe40f, + scan = 0xe257, scissors = 0xe14e, search = 0xe151, settings = 0xe154, sliders_horizontal = 0xe29a, + spline = 0xe38b, square = 0xe167, step_forward = 0xe3ea, terminal = 0xe181, diff --git a/src/ui/plot.zig b/src/ui/plot.zig new file mode 100644 index 0000000..d0e8d34 --- /dev/null +++ b/src/ui/plot.zig @@ -0,0 +1,84 @@ +//! ImPlot through its C shim (`plot/plot_shim.h`): the plot the Scene view's map draws on, with +//! equal axes, which the pointer pans, and points it drags. The map zooms itself +//! (`views/scene.zig`). + +const std = @import("std"); +const c = @import("c"); + +const theme = @import("theme.zig"); + +pub const Vec2 = c.ImVec2; +pub const Rect = c.OrPlotRect; + +/// Makes ImPlot's context, once Dear ImGui's is made, on the map's ground. The map draws its own +/// grid (`views/scene/drawing.zig`). +pub fn create() void { + c.or_plot_create(); + c.or_plot_style(theme.map); +} + +pub fn destroy() void { + c.or_plot_destroy(); +} + +/// Begins a plot `size` pixels across and down. With `shown`, its axes show it from this frame +/// on. False where it isn't drawn; `end` ends it otherwise. +pub fn begin(id: [:0]const u8, size: Vec2, shown: ?Rect) bool { + return c.or_plot_begin(id, size.x, size.y, shown != null, shown orelse std.mem.zeroes(Rect)); +} + +pub fn end() void { + c.or_plot_end(); +} + +/// What the axes show. +pub fn limits() Rect { + return c.or_plot_limits(); +} + +/// The plot's area on the screen: its top left and bottom right corners. +pub fn area() [2]Vec2 { + var rect: [4]f32 = undefined; + c.or_plot_area(&rect); + return .{ .{ .x = rect[0], .y = rect[1] }, .{ .x = rect[2], .y = rect[3] } }; +} + +/// The place on the screen of `at` on the plot. +pub fn toPixels(at: [2]f64) Vec2 { + var pixels: [2]f32 = undefined; + c.or_plot_to_pixels(at[0], at[1], &pixels); + return .{ .x = pixels[0], .y = pixels[1] }; +} + +/// The place on the plot of `at` on the screen. +pub fn fromPixels(at: Vec2) [2]f64 { + var place: [2]f64 = undefined; + c.or_plot_from_pixels(at.x, at.y, &place); + return place; +} + +/// The draw list over the plot, clipped to its area until `popClip`. +pub fn drawList() *c.ImDrawList { + c.or_plot_push_clip(); + return @ptrCast(@alignCast(c.or_plot_draw_list().?)); +} + +pub fn popClip() void { + c.or_plot_pop_clip(); +} + +/// What the pointer did to a point (`dragPoint`). +pub const Drag = struct { + clicked: bool = false, + hovered: bool = false, + /// Whether the pointer holds it. + held: bool = false, +}; + +/// A point `radius` pixels around `at`, which the pointer can click and drag; a drag moves `at`. +/// The caller draws it. +pub fn dragPoint(id: usize, at: *[2]f64, radius: f32) Drag { + var drag: Drag = .{}; + _ = c.or_plot_drag_point(@intCast(id), &at[0], &at[1], radius, &drag.clicked, &drag.hovered, &drag.held); + return drag; +} diff --git a/src/ui/plot/plot_shim.cpp b/src/ui/plot/plot_shim.cpp new file mode 100644 index 0000000..d4d1854 --- /dev/null +++ b/src/ui/plot/plot_shim.cpp @@ -0,0 +1,77 @@ +// ImPlot through the editor's C interface (`plot_shim.h`). + +#include "plot_shim.h" + +#include "imgui.h" +#include "implot.h" + +extern "C" { + +void or_plot_create(void) { ImPlot::CreateContext(); } + +void or_plot_destroy(void) { ImPlot::DestroyContext(); } + +void or_plot_style(unsigned int background) { + ImPlotStyle &style = ImPlot::GetStyle(); + style.PlotPadding = ImVec2(0, 0); + style.PlotBorderSize = 0; + style.Colors[ImPlotCol_FrameBg] = ImVec4(0, 0, 0, 0); + style.Colors[ImPlotCol_PlotBg] = ImGui::ColorConvertU32ToFloat4(background); + // The editor zooms around the pointer itself, over the points too, and fits on a double click + // with its own fit: ImPlot's zoom does nothing, and its fit waits for a middle double click. + ImPlotInputMap &input = ImPlot::GetInputMap(); + input.ZoomRate = 0; + input.Fit = ImGuiMouseButton_Middle; +} + +bool or_plot_begin(const char *id, float width, float height, bool set, OrPlotRect limits) { + if (!ImPlot::BeginPlot(id, ImVec2(width, height), ImPlotFlags_CanvasOnly | ImPlotFlags_Equal)) return false; + const ImPlotAxisFlags axes = ImPlotAxisFlags_NoDecorations | ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | + ImPlotAxisFlags_NoSideSwitch; + ImPlot::SetupAxes(nullptr, nullptr, axes, axes); + if (set) ImPlot::SetupAxesLimits(limits.x_min, limits.x_max, limits.y_min, limits.y_max, ImPlotCond_Always); + // The axes settle now, so that the plot's places are known before anything is drawn. + ImPlot::SetupFinish(); + return true; +} + +void or_plot_end(void) { ImPlot::EndPlot(); } + +OrPlotRect or_plot_limits(void) { + const ImPlotRect limits = ImPlot::GetPlotLimits(); + return OrPlotRect{limits.X.Min, limits.X.Max, limits.Y.Min, limits.Y.Max}; +} + +void or_plot_area(float rect[4]) { + const ImVec2 at = ImPlot::GetPlotPos(); + const ImVec2 size = ImPlot::GetPlotSize(); + rect[0] = at.x; + rect[1] = at.y; + rect[2] = at.x + size.x; + rect[3] = at.y + size.y; +} + +void or_plot_to_pixels(double x, double y, float pixels[2]) { + const ImVec2 at = ImPlot::PlotToPixels(x, y); + pixels[0] = at.x; + pixels[1] = at.y; +} + +void or_plot_from_pixels(float x, float y, double place[2]) { + const ImPlotPoint at = ImPlot::PixelsToPlot(x, y); + place[0] = at.x; + place[1] = at.y; +} + +void *or_plot_draw_list(void) { return ImPlot::GetPlotDrawList(); } + +void or_plot_push_clip(void) { ImPlot::PushPlotClipRect(); } + +void or_plot_pop_clip(void) { ImPlot::PopPlotClipRect(); } + +bool or_plot_drag_point(int id, double *x, double *y, float radius, bool *clicked, bool *hovered, bool *held) { + // Drawn clear: the caller draws the marker. + return ImPlot::DragPoint(id, x, y, ImVec4(0, 0, 0, 0), radius, ImPlotDragToolFlags_NoFit, clicked, hovered, held); +} + +} // extern "C" diff --git a/src/ui/plot/plot_shim.h b/src/ui/plot/plot_shim.h new file mode 100644 index 0000000..c992f71 --- /dev/null +++ b/src/ui/plot/plot_shim.h @@ -0,0 +1,48 @@ +/* ImPlot, which the Scene view's map draws with, through a C interface for the editor's Zig code + * (`plot.zig`): a plot with equal axes, what its axes show, its places on the screen, and points + * to drag. Places on the plot are doubles; on the screen, ImGui's pixels. */ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* The part of the plot its axes show. */ +typedef struct OrPlotRect { + double x_min, x_max, y_min, y_max; +} OrPlotRect; + +void or_plot_create(void); +void or_plot_destroy(void); + +/* The plots' look, their background an ImGui packed colour, with no padding and no border; and their + * input, where the pointer pans them while the wheel and double clicks are left to the editor. */ +void or_plot_style(unsigned int background); + +/* Begins a plot `width` by `height` pixels, with equal axes and nothing else: no frame, grid, + * labels, ticks, menus or box zoom. With `set`, the axes show `limits` from this frame on. False + * where it isn't drawn; otherwise `or_plot_end` ends it. */ +bool or_plot_begin(const char *id, float width, float height, bool set, OrPlotRect limits); +void or_plot_end(void); + +/* What the axes show, the plot's area on the screen, and places between the two. */ +OrPlotRect or_plot_limits(void); +void or_plot_area(float rect[4]); +void or_plot_to_pixels(double x, double y, float pixels[2]); +void or_plot_from_pixels(float x, float y, double place[2]); + +/* The draw list over the plot, clipped to its area between `or_plot_push_clip` and + * `or_plot_pop_clip`. */ +void *or_plot_draw_list(void); +void or_plot_push_clip(void); +void or_plot_pop_clip(void); + +/* A point the pointer can drag, `radius` pixels around (`x`, `y`), which the caller draws: true + * while a drag moves it. */ +bool or_plot_drag_point(int id, double *x, double *y, float radius, bool *clicked, bool *hovered, bool *held); + +#ifdef __cplusplus +} +#endif diff --git a/src/ui/theme.zig b/src/ui/theme.zig index 9f41020..6a8f61d 100644 --- a/src/ui/theme.zig +++ b/src/ui/theme.zig @@ -22,6 +22,18 @@ pub fn fade(color: Color, opacity: f32) Color { return (color & 0x00ff_ffff) | a << 24; } +/// `color` mixed with white, `amount` of the way from 0 to 1: the lighter text of a flight group's +/// colour. +pub fn lighten(color: Color, amount: f32) Color { + var result: Color = color & 0xff00_0000; + for ([_]u5{ 0, 8, 16 }) |shift| { + const value: f32 = @floatFromInt((color >> shift) & 0xff); + const mixed: u32 = @intFromFloat(value + (255 - value) * std.math.clamp(amount, 0, 1) + 0.5); + result |= mixed << shift; + } + return result; +} + pub fn vec4(color: Color) c.ImVec4 { return .{ .x = @as(f32, @floatFromInt(color & 0xff)) / 255, @@ -46,6 +58,13 @@ pub const bar = rgb(0x0c0f15); pub const toolbar = rgb(0x0e1118); /// Code, the source views and the inspector's code. pub const code = rgb(0x0b0d12); +/// The map's ground and its grid: a line every few cells, and the cells' lines between them. +pub const map = rgb(0x0a0c11); +pub const map_grid = rgb(0x202839); +pub const map_grid_minor = rgb(0x131824); +/// A trigger's range's label on the map, and a curve, in the dim text's colour. +pub const range_text = rgb(0xf4b27a); +pub const curve = text_dim; /// The line under the source's tabs that says where the cursor is. pub const breadcrumbs = rgb(0x0d1017); /// Fields, search boxes, and switches' tracks. @@ -266,3 +285,10 @@ test rgb { try std.testing.expectEqual(@as(Color, 0xff2c8af0), rgb(0xf08a2c)); try std.testing.expectEqual(@as(Color, 0x242c8af0), fade(rgb(0xf08a2c), 0.14)); } + +test lighten { + // The player's blue, nearly half way to white: the design's light blue. + try std.testing.expectEqual(rgb(0xa8d1ff), lighten(rgb(0x5aa9ff), 0.47)); + try std.testing.expectEqual(rgb(0x5aa9ff), lighten(rgb(0x5aa9ff), 0)); + try std.testing.expectEqual(rgb(0xffffff), lighten(rgb(0x5aa9ff), 1)); +} diff --git a/src/ui/widgets.zig b/src/ui/widgets.zig index ba31dac..d8fdc50 100644 --- a/src/ui/widgets.zig +++ b/src/ui/widgets.zig @@ -430,6 +430,31 @@ pub const PanelTab = struct { } }; +/// A panel's own tab bar, as the source panel's: `tabs` side by side from its left on the bar's +/// colour, with its line under them. The tab clicked, if one is. +pub fn panelTabs(tabs: []const PanelTab) ?usize { + const at = gui.cursor(); + const width = gui.available().x; + const height = px(tab_bar_height); + const list = gui.drawList(); + gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.bar, 0); + gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.tab_divider, 0); + var clicked: ?usize = null; + var x = at.x; + for (tabs, 0..) |tab, index| { + const tab_width = tab.width(); + gui.setCursor(.{ .x = x, .y = at.y }); + gui.pushIdInt(index); + if (c.ImGui_InvisibleButton("tab", .{ .x = tab_width, .y = height - px(1) }, 0)) clicked = index; + gui.popId(); + tab.draw(list, .{ .x = x, .y = at.y }, .{ .x = x + tab_width, .y = at.y + height - px(1) }, gui.hovered()); + if (tab.dot) gui.tooltip("Changed since saved"); + x += tab_width; + } + gui.setCursor(.{ .x = at.x, .y = at.y + height }); + return clicked; +} + pub const ViewTab = struct { label: []const u8, icon: Icon, @@ -699,11 +724,13 @@ pub const Frame = struct { mark: RowMark = .none, chevron: bool = true, disabled: ?Disabled = null, + /// Its width; by default the space left on the line. + width: ?f32 = null, }; /// The design's `.frame`: a field that opens a list, as the inspector's. pub fn frame(id: []const u8, shown: Frame) bool { - const width = gui.available().x; + const width = shown.width orelse gui.available().x; const height = px(26); const at = gui.cursor(); const clicked = c.ImGui_InvisibleButton(gui.z(id), .{ .x = @max(width, 1), .y = height }, 0) and shown.disabled == null; @@ -763,7 +790,7 @@ pub fn pick(id: []const u8, shown: Frame, choices: []const Choice) ?usize { gui.pushId(id); defer gui.popId(); const at = gui.cursor(); - const width = gui.available().x; + const width = shown.width orelse gui.available().x; if (frame("frame", shown)) _ = c.ImGui_OpenPopup("choices", 0); c.ImGui_SetNextWindowPos(.{ .x = at.x, .y = at.y + px(26) + px(2) }, c.ImGuiCond_Always); c.ImGui_SetNextWindowSizeConstraints(.{ .x = width, .y = 0 }, .{ .x = @max(width, px(240)), .y = px(320) }, null, null); @@ -783,18 +810,18 @@ pub fn pick(id: []const u8, shown: Frame, choices: []const Choice) ?usize { } const term = std.mem.sliceTo(&Find.text, 0); var chosen: ?usize = null; - for (choices, 0..) |choice, index| { - if (finding and term.len > 0 and std.ascii.findIgnoreCase(choice.label, term) == null) continue; + for (choices, 0..) |item, index| { + if (finding and term.len > 0 and std.ascii.findIgnoreCase(item.label, term) == null) continue; gui.pushIdInt(index); defer gui.popId(); - if (choiceRow(choice)) chosen = index; + if (choiceRow(item)) chosen = index; } if (chosen != null) c.ImGui_CloseCurrentPopup(); return chosen; } /// A row of a list to choose from, as `pick`'s. True when clicked. -pub fn choiceRow(choice: Choice) bool { +pub fn choiceRow(item: Choice) bool { const width = gui.available().x; const height = px(26); const at = gui.cursor(); @@ -802,12 +829,12 @@ pub fn choiceRow(choice: Choice) bool { const list = gui.drawList(); if (gui.hovered()) gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.row_hover, 3); const middle = at.y + height / 2; - var x = drawMark(list, at.x + px(8), middle, choice.mark); - if (choice.mono) gui.mono(.regular, fonts.size.ui) else gui.sans(.regular, fonts.size.ui); - gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, if (choice.dim) theme.text_dim else theme.text, choice.label); - x += gui.textSize(choice.label).x + px(7); + var x = drawMark(list, at.x + px(8), middle, item.mark); + if (item.mono) gui.mono(.regular, fonts.size.ui) else gui.sans(.regular, fonts.size.ui); + gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, if (item.dim) theme.text_dim else theme.text, item.label); + x += gui.textSize(item.label).x + px(7); gui.popFont(); - if (choice.detail) |detail| { + if (item.detail) |detail| { gui.mono(.regular, fonts.size.ui); gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_dim, detail); gui.popFont(); @@ -920,6 +947,73 @@ pub fn popBareInput() void { gui.popColor(4); } +/// How `choice` draws: the box's height, the space each side of a label, the labels' size, and the +/// corners' radius. The defaults are the settings' `.seg`. +pub const ChoiceLook = struct { + height: f32 = 30, + padding: f32 = 12, + font: f32 = fonts.size.ui, + rounding: f32 = 5, +}; + +/// The design's choice of a few, as the settings' `.seg` and the map's views: each option as wide +/// as its label, the one chosen lit. The option clicked, if one is. +pub fn choice(id: []const u8, options: []const []const u8, current: usize, look: ChoiceLook) ?usize { + gui.pushId(id); + defer gui.popId(); + gui.sans(.regular, look.font); + defer gui.popFont(); + // The options sit 3 pixels inside the box's line, 2 pixels apart. + const inset = px(3); + var width = inset * 2 + px(2) * @as(f32, @floatFromInt(options.len - 1)); + for (options) |option| width += gui.textSize(option).x + px(look.padding) * 2; + const height = px(look.height); + const at = gui.cursor(); + const list = gui.drawList(); + gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.field, look.rounding); + gui.outline(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.border, look.rounding); + var chosen: ?usize = null; + var x = at.x + inset; + for (options, 0..) |option, index| { + const item_width = gui.textSize(option).x + px(look.padding) * 2; + gui.setCursor(.{ .x = x, .y = at.y + inset }); + gui.pushIdInt(index); + if (c.ImGui_InvisibleButton("option", .{ .x = item_width, .y = height - inset * 2 }, 0)) chosen = index; + gui.popId(); + const on = index == current; + if (on) gui.rect(list, .{ .x = x, .y = at.y + inset }, .{ .x = x + item_width, .y = at.y + height - inset }, theme.border, 3); + gui.drawText(list, .{ .x = x + px(look.padding), .y = gui.textTop(at.y + height / 2) }, if (on) theme.white else theme.text_muted, option); + x += item_width + px(2); + } + gui.setCursor(at); + c.ImGui_Dummy(.{ .x = width, .y = height }); + return chosen; +} + +/// The design's `.chip` that turns something on and off, such as the map's Ranges: an icon and a +/// label, lit while on. True when clicked. +pub fn toggleChip(id: []const u8, label: []const u8, icon: Icon, on: bool, disabled: ?Disabled) bool { + gui.sans(.regular, fonts.size.small); + defer gui.popFont(); + const icon_size = fonts.size.icon_small; + const width = px(9) + px(icon_size) + px(6) + gui.textSize(label).x + px(9); + const height = px(24); + const at = gui.cursor(); + const clicked = c.ImGui_InvisibleButton(gui.z(id), .{ .x = width, .y = height }, 0) and disabled == null; + if (disabled == null) gui.handCursor(); + const list = gui.drawList(); + const max = gui.vec2(at.x + width, at.y + height); + const lit = on and disabled == null; + gui.rect(list, at, max, if (lit) theme.pill else theme.field, 4); + gui.outline(list, at, max, if (lit) theme.border_strong else theme.border, 4); + const color = if (disabled != null) theme.text_off else if (lit) theme.text_strong else theme.text_muted; + const middle = at.y + height / 2; + drawIcon(list, .{ .x = at.x + px(9), .y = middle }, icon, icon_size, color); + gui.drawText(list, .{ .x = at.x + px(9) + px(icon_size) + px(6), .y = gui.textTop(middle) }, color, label); + if (disabled) |off| gui.tooltip(off.reason); + return clicked; +} + /// The design's segmented switch, such as a trigger's Repeats: Once, Always, Counted. pub fn segmented(id: []const u8, options: []const []const u8, current: usize, disabled: ?Disabled) ?usize { gui.pushId(id); diff --git a/src/views/scene.zig b/src/views/scene.zig new file mode 100644 index 0000000..a3e6925 --- /dev/null +++ b/src/views/scene.zig @@ -0,0 +1,857 @@ +//! The Scene view: the mission's map, from above, from the side or from the front, with its ships +//! and markers where they start in their flight groups' colours, the ranges its triggers watch, its +//! curves, and the routes its script sends ships along (`game.Scene`). Things that would overlap on +//! the screen show as one mark, a cluster, which opens with a click, so that a mission spread over +//! areas far apart shows whole. The pointer pans and zooms the map. A ship or a marker dragged +//! moves where it starts, which rewrites its place in the source as one step of Undo, and what the +//! map selects is what the outline and the inspector show. `scene/drawing.zig` draws the map's +//! parts. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const c = @import("c"); + +const app_mod = @import("../app.zig"); +const App = app_mod.App; +const commands = @import("../commands.zig"); +const drawing = @import("scene/drawing.zig"); +const Document = @import("../mission/document.zig").Document; +const game = @import("../game.zig"); +const source_edit = @import("../mission/edit.zig"); +const panels = @import("../panels.zig"); +const ui = @import("../ui.zig"); +const fonts = ui.fonts; +const gui = ui.gui; +const plot = ui.plot; +const theme = ui.theme; +const widgets = ui.widgets; +const Color = theme.Color; +const Icon = ui.icons.Icon; +const Vec2 = gui.Vec2; +const px = gui.px; + +/// How the map looks at the mission. +pub const Projection = enum { + top, + side, + front, + + /// The map's place of `place`, in the game's coordinates, where Y points down: its horizontal + /// coordinate, then its vertical one, which points up. + pub fn project(projection: Projection, place: [3]f64) [2]f64 { + return switch (projection) { + .top => .{ place[0], place[2] }, + .side => .{ place[2], -place[1] }, + .front => .{ place[0], -place[1] }, + }; + } + + /// The game's place of `at` on the map, with the coordinate the map doesn't show kept from + /// `kept`. + pub fn unproject(projection: Projection, at: [2]f64, kept: [3]f64) [3]f64 { + return switch (projection) { + .top => .{ at[0], kept[1], at[1] }, + .side => .{ kept[0], -at[1], at[0] }, + .front => .{ at[0], -at[1], kept[2] }, + }; + } + + /// The game's coordinates the map shows, across and up, by their index in a place. + fn axes(projection: Projection) [2]usize { + return switch (projection) { + .top => .{ 0, 2 }, + .side => .{ 2, 1 }, + .front => .{ 0, 1 }, + }; + } + + /// The note in the map's corner, which says which way its axes point. + fn note(projection: Projection) []const u8 { + return switch (projection) { + .top => "X right · Z up", + .side => "Z right · Y down", + .front => "X right · Y down", + }; + } + + fn label(projection: Projection) []const u8 { + return switch (projection) { + .top => "Top", + .side => "Side", + .front => "Front", + }; + } +}; + +/// A record the map places, by its section and its place among the section's. +const Ref = struct { + section: game.Section, + index: usize, + + fn of(thing: game.Thing) Ref { + return .{ .section = thing.section, .index = thing.index }; + } + + fn eql(ref: Ref, other: Ref) bool { + return ref.section == other.section and ref.index == other.index; + } +}; + +/// What the map keeps from one frame to the next. +const Map = struct { + var projection: Projection = .top; + var ranges: bool = true; + var routes: bool = true; + var curves: bool = false; + /// Whether the axes fit the mission on the next frame: as the map first shows, as Fit asks, + /// and as the projection changes. + var fit: bool = true; + /// The record the map keeps in its middle. + var follow: ?Ref = null; + /// The record being dragged, and where it is now. + var dragging: ?struct { ref: Ref, place: [3]f64 } = null; + /// What the axes showed last, whose size Follow keeps, and where the map was on the screen. + var shown: ?plot.Rect = null; + var area: ?[2]Vec2 = null; + /// The mission the map last fitted, by its path: one opened anew shows whole. + var fitted_for: u64 = 0; + /// The places a click on a cluster opens on the next frame, and when the last one did, so that + /// the second click of a double click doesn't fit the mission again. + var zoom_to: ?[2][2]f64 = null; + var zoomed_at: f64 = -1; + /// The first thing of the cluster whose list is open. + var listed: ?Ref = null; + /// The place on the map under the pointer, for the status bar. + var pointer: ?[2]f64 = null; +}; + +/// The pixels around a marker's middle that the pointer takes it by. +const grab_radius = 11; +/// The marks' sizes, in design pixels: a ship's or a marker's, the player's, a camera point's and a +/// planet's (a cluster's is `drawing.cluster_size`). +const marker_size = 18; +const player_size = 20; +const camera_size = 8; +const scenery_size = 14; +/// How close on the screen, in pixels, things draw as one cluster. +const cluster_radius = 14; +/// The least the map shows across, in the game's units, as it fits the mission and as it opens a +/// cluster. +const least_fit = 4000; +const least_opened = 400; +/// How many of a cluster's things its tooltip names, and the least width, in design pixels, of the +/// list of things too close together to show apart. +const most_listed = 12; +const list_width = 200; +/// How much lighter than its group's colour the player's name is. +const player_tint = 0.47; +/// How much a notch of the wheel zooms, and how far in and out the map goes, in the game's +/// units across. +const zoom_step = 1.25; +const least_across = 200; +const most_across = 1e9; +/// The places a drag with Shift held snaps to, in the game's units. +const snap = 100; + +pub fn draw(app: *App) void { + const document = app.document orelse return; + if (widgets.panelTabs(&.{ + .{ .label = "Map", .icon = .map, .icon_color = theme.accent, .on = true }, + .{ .label = document.file, .icon = .file_code, .dot = document.dirty() }, + })) |index| if (index == 1) app.run(.view_1); + const scene = document.scene() catch return; + toolbar(document, scene); + map(app, document, scene); +} + +/// The status bar's part while the map shows: the projection, and the place under the pointer. +pub fn status() []const u8 { + const projection = Map.projection; + const at = Map.pointer orelse return gui.print("Map · {s}", .{lower(projection.label())}); + const names = axisNames(projection); + return gui.print("Map · {s} · {s} {s} · {s} {s}", .{ lower(projection.label()), names[0], drawing.distance(at[0]), names[1], drawing.distance(projectedValue(projection, at, 1)) }); +} + +/// The names of the game's coordinates across and up the map: `x` and `z` from above. +fn axisNames(projection: Projection) [2][]const u8 { + const names = [_][]const u8{ "x", "y", "z" }; + const axes = projection.axes(); + return .{ names[axes[0]], names[axes[1]] }; +} + +/// The game's coordinate `which` of a place on the map, 0 across and 1 up: up is Y's opposite on +/// the side and the front. +fn projectedValue(projection: Projection, at: [2]f64, which: usize) f64 { + if (which == 1 and projection != .top) return -at[1]; + return at[which]; +} + +fn lower(text: []const u8) []const u8 { + return std.ascii.lowerString(gui.frameAllocator().alloc(u8, text.len) catch return text, text); +} + +/// The map's toolbar: the projection, what it shows, the record it follows, and Fit. +fn toolbar(document: *Document, scene: game.Scene) void { + const at = gui.cursor(); + const width = gui.available().x; + const height = px(36); + const list = gui.drawList(); + gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.toolbar, 0); + gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.divider, 0); + const middle = at.y + height / 2; + var x = at.x + px(10); + + gui.setCursor(.{ .x = x, .y = middle - px(13) }); + const projections = [_][]const u8{ Projection.top.label(), Projection.side.label(), Projection.front.label() }; + if (widgets.choice("projection", &projections, @backingInt(Map.projection), .{ .height = 26, .padding = 9, .font = fonts.size.small, .rounding = 4 })) |chosen| { + if (chosen != @backingInt(Map.projection)) { + Map.projection = @fromBackingInt(@as(u2, @intCast(chosen))); + Map.fit = true; + } + } + x = gui.lastItemRight() + px(8); + gui.rect(list, .{ .x = x, .y = middle - px(9) }, .{ .x = x + px(1), .y = middle + px(9) }, theme.border, 0); + x += px(9); + const live_planned: widgets.Disabled = .{ .reason = gui.print("Planned · {f}", .{commands.plans.live_scene}) }; + const chips = [_]struct { []const u8, []const u8, Icon, ?*bool }{ + .{ "live", "Live", .radio, null }, + .{ "ranges", "Ranges", .circle_dashed, &Map.ranges }, + .{ "routes", "Routes", .route, &Map.routes }, + .{ "orders", "Orders", .arrow_up_right, null }, + .{ "curves", "Curves", .spline, &Map.curves }, + }; + for (chips) |chip| { + gui.setCursor(.{ .x = x, .y = middle - px(12) }); + if (widgets.toggleChip(chip[0], chip[1], chip[2], if (chip[3]) |on| on.* else false, if (chip[3] == null) live_planned else null)) { + if (chip[3]) |on| on.* = !on.*; + } + x = gui.lastItemRight() + px(6); + } + + // At the end: Follow, and Fit. + const fit_width = px(30); + const follow_width = px(120); + var right = at.x + width - px(10) - fit_width; + gui.setCursor(.{ .x = right, .y = middle - px(13) }); + if (widgets.toolButton("fit", .{ .icon = .scan, .icon_only = true, .height = 26, .tooltip = "Zoom to fit the mission" })) Map.fit = true; + right -= px(6) + follow_width; + gui.setCursor(.{ .x = right, .y = middle - px(13) }); + followPick(document, scene, follow_width); + gui.sans(.regular, fonts.size.small); + const follow_label = "Follow"; + gui.drawText(list, .{ .x = right - px(6) - gui.textSize(follow_label).x, .y = gui.textTop(middle) }, theme.text_dim, follow_label); + gui.popFont(); + gui.setCursor(.{ .x = at.x, .y = at.y + height }); +} + +/// Follow's list: none, or a ship, which the map then keeps in its middle. +fn followPick(document: *Document, scene: game.Scene, width: f32) void { + const known = document.outline(); + const allocator = gui.frameAllocator(); + var choices: std.ArrayList(widgets.Choice) = .empty; + var refs: std.ArrayList(?Ref) = .empty; + choices.append(allocator, .{ .label = "none", .dim = true }) catch return; + refs.append(allocator, null) catch return; + var current: ?*const game.Record = null; + for (scene.things) |thing| { + if (thing.section != .ships) continue; + const record = known.nth(thing.section, thing.index) orelse continue; + choices.append(allocator, .{ .label = record.title, .mark = .{ .icon = .{ .icon = .navigation_2, .color = panels.groupColor(record.*) } } }) catch return; + refs.append(allocator, Ref.of(thing)) catch return; + if (Map.follow) |followed| if (followed.eql(Ref.of(thing))) { + current = record; + }; + } + const frame: widgets.Frame = if (current) |record| + .{ .label = record.title, .mark = .{ .icon = .{ .icon = .navigation_2, .color = panels.groupColor(record.*) } }, .width = width } + else + .{ .label = "none", .dim_label = true, .width = width }; + if (widgets.pick("follow", frame, choices.items)) |index| Map.follow = refs.items[index]; +} + +/// The map: the grid, the ranges and the curves, then the ships and markers, gathered into one +/// mark where they would overlap, each named where its name has room, and over them the legend, +/// the scale and the axes' note. The pointer pans the map and zooms it with the wheel, selects and +/// drags a thing, and opens a cluster with a click; a double click fits the mission. +fn map(app: *App, document: *Document, scene: game.Scene) void { + const size = gui.available(); + if (size.x < 1 or size.y < 1) return; + const known = document.outline(); + const projection = Map.projection; + const opened = std.hash.Wyhash.hash(0, document.path); + if (Map.fitted_for != opened) { + Map.fitted_for = opened; + Map.fit = true; + } + var wanted: ?plot.Rect = if (Map.fit) fitted(scene, projection, size) else if (Map.zoom_to) |bounds| framed(bounds, size, least_opened) else wheeled(); + Map.fit = false; + Map.zoom_to = null; + if (Map.follow) |followed| if (thingOf(scene, followed)) |thing| if (wanted orelse Map.shown) |around| { + wanted = centredOn(around, projection.project(placeOf(thing))); + }; + if (!plot.begin("##map", size, wanted)) return; + defer plot.end(); + Map.shown = plot.limits(); + Map.area = plot.area(); + const list = plot.drawList(); + defer plot.popClip(); + const corners = Map.area.?; + const shown = Map.shown.?; + const scale = (corners[1].x - corners[0].x) / @as(f32, @floatCast(shown.x_max - shown.x_min)); + const cell = drawing.cellOf(scale); + drawing.grid(list, shown, cell); + const rings: []const Ring = if (Map.ranges) drawRanges(list, scene, projection, scale, corners) else &.{}; + if (Map.curves) for (scene.curves) |path| drawCurve(list, projection, path); + + const inspected = app.inspected(); + const selected: ?Ref = if (inspected) |record| refOf(scene, record.*) else null; + const gathered = gather(scene, projection, selected) catch return; + const lines: []const [2]Vec2 = if (Map.routes) drawRoutes(list, scene, gathered, projection, if (inspected) |record| record.* else null, corners) else &.{}; + const pointer = pointerOnMap(); + var hovered: ?usize = null; + for (gathered.marks, 0..) |*mark, which| { + const thing = scene.things[mark.first]; + const record = known.nth(thing.section, thing.index) orelse continue; + const color = panels.groupColor(record.*); + if (mark.count > 1) { + drawing.cluster(list, mark.middle, mark.count, color); + if (pointer) |at| if (drawing.isInside(at, mark.box())) { + hovered = which; + }; + continue; + } + const ref = Ref.of(thing); + const dragged = if (Map.dragging) |drag| drag.ref.eql(ref) else false; + var at = projection.project(placeOf(thing)); + const drag = plot.dragPoint(mark.first, &at, px(grab_radius)); + if (drag.hovered or drag.held) hovered = which; + if (drag.clicked) app.select(record.*); + if (drag.held) { + if (c.ImGui_GetIO().*.KeyShift) at = .{ @round(at[0] / snap) * snap, @round(at[1] / snap) * snap }; + Map.dragging = .{ .ref = ref, .place = projection.unproject(at, thing.place) }; + } else if (dragged) { + // Let go: the place goes into the source, where it moved. + const moved = Map.dragging.?.place; + Map.dragging = null; + if (!std.meta.eql(moved, thing.place)) moveTo(document, scene, record.*, moved); + } + mark.middle = plot.toPixels(at); + if (drag.hovered or drag.held) gui.dot(list, mark.middle, mark.size / 2 + 6, theme.fade(theme.text, 0.08)); + drawing.thing(list, mark.middle, thing.kind, if (thing.heading) |heading| projection.project(heading) else null, mark.size, color); + } + + var labels: drawing.Labels = .{ .corners = corners, .lines = lines }; + for (gathered.marks) |mark| labels.take(mark.box()); + const named = nameMarks(list, &labels, scene, known, gathered.marks); + for (rings) |ring| nameRing(list, &labels, ring); + for (gathered.marks) |mark| { + const thing = scene.things[mark.first]; + if (mark.count > 1 or selected == null or !selected.?.eql(Ref.of(thing))) continue; + const place = placeOf(thing); + const axes = projection.axes(); + drawing.brackets(list, mark.middle, px(mark.size) / 2 + px(5)); + drawing.placeNote(list, mark.middle, axisNames(projection), .{ place[axes[0]], place[axes[1]] }); + } + if (hovered) |which| { + const mark = gathered.marks[which]; + if (mark.count > 1) { + c.ImGui_SetMouseCursor(c.ImGuiMouseCursor_Hand); + gui.tooltipNow(memberNames(scene, known, gathered, which, size)); + if (isClick(mark.middle, px(drawing.cluster_size) / 2)) openMark(scene, mark, size); + } else if (!named[which]) { + if (known.nth(scene.things[mark.first].section, scene.things[mark.first].index)) |record| gui.tooltipNow(record.title); + } + } + listMembers(app, scene, known, gathered); + // A click on an empty part of the map clears the selection. + if (hovered == null) if (pointer) |at| if (isClick(at, px(4))) { + app.selection = null; + }; + // A double click away from the things fits the mission again. + const settled = c.ImGui_GetTime() - Map.zoomed_at > c.ImGui_GetIO().*.MouseDoubleClickTime; + if (pointer != null and hovered == null and settled and c.ImGui_IsMouseDoubleClicked(0)) Map.fit = true; + + drawing.legend(list, corners[0], Map.routes and scene.routes.len > 0); + drawing.scaleBar(list, .{ .x = corners[0].x + px(12), .y = corners[1].y - px(12) }, cell, scale); + gui.sans(.regular, 11); + const note = projection.note(); + gui.drawText(list, .{ .x = corners[1].x - px(12) - gui.textSize(note).x, .y = corners[1].y - px(12) - c.ImGui_GetFontSize() }, theme.text_dim, note); + gui.popFont(); + if (scene.player) |player| { + const thing = scene.things[player]; + if (known.nth(thing.section, thing.index)) |record| drawStartOffMap(list, corners, shown, projection, thing, record.*); + } + Map.pointer = if (pointer) |at| plot.fromPixels(at) else null; +} + +/// Where the pointer is, while it's over the map and not over something in front of it. +fn pointerOnMap() ?Vec2 { + if (!c.ImGui_IsWindowHovered(0)) return null; + const mouse = c.ImGui_GetMousePos(); + return if (drawing.isInside(mouse, Map.area orelse return null)) mouse else null; +} + +/// Whether the left button has just been let go after a press within `radius` of `middle`, with +/// no drag between. +fn isClick(middle: Vec2, radius: f32) bool { + if (!c.ImGui_IsMouseReleased(0)) return false; + const io = c.ImGui_GetIO(); + const delta = c.ImGui_GetMouseDragDelta(0, -1); + return delta.x == 0 and delta.y == 0 and drawing.distanceTo(io.*.MouseClickedPos[0], middle) <= radius; +} + +/// Whether zooming in to a cluster's things, on a map `size` big, shows them apart. +fn opensApart(mark: Mark, size: Vec2) bool { + const opened = framed(.{ mark.low, mark.high }, size, least_opened); + const scale = size.x / (opened.x_max - opened.x_min); + return @max(mark.high[0] - mark.low[0], mark.high[1] - mark.low[1]) * scale >= px(cluster_radius); +} + +/// Opens a cluster: zooms in to its things where that shows them apart, or lists them where they +/// lie too close together. +fn openMark(scene: game.Scene, mark: Mark, size: Vec2) void { + if (opensApart(mark, size)) { + Map.zoom_to = .{ mark.low, mark.high }; + Map.zoomed_at = c.ImGui_GetTime(); + } else { + Map.listed = Ref.of(scene.things[mark.first]); + _ = c.ImGui_OpenPopup("members", 0); + } +} + +/// The list of a cluster's things that lie too close together to show apart. A click on one +/// selects it. +fn listMembers(app: *App, scene: game.Scene, known: game.Outline, gathered: Gathered) void { + if (!c.ImGui_BeginPopup("members", c.ImGuiWindowFlags_NoMove)) return; + defer c.ImGui_EndPopup(); + const listed = Map.listed orelse return c.ImGui_CloseCurrentPopup(); + const which = for (gathered.marks, 0..) |mark, index| { + if (Ref.of(scene.things[mark.first]).eql(listed)) break index; + } else return c.ImGui_CloseCurrentPopup(); + c.ImGui_Dummy(.{ .x = px(list_width), .y = 0 }); + for (scene.things, gathered.owners, 0..) |thing, owner, index| { + if (owner != which) continue; + const record = known.nth(thing.section, thing.index) orelse continue; + gui.pushIdInt(index); + defer gui.popId(); + const mark: widgets.RowMark = .{ .icon = .{ .icon = kindIcon(thing.kind), .color = panels.groupColor(record.*) } }; + if (widgets.choiceRow(.{ .label = record.title, .mark = mark })) { + app.select(record.*); + c.ImGui_CloseCurrentPopup(); + } + } +} + +fn kindIcon(kind: game.ThingKind) Icon { + return switch (kind) { + .ship => .navigation_2, + .marker => .diamond, + .camera => .circle_dot, + .scenery => .circle, + }; +} + +/// The names of a cluster's things, for its tooltip: the first `most_listed`, how many +/// more, and what a click does on a map `size` big. +fn memberNames(scene: game.Scene, known: game.Outline, gathered: Gathered, which: usize, size: Vec2) []const u8 { + var text: std.Io.Writer.Allocating = .init(gui.frameAllocator()); + const writer = &text.writer; + var count: usize = 0; + for (scene.things, gathered.owners) |thing, owner| { + if (owner != which) continue; + count += 1; + if (count > most_listed) continue; + const record = known.nth(thing.section, thing.index) orelse continue; + writer.print("{s}{s}", .{ if (count > 1) "\n" else "", record.title }) catch return ""; + } + if (count > most_listed) writer.print("\n{d} more", .{count - most_listed}) catch return ""; + writer.writeAll(if (opensApart(gathered.marks[which], size)) "\nClick to zoom in" else "\nClick to choose one") catch return ""; + return text.written(); +} + +/// What the map draws for a thing, or for several that would overlap on the screen. +const Mark = struct { + /// The thing it's for, the most important where it's for several, by its place in the scene. + first: usize, + count: usize, + middle: Vec2, + /// The map's places its things span, which a click on a cluster opens. + low: [2]f64, + high: [2]f64, + /// Its marker's height and width, in design pixels: a cluster is wider for a count of + /// three figures. + size: f32, + width: f32, + + /// Where its marker covers the map. + fn box(mark: Mark) [2]Vec2 { + const half: Vec2 = .{ .x = px(mark.width) / 2, .y = px(mark.size) / 2 }; + return .{ .{ .x = mark.middle.x - half.x, .y = mark.middle.y - half.y }, .{ .x = mark.middle.x + half.x, .y = mark.middle.y + half.y } }; + } +}; + +/// The map's marks, and the mark each thing of the scene is in, by its place among the marks; a +/// camera point, while the curves are hidden, is in none. +const Gathered = struct { + marks: []Mark, + owners: []const usize, +}; + +/// The things the map shows as marks, the most important first: the selected thing, the player, +/// the ships, the markers, the camera's points where the curves show, and the scenery. A thing +/// within `cluster_radius` of a mark on the screen joins it, but for the selected thing and the +/// one being dragged, which stay apart. +fn gather(scene: game.Scene, projection: Projection, selected: ?Ref) Allocator.Error!Gathered { + const allocator = gui.frameAllocator(); + var order: std.ArrayList(usize) = .empty; + for (scene.things, 0..) |thing, index| { + if (thing.kind == .camera and !Map.curves) continue; + try order.append(allocator, index); + } + const Order = struct { + scene: game.Scene, + selected: ?Ref, + fn lessThan(context: @This(), a: usize, b: usize) bool { + const first = importance(context.scene, a, context.selected); + const second = importance(context.scene, b, context.selected); + return if (first != second) first < second else a < b; + } + }; + std.mem.sort(usize, order.items, Order{ .scene = scene, .selected = selected }, Order.lessThan); + const owners = try allocator.alloc(usize, scene.things.len); + @memset(owners, std.math.maxInt(usize)); + var marks: std.ArrayList(Mark) = .empty; + things: for (order.items) |index| { + const thing = scene.things[index]; + const at = projection.project(placeOf(thing)); + const middle = plot.toPixels(at); + if (!isApart(Ref.of(thing), selected)) for (marks.items, 0..) |*mark, which| { + if (isApart(Ref.of(scene.things[mark.first]), selected)) continue; + if (drawing.distanceTo(mark.middle, middle) > px(cluster_radius)) continue; + mark.count += 1; + mark.size = drawing.cluster_size; + mark.width = drawing.clusterWidth(mark.count); + const point: @Vector(2, f64) = at; + const low: @Vector(2, f64) = mark.low; + const high: @Vector(2, f64) = mark.high; + mark.low = @min(low, point); + mark.high = @max(high, point); + owners[index] = which; + continue :things; + }; + owners[index] = marks.items.len; + const size = sizeOf(scene, index); + try marks.append(allocator, .{ .first = index, .count = 1, .middle = middle, .low = at, .high = at, .size = size, .width = size }); + } + return .{ .marks = marks.items, .owners = owners }; +} + +/// How important the thing at `index` in the scene is to the map: 0 is the most important. +fn importance(scene: game.Scene, index: usize, selected: ?Ref) u8 { + const thing = scene.things[index]; + if (isApart(Ref.of(thing), selected)) return 0; + if (scene.player == index) return 1; + return switch (thing.kind) { + .ship => 2, + .marker => 3, + .camera => 4, + .scenery => 5, + }; +} + +/// Whether a thing stays apart from the others: it's selected, or being dragged. +fn isApart(ref: Ref, selected: ?Ref) bool { + if (selected) |chosen| if (chosen.eql(ref)) return true; + if (Map.dragging) |drag| if (drag.ref.eql(ref)) return true; + return false; +} + +fn sizeOf(scene: game.Scene, index: usize) f32 { + if (scene.player == index) return player_size; + return switch (scene.things[index].kind) { + .ship, .marker => marker_size, + .camera => camera_size, + .scenery => scenery_size, + }; +} + +/// The scene's thing for `record`, where the map places it. +fn refOf(scene: game.Scene, record: game.Record) ?Ref { + const ref: Ref = .{ .section = record.section, .index = record.index }; + return if (thingOf(scene, ref) != null) ref else null; +} + +/// The axes the wheel asks for, zoomed by `zoom_step` a notch around the place under the pointer, +/// where the pointer is over the map; null otherwise. +fn wheeled() ?plot.Rect { + const wheel = c.ImGui_GetIO().*.MouseWheel; + if (wheel == 0) return null; + const mouse = pointerOnMap() orelse return null; + const area = Map.area.?; + const last = Map.shown orelse return null; + const across: f64 = (mouse.x - area[0].x) / (area[1].x - area[0].x); + const down: f64 = (mouse.y - area[0].y) / (area[1].y - area[0].y); + const at: [2]f64 = .{ last.x_min + across * (last.x_max - last.x_min), last.y_max - down * (last.y_max - last.y_min) }; + const width = last.x_max - last.x_min; + const factor = std.math.clamp(std.math.pow(f64, zoom_step, -wheel), least_across / width, most_across / width); + return .{ + .x_min = at[0] + (last.x_min - at[0]) * factor, + .x_max = at[0] + (last.x_max - at[0]) * factor, + .y_min = at[1] + (last.y_min - at[1]) * factor, + .y_max = at[1] + (last.y_max - at[1]) * factor, + }; +} + +/// Puts where `record` starts at `place`, in its table's place field, as one step of Undo, once +/// the frame is drawn. +fn moveTo(document: *Document, scene: game.Scene, record: game.Record, place: [3]f64) void { + const value = source_edit.vector(gui.frameAllocator(), &place) catch return; + document.queue(.{ .edit = .{ .section = record.section, .index = record.index, .edit = .{ .field = .{ .name = scene.place_field, .value = value } } } }); +} + +/// The scene's thing for a record. +fn thingOf(scene: game.Scene, ref: Ref) ?game.Thing { + return scene.things[indexOf(scene, ref) orelse return null]; +} + +/// Where the scene lists a record's thing. +fn indexOf(scene: game.Scene, ref: Ref) ?usize { + for (scene.things, 0..) |thing, index| if (Ref.of(thing).eql(ref)) return index; + return null; +} + +/// Where a thing is now: where a drag has it, or where it starts. +fn placeOf(thing: game.Thing) [3]f64 { + if (Map.dragging) |drag| if (drag.ref.eql(Ref.of(thing))) return drag.place; + return thing.place; +} + +/// The axes that show the mission: its ships and markers, and the camera's points where the +/// curves show, but not its scenery, which lies far off; every thing where that's all it has. +fn fitted(scene: game.Scene, projection: Projection, size: Vec2) plot.Rect { + var low: @Vector(2, f64) = @splat(std.math.inf(f64)); + var high: @Vector(2, f64) = @splat(-std.math.inf(f64)); + for ([_]bool{ false, true }) |everything| { + for (scene.things) |thing| { + if (!everything and (thing.kind == .scenery or (thing.kind == .camera and !Map.curves))) continue; + const at: @Vector(2, f64) = projection.project(thing.place); + low = @min(low, at); + high = @max(high, at); + } + if (low[0] <= high[0]) break; + } + if (low[0] > high[0]) return framed(.{ @splat(0), @splat(0) }, size, least_fit); + return framed(.{ low, high }, size, least_fit); +} + +/// The axes that show `bounds`, with a margin of a tenth each side and at least `least` across and +/// up, at the map's aspect. +fn framed(bounds: [2][2]f64, size: Vec2, least: f64) plot.Rect { + const across = @max(bounds[1][0] - bounds[0][0], least) * 1.2; + const up = @max(bounds[1][1] - bounds[0][1], least) * 1.2; + const middle: [2]f64 = .{ (bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2 }; + // Equal axes: the side that's too short for the map's shape grows. + const aspect: f64 = size.x / size.y; + const width = @max(across, up * aspect); + const height = width / aspect; + return .{ .x_min = middle[0] - width / 2, .x_max = middle[0] + width / 2, .y_min = middle[1] - height / 2, .y_max = middle[1] + height / 2 }; +} + +/// `last` moved to have `middle` in its middle. +fn centredOn(last: plot.Rect, middle: [2]f64) plot.Rect { + const half_across = (last.x_max - last.x_min) / 2; + const half_up = (last.y_max - last.y_min) / 2; + return .{ .x_min = middle[0] - half_across, .x_max = middle[0] + half_across, .y_min = middle[1] - half_up, .y_max = middle[1] + half_up }; +} + +/// Each mark's name, the most important first: right of its marker, else left, below or above, +/// wherever it covers no mark and no name placed before it. Returns which marks have their name +/// shown; the others show it as the pointer comes over them. +fn nameMarks(list: *c.ImDrawList, labels: *drawing.Labels, scene: game.Scene, known: game.Outline, marks: []const Mark) []const bool { + const named = gui.frameAllocator().alloc(bool, marks.len) catch return &.{}; + @memset(named, false); + gui.sans(.regular, fonts.size.smaller); + defer gui.popFont(); + const height = c.ImGui_GetFontSize(); + for (marks, named) |mark, *shown| { + const thing = scene.things[mark.first]; + const record = known.nth(thing.section, thing.index) orelse continue; + const text = if (mark.count > 1) gui.print("{s} +{d}", .{ record.title, mark.count - 1 }) else record.title; + const color = if (scene.player == mark.first) + theme.lighten(panels.groupColor(record.*), player_tint) + else switch (thing.kind) { + .ship, .marker => theme.text, + .camera, .scenery => theme.text_dim, + }; + const width = gui.textSize(text).x; + const box = mark.box(); + const gap = px(5); + const spots = [_]Vec2{ + .{ .x = box[1].x + gap, .y = mark.middle.y - height / 2 }, + .{ .x = box[0].x - gap - width, .y = mark.middle.y - height / 2 }, + .{ .x = mark.middle.x - width / 2, .y = box[1].y + px(2) }, + .{ .x = mark.middle.x - width / 2, .y = box[0].y - px(2) - height }, + }; + const at = labels.place(&spots, .{ .x = width, .y = height }) orelse continue; + drawing.label(list, at, .{ .x = width, .y = height }, color, text); + shown.* = true; + } + return named; +} + +/// A ring the map draws, for its label. +const Ring = struct { + middle: Vec2, + radius: f32, + range: game.Range, +}; + +/// The ranges, each ring once however many triggers watch it: a trigger's as a dashed ring with a +/// faint fill, a hint's as a dotted one. Returns the rings drawn, for their labels. +fn drawRanges(list: *c.ImDrawList, scene: game.Scene, projection: Projection, scale: f32, corners: [2]Vec2) []const Ring { + var rings: std.ArrayList(Ring) = .empty; + ranges: for (scene.ranges, 0..) |range, index| { + for (scene.ranges[0..index]) |before| { + if (before.section == range.section and before.index == range.index and before.radius == range.radius and before.style == range.style) continue :ranges; + } + const subject = thingOf(scene, .{ .section = range.section, .index = range.index }) orelse continue; + const middle = plot.toPixels(projection.project(placeOf(subject))); + const radius = @as(f32, @floatCast(range.radius)) * scale; + const reach = drawing.reachOf(middle, corners); + // A ring smaller than a few pixels, or one the map lies outside, shows nothing; one the + // map lies inside shows only its fill. + if (radius < px(4) or radius < reach[0]) continue; + const shows = radius <= reach[1]; + switch (range.style) { + .trigger => { + const fill = theme.fade(theme.accent, 0.035); + if (shows) c.ImDrawList_AddCircleFilled(list, middle, radius, fill, 0) else gui.rect(list, corners[0], corners[1], fill, 0); + if (shows) drawing.dashedCircle(list, middle, radius, theme.fade(theme.accent, 0.55), 5, 4, corners); + }, + .hint => if (shows) drawing.dashedCircle(list, middle, radius, theme.fade(theme.text_menu, 0.3), 1.5, 3, corners), + } + if (shows) rings.append(gui.frameAllocator(), .{ .middle = middle, .radius = radius, .range = range }) catch break; + } + return rings.items; +} + +/// A ring's label, inside it at its top or else at its bottom, where the ring is wide enough to +/// hold it and the label covers no mark and no name. +fn nameRing(list: *c.ImDrawList, labels: *drawing.Labels, ring: Ring) void { + gui.sans(.regular, 11); + defer gui.popFont(); + const text = gui.print("{s} · {s}", .{ ring.range.label, drawing.distance(ring.range.radius) }); + const size = gui.textSize(text); + if (size.x + px(40) > ring.radius * 2) return; + const left = ring.middle.x - size.x / 2; + const spots = [_]Vec2{ + .{ .x = left, .y = ring.middle.y - ring.radius + px(6) }, + .{ .x = left, .y = ring.middle.y + ring.radius - px(6) - size.y }, + }; + const at = labels.place(&spots, size) orelse return; + const color = switch (ring.range.style) { + .trigger => theme.range_text, + .hint => theme.text_muted, + }; + drawing.label(list, at, size, color, text); +} + +fn drawCurve(list: *c.ImDrawList, projection: Projection, path: []const [3]f64) void { + for (path) |place| c.ImDrawList_PathLineTo(list, plot.toPixels(projection.project(place))); + c.ImDrawList_PathStroke(list, theme.curve, px(1.4), 0); +} + +/// The routes, under the marks: each leg from mark to mark, dotted where the ship flies and dashed +/// where it jumps, in its group's colour, with an arrowhead where it goes and a ring where its +/// route starts. A leg within one cluster shows nothing, and a leg several routes share shows once. +/// With a ship, a group or a marker selected, the routes it's on show brighter and the others +/// faint. Returns the legs drawn, as lines on the screen. +fn drawRoutes(list: *c.ImDrawList, scene: game.Scene, gathered: Gathered, projection: Projection, inspected: ?game.Record, corners: [2]Vec2) []const [2]Vec2 { + var lines: std.ArrayList([2]Vec2) = .empty; + const selected: ?usize = if (inspected) |record| indexOf(scene, .{ .section = record.section, .index = record.index }) else null; + // A ship's group, or a group itself: the routes of the group show too. + const selected_group: ?usize = if (inspected) |record| switch (record.section) { + .ships, .flight_groups => record.group, + else => null, + } else null; + const Drawn = struct { from: usize, to: usize, way: game.Way, color: Color }; + var drawn: std.AutoHashMapUnmanaged(Drawn, void) = .empty; + for (scene.routes) |route| { + const base = if (route.group) |group| theme.groups[group % theme.groups.len] else theme.text_dim; + for (route.legs) |leg| { + const from = endOf(scene, gathered, projection, leg.from); + const to = endOf(scene, gathered, projection, leg.to); + if (from.mark != null and from.mark == to.mark) continue; + const focus: f32 = if (inspected == null) 0.75 else if (isOnRoute(route, leg, selected, selected_group)) 1 else 0.3; + const color = theme.fade(base, focus); + const key: Drawn = .{ .from = from.key(leg.from), .to = to.key(leg.to), .way = leg.way, .color = color }; + const seen = drawn.getOrPut(gui.frameAllocator(), key) catch return lines.items; + if (seen.found_existing) continue; + const line = drawing.leg(list, from, to, .{ .way = leg.way, .starts = leg.starts, .color = color }, corners) orelse continue; + lines.append(gui.frameAllocator(), line) catch return lines.items; + } + } + return lines.items; +} + +/// Whether a leg of `route` is on the selection: the selected ship's route, its group's, or a leg +/// to or from the selected marker. +fn isOnRoute(route: game.Route, leg: game.Leg, selected: ?usize, selected_group: ?usize) bool { + if (selected) |thing| { + if (route.ship == thing or leg.from == thing or leg.to == thing) return true; + } + if (route.ship == null) if (route.group) |group| if (selected_group == group) return true; + return false; +} + +/// Where a leg meets the thing at `thing` in the scene: the mark it's in, or where it is. +fn endOf(scene: game.Scene, gathered: Gathered, projection: Projection, thing: usize) drawing.End { + const owner = gathered.owners[thing]; + if (owner < gathered.marks.len) { + const mark = gathered.marks[owner]; + return .{ .middle = mark.middle, .reach = px(@max(mark.size, mark.width)) / 2, .mark = owner }; + } + return .{ .middle = plot.toPixels(projection.project(placeOf(scene.things[thing]))) }; +} + +/// Where the player starts, at the map's edge, where the map doesn't show it: the way to it, and +/// how far it is from the map's middle. +fn drawStartOffMap(list: *c.ImDrawList, corners: [2]Vec2, shown: plot.Rect, projection: Projection, player: game.Thing, record: game.Record) void { + const at = projection.project(placeOf(player)); + if (at[0] >= shown.x_min and at[0] <= shown.x_max and at[1] >= shown.y_min and at[1] <= shown.y_max) return; + const middle: [2]f64 = .{ (shown.x_min + shown.x_max) / 2, (shown.y_min + shown.y_max) / 2 }; + const across = at[0] - middle[0]; + const up = at[1] - middle[1]; + const far = @sqrt(across * across + up * up); + // The edge the player is beyond, by which way it is most. + const ratio = (shown.x_max - shown.x_min) / (shown.y_max - shown.y_min); + const sideways = @abs(across) > @abs(up) * ratio; + const icon: Icon = if (sideways) (if (across > 0) .arrow_right else .arrow_left) else (if (up > 0) .arrow_up else .arrow_down); + const color = theme.lighten(panels.groupColor(record), player_tint); + gui.sans(.regular, fonts.size.smaller); + defer gui.popFont(); + const text = gui.print("{s} start · {s}", .{ record.title, drawing.distance(far) }); + const width = px(9 + 12 + 6 + 9) + gui.textSize(text).x; + const height = px(24); + const centre: Vec2 = .{ .x = (corners[0].x + corners[1].x) / 2, .y = (corners[0].y + corners[1].y) / 2 }; + const min: Vec2 = if (sideways) + .{ .x = if (across > 0) corners[1].x - px(10) - width else corners[0].x + px(10), .y = centre.y - height / 2 } + else + .{ .x = centre.x - width / 2, .y = if (up > 0) corners[0].y + px(10) else corners[1].y - px(10) - height }; + const max: Vec2 = .{ .x = min.x + width, .y = min.y + height }; + gui.rect(list, min, max, theme.fade(theme.bar, 0.9), height / 2); + gui.outline(list, min, max, theme.border_off, height / 2); + widgets.drawIconCentered(list, .{ .x = min.x + px(9 + 6), .y = (min.y + max.y) / 2 }, icon, fonts.size.icon_tiny, color); + gui.drawText(list, .{ .x = min.x + px(9 + 12 + 6), .y = gui.textTop((min.y + max.y) / 2) }, color, text); +} + +test Projection { + const place: [3]f64 = .{ 1, 2, 3 }; + for (std.enums.values(Projection)) |projection| { + const at = projection.project(place); + try std.testing.expectEqual(place, projection.unproject(at, place)); + } + // From above, X runs across and Z up; from the side and the front, up is Y's opposite. + try std.testing.expectEqual([2]f64{ 1, 3 }, Projection.top.project(place)); + try std.testing.expectEqual([2]f64{ 3, -2 }, Projection.side.project(place)); +} diff --git a/src/views/scene/drawing.zig b/src/views/scene/drawing.zig new file mode 100644 index 0000000..44ed64f --- /dev/null +++ b/src/views/scene/drawing.zig @@ -0,0 +1,475 @@ +//! How the Scene view's map draws its parts on the screen: the grid, the rings, the routes' legs, +//! the ships' and markers' marks, the names, and the notes over the map. Nothing here knows the +//! map's state; the view (`views/scene.zig`) says what goes where. + +const std = @import("std"); +const c = @import("c"); + +const game = @import("../../game.zig"); +const ui = @import("../../ui.zig"); +const fonts = ui.fonts; +const gui = ui.gui; +const plot = ui.plot; +const theme = ui.theme; +const Color = theme.Color; +const Vec2 = gui.Vec2; +const px = gui.px; + +/// The least spacing of the grid's cells, in pixels: each is the smallest of 1, 2 or 5 times a +/// power of ten that is at least this wide. A line every `major_every` cells is brighter. +const least_cell = 40; +const major_every = 5; +/// A cluster's height, the size of its count, and the room around the count, in design pixels. A +/// cluster is the mark the map draws for several things that would overlap on the screen. +pub const cluster_size = 20; +const cluster_figures = fonts.size.tiny; +const cluster_padding = 4; +/// How long a leg to or from a place off the map is drawn, in pixels. +const stub_length = 56; + +pub fn distanceTo(a: Vec2, b: Vec2) f32 { + return @sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)); +} + +/// The point `pixels` from `from` along the unit vector `way`. +pub fn along(from: Vec2, way: Vec2, pixels: f32) Vec2 { + return .{ .x = from.x + way.x * pixels, .y = from.y + way.y * pixels }; +} + +/// Whether `point` is in the box between `corners`, edges included. +pub fn isInside(point: Vec2, corners: [2]Vec2) bool { + return point.x >= corners[0].x and point.x <= corners[1].x and point.y >= corners[0].y and point.y <= corners[1].y; +} + +/// The part of the line from `from` to `to` inside `corners`, as distances along it from `from`; +/// null where none of it is. +pub fn clipAlong(from: Vec2, to: Vec2, corners: [2]Vec2) ?[2]f32 { + const length = distanceTo(from, to); + if (length == 0) return null; + const delta: Vec2 = .{ .x = to.x - from.x, .y = to.y - from.y }; + var first: f32 = 0; + var last: f32 = 1; + // Each edge cuts off the part of the line beyond it (Liang and Barsky's clipping). + for ([_][2]f32{ + .{ -delta.x, from.x - corners[0].x }, + .{ delta.x, corners[1].x - from.x }, + .{ -delta.y, from.y - corners[0].y }, + .{ delta.y, corners[1].y - from.y }, + }) |edge| { + const toward, const room = edge; + if (toward == 0) { + if (room < 0) return null; + continue; + } + const cut = room / toward; + if (toward < 0) first = @max(first, cut) else last = @min(last, cut); + } + if (first > last) return null; + return .{ first * length, last * length }; +} + +/// How near and how far the map's area is from `middle`, in pixels: a ring shows on the map only +/// with a radius between the two. +pub fn reachOf(middle: Vec2, corners: [2]Vec2) [2]f32 { + const nearest: Vec2 = .{ .x = std.math.clamp(middle.x, corners[0].x, corners[1].x), .y = std.math.clamp(middle.y, corners[0].y, corners[1].y) }; + var farthest: f32 = 0; + for ([_]f32{ corners[0].x, corners[1].x }) |x| for ([_]f32{ corners[0].y, corners[1].y }) |y| { + farthest = @max(farthest, distanceTo(middle, .{ .x = x, .y = y })); + }; + return .{ distanceTo(middle, nearest), farthest }; +} + +/// The grid's cell for `scale` pixels to the game's unit: 1, 2 or 5 times a power of ten, the least +/// at least `least_cell` pixels wide. +pub fn cellOf(scale: f32) f64 { + if (scale <= 0) return 1000; + const least: f64 = least_cell / @as(f64, scale); + var power = std.math.pow(f64, 10, @floor(std.math.log10(least))); + while (true) : (power *= 10) { + for ([_]f64{ 1, 2, 5 }) |step| if (step * power >= least) return step * power; + } +} + +/// The grid over the part of the map `shown`, a line every `cell` of the game's units. +pub fn grid(list: *c.ImDrawList, shown: plot.Rect, cell: f64) void { + const corners = plot.area(); + var x = @ceil(shown.x_min / cell) * cell; + while (x <= shown.x_max) : (x += cell) { + const at = plot.toPixels(.{ x, 0 }).x; + const color = if (isMajor(x, cell)) theme.map_grid else theme.map_grid_minor; + c.ImDrawList_AddLineEx(list, .{ .x = at, .y = corners[0].y }, .{ .x = at, .y = corners[1].y }, color, px(1)); + } + var y = @ceil(shown.y_min / cell) * cell; + while (y <= shown.y_max) : (y += cell) { + const at = plot.toPixels(.{ 0, y }).y; + const color = if (isMajor(y, cell)) theme.map_grid else theme.map_grid_minor; + c.ImDrawList_AddLineEx(list, .{ .x = corners[0].x, .y = at }, .{ .x = corners[1].x, .y = at }, color, px(1)); + } +} + +fn isMajor(value: f64, cell: f64) bool { + const cells = @round(value / cell); + return @mod(cells, major_every) == 0; +} + +/// A circle of dashes `dash` pixels long, `gap` pixels apart, drawn only where it can show on the +/// map, so that a ring far larger than the map costs no more than one that fits it. +pub fn dashedCircle(list: *c.ImDrawList, middle: Vec2, radius: f32, color: Color, dash: f32, gap: f32, corners: [2]Vec2) void { + var from: f32 = 0; + var to: f32 = 2 * std.math.pi; + if (!isInside(middle, corners)) { + // From outside the map, its corners lie within half a turn of the way to its middle. + const towards = std.math.atan2((corners[0].y + corners[1].y) / 2 - middle.y, (corners[0].x + corners[1].x) / 2 - middle.x); + var least: f32 = 0; + var most: f32 = 0; + for ([_]f32{ corners[0].x, corners[1].x }) |x| for ([_]f32{ corners[0].y, corners[1].y }) |y| { + var turn = std.math.atan2(y - middle.y, x - middle.x) - towards; + if (turn > std.math.pi) turn -= 2 * std.math.pi; + if (turn < -std.math.pi) turn += 2 * std.math.pi; + least = @min(least, turn); + most = @max(most, turn); + }; + from = towards + least; + to = towards + most; + } + const step = px(dash + gap) / radius; + const share = dash / (dash + gap); + var angle = @floor(from / step) * step; + while (angle < to) : (angle += step) { + c.ImDrawList_PathArcTo(list, middle, radius, angle, angle + step * share, 2); + c.ImDrawList_PathStroke(list, color, px(1), 0); + } +} + +/// The places taken on the map by its marks and the names placed so far, so that a name goes where +/// it covers neither, and the routes' legs, which a name keeps off where it can. +pub const Labels = struct { + corners: [2]Vec2, + taken: std.ArrayList([2]Vec2) = .empty, + lines: []const [2]Vec2 = &.{}, + + /// Takes the first of `spots` where a name `size` big fits, clear of the legs where one is, + /// and returns it, if one does. + pub fn place(labels: *Labels, spots: []const Vec2, size: Vec2) ?Vec2 { + for ([_]bool{ true, false }) |clear_of_lines| for (spots) |spot| { + const box: [2]Vec2 = .{ spot, .{ .x = spot.x + size.x, .y = spot.y + size.y } }; + if (!isInside(box[0], labels.corners) or !isInside(box[1], labels.corners) or labels.covers(box)) continue; + if (clear_of_lines and labels.crossesLine(box)) continue; + labels.take(box); + return spot; + }; + return null; + } + + pub fn take(labels: *Labels, box: [2]Vec2) void { + labels.taken.append(gui.frameAllocator(), box) catch {}; + } + + fn covers(labels: Labels, box: [2]Vec2) bool { + for (labels.taken.items) |other| { + if (box[0].x < other[1].x and box[1].x > other[0].x and box[0].y < other[1].y and box[1].y > other[0].y) return true; + } + return false; + } + + fn crossesLine(labels: Labels, box: [2]Vec2) bool { + for (labels.lines) |line| if (clipAlong(line[0], line[1], box) != null) return true; + return false; + } +}; + +/// A name on the map, `size` big at `at`, on a backing of the map's colour that the lines and rings +/// pass under, with a dark edge. +pub fn label(list: *c.ImDrawList, at: Vec2, size: Vec2, color: Color, text: []const u8) void { + const pad: Vec2 = .{ .x = px(3), .y = px(1) }; + gui.rect(list, .{ .x = at.x - pad.x, .y = at.y - pad.y }, .{ .x = at.x + size.x + pad.x, .y = at.y + size.y + pad.y }, theme.fade(theme.map, 0.82), 3); + for ([_][2]f32{ .{ -1, 0 }, .{ 1, 0 }, .{ 0, -1 }, .{ 0, 1 } }) |offset| { + gui.drawText(list, .{ .x = at.x + offset[0], .y = at.y + offset[1] }, theme.ground, text); + } + gui.drawText(list, at, color, text); +} + +/// What the map draws for a thing at `middle`, `size` design pixels across: a ship's arrow, +/// pointing the way `heading` goes on the map, outlined in its group's colour on the map's ground, +/// as where it starts; a marker's diamond, filled; a camera point's small ring; or a planet's or +/// the sun's faint disc. +pub fn thing(list: *c.ImDrawList, middle: Vec2, kind: game.ThingKind, heading: ?[2]f64, size: f32, color: Color) void { + const scale = px(size) / 24; + switch (kind) { + .ship => { + // The design's arrow, in a box of 24 with its middle at 12, 12, pointing up. + const shape = [_][2]f32{ .{ 0, -10 }, .{ 8, 9 }, .{ 0, 4.5 }, .{ -8, 9 } }; + const way = heading orelse .{ 0, 1 }; + const angle: f32 = if (@abs(way[0]) + @abs(way[1]) < 1e-6) 0 else @floatCast(std.math.atan2(way[0], way[1])); + var points: [4]Vec2 = undefined; + for (shape, &points) |point, *made| made.* = turned(middle, point, scale, angle); + // Filled in two halves, since it isn't convex, then outlined. + c.ImDrawList_AddTriangleFilled(list, points[0], points[1], points[2], theme.map); + c.ImDrawList_AddTriangleFilled(list, points[0], points[2], points[3], theme.map); + c.ImDrawList_AddPolyline(list, &points, points.len, color, px(1.8), c.ImDrawFlags_Closed); + }, + .marker => { + const shape = [_][2]f32{ .{ 0, -10 }, .{ 10, 0 }, .{ 0, 10 }, .{ -10, 0 } }; + var points: [4]Vec2 = undefined; + for (shape, &points) |point, *made| made.* = turned(middle, point, scale, 0); + c.ImDrawList_AddConvexPolyFilled(list, &points, points.len, color); + c.ImDrawList_AddPolyline(list, &points, points.len, theme.ground, px(1.2), c.ImDrawFlags_Closed); + }, + .camera => { + c.ImDrawList_AddCircleFilled(list, middle, px(size) / 2, theme.map, 0); + c.ImDrawList_AddCircleEx(list, middle, px(size) / 2, theme.curve, 0, px(1.4)); + }, + .scenery => { + c.ImDrawList_AddCircleFilled(list, middle, px(size) / 2, theme.fade(theme.text_dim, 0.18), 0); + c.ImDrawList_AddCircleEx(list, middle, px(size) / 2, theme.fade(theme.text_dim, 0.7), 0, px(1.2)); + }, + } +} + +/// `point` of a shape around `middle`, scaled, and turned clockwise by `angle` radians. +fn turned(middle: Vec2, point: [2]f32, scale: f32, angle: f32) Vec2 { + const x = point[0] * scale; + const y = point[1] * scale; + return .{ .x = middle.x + x * @cos(angle) - y * @sin(angle), .y = middle.y + x * @sin(angle) + y * @cos(angle) }; +} + +/// A cluster of `count` things: a ring in `color` with the count in its middle, or a pill where the +/// count is too wide for the ring (`clusterWidth`). +pub fn cluster(list: *c.ImDrawList, middle: Vec2, count: usize, color: Color) void { + const width = clusterWidth(count); + gui.sans(.semibold, cluster_figures); + defer gui.popFont(); + const text = gui.print("{d}", .{count}); + const text_width = gui.textSize(text).x; + // ImGui draws text from whole points, so the count goes there, and the ring around it. + const at: Vec2 = .{ .x = @round(middle.x - text_width / 2), .y = gui.capitalsTop(middle.y) }; + const centre: Vec2 = .{ .x = at.x + text_width / 2, .y = at.y + gui.ascent() - gui.capitals() / 2 }; + const half: Vec2 = .{ .x = px(width) / 2, .y = px(cluster_size) / 2 }; + const line = px(1.6); + if (width > cluster_size) { + const min: Vec2 = .{ .x = centre.x - half.x, .y = centre.y - half.y }; + const max: Vec2 = .{ .x = centre.x + half.x, .y = centre.y + half.y }; + gui.rect(list, min, max, theme.map, cluster_size / 2); + c.ImDrawList_AddRectEx(list, .{ .x = min.x + line / 2, .y = min.y + line / 2 }, .{ .x = max.x - line / 2, .y = max.y - line / 2 }, color, half.y - line / 2, line, 0); + } else { + c.ImDrawList_AddCircleFilled(list, centre, half.y, theme.map, 0); + c.ImDrawList_AddCircleEx(list, centre, half.y - line / 2, color, 0, line); + } + gui.drawText(list, at, color, text); +} + +/// How wide a cluster of `count` things is, in design pixels: a ring, or wider for a count of three +/// figures. +pub fn clusterWidth(count: usize) f32 { + gui.sans(.semibold, cluster_figures); + defer gui.popFont(); + const figures = gui.textSize(gui.print("{d}", .{count})).x / px(1); + return @max(cluster_size, figures + 2 * cluster_padding); +} + +/// The selection's corners around `middle`, `half` pixels out. +pub fn brackets(list: *c.ImDrawList, middle: Vec2, half: f32) void { + const long = px(8); + for ([_][2]f32{ .{ -1, -1 }, .{ 1, -1 }, .{ -1, 1 }, .{ 1, 1 } }) |corner| { + const tip: Vec2 = .{ .x = middle.x + corner[0] * half, .y = middle.y + corner[1] * half }; + var points = [_]Vec2{ + .{ .x = tip.x, .y = tip.y - corner[1] * long }, + tip, + .{ .x = tip.x - corner[0] * long, .y = tip.y }, + }; + c.ImDrawList_AddPolyline(list, &points, points.len, theme.accent, px(2), 0); + } +} + +/// The note by the selected thing: where it is on the map's axes, `names` across and up, and how +/// to move it. +pub fn placeNote(list: *c.ImDrawList, middle: Vec2, names: [2][]const u8, values: [2]f64) void { + const where = gui.print("{s} {d} · {s} {d}", .{ names[0], @round(values[0]), names[1], @round(values[1]) }); + const how = "Drag · Shift: snap 100"; + gui.mono(.regular, fonts.size.smaller); + const where_width = gui.textSize(where).x; + gui.popFont(); + gui.sans(.regular, fonts.size.smaller); + const how_width = gui.textSize(how).x; + gui.popFont(); + const width = @max(where_width, how_width) + px(18); + const height = px(42); + const min: Vec2 = .{ .x = middle.x - px(14) - width, .y = middle.y + px(14) }; + const max: Vec2 = .{ .x = min.x + width, .y = min.y + height }; + gui.rect(list, min, max, theme.fade(theme.panel, 0.94), 4); + gui.outline(list, min, max, theme.border_strong, 4); + gui.mono(.regular, fonts.size.smaller); + gui.drawText(list, .{ .x = min.x + px(9), .y = gui.textTop(min.y + px(13)) }, theme.text_strong, where); + gui.popFont(); + gui.sans(.regular, fonts.size.smaller); + gui.drawText(list, .{ .x = min.x + px(9), .y = gui.textTop(min.y + px(29)) }, theme.text_muted, how); + gui.popFont(); +} + +/// Where a leg meets a thing on the screen: the middle of the mark it's in, and how far out from +/// it the mark reaches, or where it is where it's in no mark. +pub const End = struct { + middle: Vec2, + reach: f32 = 0, + mark: ?usize = null, + + /// A number for the end: its mark's, or else its thing's, `thing_index`. The two never clash. + pub fn key(end: End, thing_index: usize) usize { + return if (end.mark) |mark| mark * 2 else thing_index * 2 + 1; + } +}; + +pub const LegLook = struct { + way: game.Way, + starts: bool, + color: Color, +}; + +/// A leg from `from` to `to`, clear of both marks: dots for a flight, dashes for a jump, an +/// arrowhead at its end, and a ring at its start where its route starts there. A leg to or from a +/// place the map doesn't show is a stub `stub_length` long that points the way, and one between +/// two such places shows nothing. Only the part inside `corners` is drawn. Returns the line drawn, +/// if any. +pub fn leg(list: *c.ImDrawList, from: End, to: End, look: LegLook, corners: [2]Vec2) ?[2]Vec2 { + const from_shows = isInside(from.middle, corners); + const to_shows = isInside(to.middle, corners); + if (!from_shows and !to_shows) return null; + const length = distanceTo(from.middle, to.middle); + var start_at = from.reach + px(3); + var end_at = length - to.reach - px(3); + if (end_at - start_at < px(6)) return null; + if (!to_shows) end_at = @min(end_at, start_at + px(stub_length)); + if (!from_shows) start_at = @max(start_at, end_at - px(stub_length)); + const way: Vec2 = .{ .x = (to.middle.x - from.middle.x) / length, .y = (to.middle.y - from.middle.y) / length }; + const start = along(from.middle, way, start_at); + const end = along(from.middle, way, end_at); + const shown = clipAlong(start, end, corners) orelse return null; + const period = px(switch (look.way) { + .fly => 5, + .jump => 10, + }); + var at = @floor(shown[0] / period) * period; + while (at < shown[1]) : (at += period) { + switch (look.way) { + .fly => if (at >= shown[0]) c.ImDrawList_AddCircleFilled(list, along(start, way, at), px(1), look.color, 4), + .jump => { + const dash_start = @max(at, shown[0]); + const dash_end = @min(at + px(6), shown[1]); + if (dash_end > dash_start) c.ImDrawList_AddLineEx(list, along(start, way, dash_start), along(start, way, dash_end), look.color, px(1.5)); + }, + } + } + if (isInside(end, corners)) { + const back = along(end, way, -px(7)); + const side: Vec2 = .{ .x = -way.y * px(3.5), .y = way.x * px(3.5) }; + c.ImDrawList_AddTriangleFilled(list, end, .{ .x = back.x + side.x, .y = back.y + side.y }, .{ .x = back.x - side.x, .y = back.y - side.y }, look.color); + } + if (look.starts and from_shows and isInside(start, corners)) gui.ring(list, along(start, way, px(3)), 3, 1.4, look.color); + return .{ along(start, way, shown[0]), along(start, way, shown[1]) }; +} + +/// What the map's marks and lines are, in its top left corner: where the ships start, and while the +/// routes show, the legs ships fly and those they jump. +pub fn legend(list: *c.ImDrawList, corner: Vec2, routes: bool) void { + const rows = [_][]const u8{ "Start", "Flies", "Jumps" }; + const shown: usize = if (routes) rows.len else 1; + gui.sans(.regular, fonts.size.smaller); + defer gui.popFont(); + var widest: f32 = 0; + for (rows[0..shown]) |text| widest = @max(widest, gui.textSize(text).x); + const sample = px(18); + const row = px(20); + const min: Vec2 = .{ .x = corner.x + px(10), .y = corner.y + px(10) }; + const max: Vec2 = .{ .x = min.x + px(10 + 7 + 10) + sample + widest, .y = min.y + px(10) + row * @as(f32, @floatFromInt(shown)) }; + gui.rect(list, min, max, theme.fade(theme.bar, 0.9), 4); + gui.outline(list, min, max, theme.border_off, 4); + for (rows[0..shown], 0..) |text, index| { + const middle = min.y + px(5) + row * (@as(f32, @floatFromInt(index)) + 0.5); + const left = min.x + px(10); + switch (index) { + 0 => thing(list, .{ .x = left + sample / 2, .y = middle }, .ship, null, fonts.size.icon_tiny, theme.text_menu), + else => _ = leg(list, .{ .middle = .{ .x = left, .y = middle } }, .{ .middle = .{ .x = left + sample + px(4), .y = middle } }, .{ + .way = if (index == 1) .fly else .jump, + .starts = false, + .color = theme.text_menu, + }, .{ min, max }), + } + gui.drawText(list, .{ .x = left + sample + px(7), .y = gui.textTop(middle) }, theme.text_menu, text); + } +} + +/// The scale, a cell of the grid long, its left end at `corner`, at `scale` pixels to the game's +/// unit. +pub fn scaleBar(list: *c.ImDrawList, corner: Vec2, cell: f64, scale: f32) void { + const length = @as(f32, @floatCast(cell)) * scale; + const bottom = corner.y; + var points = [_]Vec2{ .{ .x = corner.x, .y = bottom - px(6) }, .{ .x = corner.x, .y = bottom }, .{ .x = corner.x + length, .y = bottom }, .{ .x = corner.x + length, .y = bottom - px(6) } }; + c.ImDrawList_AddPolyline(list, &points, points.len, theme.text_muted, px(1), 0); + gui.sans(.regular, 11); + defer gui.popFont(); + gui.drawText(list, .{ .x = corner.x, .y = bottom - px(9) - c.ImGui_GetFontSize() }, theme.text_muted, distance(cell)); +} + +/// A distance as the map writes it: `4k` for 4000, `1.5k`, `2.5M` for 2,500,000, or `500`. +pub fn distance(value: f64) []const u8 { + const size = @abs(value); + for ([_]struct { f64, []const u8 }{ .{ 1e6, "M" }, .{ 1e3, "k" } }) |unit| { + if (size < unit[0]) continue; + const count = value / unit[0]; + if (@round(count) == count or size >= 100 * unit[0]) return gui.print("{d}{s}", .{ @round(count), unit[1] }); + return gui.print("{d:.1}{s}", .{ count, unit[1] }); + } + return gui.print("{d}", .{@round(value)}); +} + +test cellOf { + // 54 pixels to a thousand: the cell is a thousand; zoomed out ten times, ten thousand. + try std.testing.expectEqual(1000, cellOf(54.0 / 1000.0)); + try std.testing.expectEqual(10_000, cellOf(5.4 / 1000.0)); + try std.testing.expectEqual(2000, cellOf(30.0 / 1000.0)); +} + +test distance { + try std.testing.expectEqualStrings("4k", distance(4000)); + try std.testing.expectEqualStrings("1.5k", distance(1500)); + try std.testing.expectEqualStrings("500", distance(500)); + try std.testing.expectEqualStrings("-2k", distance(-2000)); + try std.testing.expectEqualStrings("2.5M", distance(2_500_000)); + try std.testing.expectEqualStrings("150M", distance(150_000_000)); +} + +test clipAlong { + const corners: [2]Vec2 = .{ .{ .x = 0, .y = 0 }, .{ .x = 100, .y = 100 } }; + // A line across the map from outside it on the left to outside it on the right shows inside + // it, 50 to 150 pixels along. + try std.testing.expectEqual([2]f32{ 50, 150 }, clipAlong(.{ .x = -50, .y = 50 }, .{ .x = 150, .y = 50 }, corners).?); + // One wholly inside shows whole, and one beside the map not at all. + try std.testing.expectEqual([2]f32{ 0, 10 }, clipAlong(.{ .x = 10, .y = 10 }, .{ .x = 20, .y = 10 }, corners).?); + try std.testing.expectEqual(null, clipAlong(.{ .x = -50, .y = -10 }, .{ .x = 150, .y = -10 }, corners)); +} + +test Labels { + defer gui.newFrame(); + const corners: [2]Vec2 = .{ .{ .x = 0, .y = 0 }, .{ .x = 200, .y = 200 } }; + // A leg leaves a mark at 100, 100 to the right. + var labels: Labels = .{ .corners = corners, .lines = &.{.{ .{ .x = 110, .y = 100 }, .{ .x = 190, .y = 100 } }} }; + const size: Vec2 = .{ .x = 40, .y = 10 }; + const right: Vec2 = .{ .x = 110, .y = 95 }; + const left: Vec2 = .{ .x = 50, .y = 95 }; + // The name goes left, clear of the leg, though right comes first. + try std.testing.expectEqual(left, labels.place(&.{ right, left }, size).?); + // With the left taken, the next covers the leg rather than going without. + try std.testing.expectEqual(right, labels.place(&.{ right, left }, size).?); + // With both taken, the third goes without. + try std.testing.expectEqual(null, labels.place(&.{ right, left }, size)); +} + +test reachOf { + const corners: [2]Vec2 = .{ .{ .x = 0, .y = 0 }, .{ .x = 300, .y = 400 } }; + // From inside the map, the nearest is its own place and the farthest the far corner. + try std.testing.expectEqual([2]f32{ 0, 500 }, reachOf(.{ .x = 0, .y = 0 }, corners)); + // From 100 pixels left of the map's left edge. + const reach = reachOf(.{ .x = -100, .y = 0 }, corners); + try std.testing.expectEqual(100, reach[0]); + try std.testing.expectApproxEqAbs(@sqrt(400.0 * 400.0 + 400.0 * 400.0), reach[1], 1e-3); +} diff --git a/src/views/script.zig b/src/views/script.zig index 9831b63..92df513 100644 --- a/src/views/script.zig +++ b/src/views/script.zig @@ -72,30 +72,16 @@ pub fn draw(app: *App) void { } } -/// The panel's own tab bar, as the docked panels' (`widgets.PanelTab`): the source file, and its +/// The panel's own tab bar, as the docked panels' (`widgets.panelTabs`): the source file, and its /// script listing. fn tabs(app: *App, document: *Document) void { - const at = gui.cursor(); - const width = gui.available().x; - const height = px(widgets.tab_bar_height); - const list = gui.drawList(); - gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.bar, 0); - gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.tab_divider, 0); - var x = at.x; - for (std.enums.values(app_mod.SourceTab)) |which| { - const on = app.source_tab == which; - const shown: widgets.PanelTab = switch (which) { - .source => .{ .label = document.file, .icon = .file_code, .icon_color = theme.accent, .dot = document.dirty(), .on = on }, - .listing => .{ .label = "Script listing", .icon = .list, .detail = if (document.built) |built| built.file else "not built", .on = on }, - }; - const tab_width = shown.width(); - gui.setCursor(.{ .x = x, .y = at.y }); - if (c.ImGui_InvisibleButton(@tagName(which), .{ .x = tab_width, .y = height - px(1) }, 0)) app.source_tab = which; - shown.draw(list, .{ .x = x, .y = at.y }, .{ .x = x + tab_width, .y = at.y + height - px(1) }, gui.hovered()); - if (shown.dot) gui.tooltip("Changed since saved"); - x += tab_width; - } - gui.setCursor(.{ .x = at.x, .y = at.y + height }); + const which = std.enums.values(app_mod.SourceTab); + var shown: [which.len]widgets.PanelTab = undefined; + for (which, &shown) |tab, *made| made.* = switch (tab) { + .source => .{ .label = document.file, .icon = .file_code, .icon_color = theme.accent, .dot = document.dirty(), .on = app.source_tab == tab }, + .listing => .{ .label = "Script listing", .icon = .list, .detail = if (document.built) |built| built.file else "not built", .on = app.source_tab == tab }, + }; + if (widgets.panelTabs(&shown)) |index| app.source_tab = which[index]; } /// The line under the tabs: the file, the record the cursor is in, and its statement, with the