diff --git a/docs/manual/09-objects.md b/docs/manual/09-objects.md index 2ac659c..0b56b6f 100644 --- a/docs/manual/09-objects.md +++ b/docs/manual/09-objects.md @@ -56,8 +56,10 @@ The **Objects** panel sits in the left dock next to the Outliner – the tab wit * **Click** an entry to make it the current type and switch to the **Place objects** tool (**P**). Then click in the viewport to place it. * **Drag** an entry into the viewport to place one object exactly under the mouse pointer. -* **Right-click** an entry to add it to or remove it from your **favourites**. Favourites are - marked with ★, listed first and remembered between sessions. +* **Right-click** an entry for its menu: **Add to favourites ★** / **Remove from favourites** + (favourites are marked with ★, listed first and remembered between sessions) and, for your + own [custom types](22-strats.md), **Edit type…** and **Delete type…**. The same menu (with + **Place at view centre**) opens on the types in **Assets → Objects**. * Hover an entry for a tooltip with the description of the type. * **parameters decoded** means the editor knows the meaning of the type's parameters and shows named fields in the Inspector (see [Object parameters](#object-parameters)). @@ -89,7 +91,10 @@ ground. It is chosen automatically when you click a type in the Objects panel or 3. Click to place. The new object is selected, and the status bar shows its position. With **Snap to floor** on, the object is put onto the *collision* floor under the pointer, not -just onto the visible surface – so it ends up where the llama can actually reach it. With grid +just onto the visible surface – so it ends up where the llama can actually reach it. Like the +game, the editor only counts the tiles listed in the grid cell under the point: a floor polygon +that reaches into the cell from a neighbouring tile is ignored there (it is used only when the +cell has no floor at all). With grid snapping on (**G**), the position snaps to the move step first. ### Moving and turning objects @@ -139,11 +144,11 @@ create the complete set with one click in the viewport. The player start is the **Llama** object. A level has exactly one, so the setup **moves** the existing Llama to the clicked point (and turns it to the chosen yaw) instead of adding a second -one. If the level has no Llama yet, one is placed together with its waypoint chain. +one. If the level has no Llama yet, one is placed with a single waypoint 2 cells behind it. -The first node of the Llama's chain is the **start position of the camera**: the setup puts it -8 cells behind the llama, slightly lower; the remaining nodes stay on the llama. See -[The start camera](11-cameras-triggers.md#the-start-camera). +The Llama's waypoints are **start camera positions**: the game uses the first one within 2.5 +cells of the llama. Moving the start takes the nearby nodes and the village fly-by trigger along. +See [The start camera](11-cameras-triggers.md#the-start-camera). > **Tip:** **Level → Play from camera position** (**Shift+F5**) starts a play-test at another > point without moving the real start – see [Building and play-testing](15-build-play.md). @@ -305,6 +310,8 @@ The editor knows the parameters of these types (the rest show raw values): | **RubyIdolDoor** | Idols needed, Offset (sign distance) | – | | **PressurePadDoor** | Pad flags (bit field), Door z, Camera, Camera hold | – | | **CityGuard** (and CityGuardWithRubyIdol, CowGuard, GorillaGuard, LizardGuard, WarthogGuard) | View range, Chase range, Event bit (0 = active at once), Wall collision (CityGuard only) | Walking boxes: node pairs; the guard must **start inside one box** | +| **CowGuardSpawner** | View range and Chase range of its guards, Max guards, Interval (s) | Walking boxes handed to its guards; the spawner must stand inside one | +| **Alligator**, **AlligatorSpawner** | Spawner: Max alligators, Interval (s), Floor collision | Walking boxes: node pairs; an alligator outside every box is moved into the first one; a spawner's alligators walk straight ahead into the first box | | **JumpingBeetleBlue**, **JumpingBeetle** | Jump time, Wait, Jump height, Pause | Jump targets | | **Spider** | Range, Speed, Down time, Up time, Start down | Path | | **Bunny** | Flee range | – | @@ -314,7 +321,12 @@ The editor knows the parameters of these types (the rest show raw values): > game removes it when the level starts – it never appears. Placing a guard gives it one 4×4 box > around itself and Event bit 0; the Inspector shows a warning and a **Box around it** button > when the boxes are wrong. An **Event bit** above 0 keeps the guard hidden until another object -> with that event number (a talking character, a smash statue, a door pad …) sets the bit. +> with that event number sets the bit: TipoVillage / ChacaVillage (parameter 6), the smash +> statues (parameter 0), LabBigCutscene (1), LabWall (4), LabSlidingDoor (5), GoldIdol (1), +> CityWall (4), Condor (2), RiverCrocGame (1), RiverTriggerBox (0), KidOnCanoe (4), YzmaRiver +> (2, 6), EndGameTriggerBox (0). **Level → Check** notes a guard whose bit no object in the level +> sets. Guards and alligators get their one box however you add them (Place tool, drag from a +> panel, double-click in Assets). > **Tip:** For pickups, leave **Snap to floor** on: the game then drops the coin onto the floor > when the level starts, so a coin placed slightly too high still ends up in the right place. diff --git a/docs/manual/11-cameras-triggers.md b/docs/manual/11-cameras-triggers.md index c445f4c..1cad406 100644 --- a/docs/manual/11-cameras-triggers.md +++ b/docs/manual/11-cameras-triggers.md @@ -21,17 +21,27 @@ areas as boxes and their cameras as frusta (pyramids showing the view). ## The start camera -The **Llama** (the player start) owns a waypoint chain. Its **first node is the camera's start -position** when the level begins; the game then moves the camera to its normal place behind the -llama. Levels made with the editor put this node 8 cells behind the llama (the -[Player start](09-objects.md#player-start) setup keeps it there when you move the start). +The **Llama** (the player start) owns a waypoint chain of **camera start positions**. When the +level begins, the game takes the first node that lies within **2.5 cells** of the llama as the +camera position, looking at the llama. If that node's field *unk2* is not 0, its distance and +height to the llama also become the camera distance – until the llama first moves. In the +original levels there is one node per entry door; a node that sits on the llama would put the +camera into the llama's head. + +The [Player start](09-objects.md#player-start) setup gives a new Llama a single node 2 cells +behind it. Moving the start takes the nodes near the old start along (and turns them with the +llama), leaves the door nodes where they are, and adds a node if none is in range. The Place +tool's rotate knob turns the near nodes too. **Level → Check** warns when the start camera would +sit inside the llama. + +The villages also start with a **fly-by**: a *CutSceneTrigger* stands on the player start (radius +0.7). When you move the start, the setup moves this trigger along and sets its "end behind the +player" flag (parameter 6, bit 1), so the fly-by ends behind the llama at the new start instead +of at the old one. The Llama's *Camera mode* parameter switches between the normal and an alternative camera behaviour. -> **Note:** In the original levels the Llama's chain is often a longer list used by the level's -> scripts (for example the village's fly-by at the start). Edit it with care. - ## Camera zones A camera zone is an object with a **box**: two waypoints marking opposite corners on the ground. diff --git a/notes/editor-object-schemas.md b/notes/editor-object-schemas.md index f6cb608..6b73183 100644 --- a/notes/editor-object-schemas.md +++ b/notes/editor-object-schemas.md @@ -63,7 +63,7 @@ values only. | RubyIdol | p0 falls, p1 snap | – | | Secret (high) | p0 box mode (player inside the box of the 2 nodes, op 0x178) | box (2 nodes) | | CoinLine / CoinCircle (low) | count (counted down), spacing / radius | – | -| Llama | p0 spawns a helper, p1 unused, p2 camera mode (global 107) | first node = camera start | +| Llama | p0 spawns a helper, p1 unused, p2 camera mode (global 107) | camera starts: first node within 2.5 cells of the llama (see "Start camera") | | CheckPointPost (high) | p0 collision radius (+0xD2, 0.2) | 1 node = respawn position + yaw (98 of 102) | | LevelDoor (high) | tribe, world, chapter of the next level (7,7,7 = world finished); fires on material-16 floor of a tile in its cell (any height; often only in the swap model a RubyIdolDoor/other door object switches to when it opens) | – | | CameraSide | p0 height (g48), p1 distance (g47), p2 yaw mode (1 node yaw, 2 node yaw or ±180°), p3 only below node y, p4 flag 0x10000, p10 several boxes, p11 height offset, p12/p13 alt. height/distance | box pairs, node 0 yaw = camera yaw | @@ -100,7 +100,53 @@ info for p2 > 0; the Inspector shows the state plus a **Box around it** button ( `Setups.guardBox`). Verified in game: CityGuard with its box on a new level (base village 2) appears and attacks the llama; a second one with a 4-node "square path" next to it never appears. -The rest (~150 placed types) shows raw values with the original statistics. Next candidates: -Alligator (also box pairs: "Forcing alligator inside first WP box"), PressurePadDoor (bit field), TargetRopePlatform, CataSwingingBlade (speeds/angles), RiverLog. +### Other box walkers (2026-11-07, objs2) + +Same pair scan, kind-specific rules (`Guards`, model list `editor.model.WaypointBoxes`): +* **CowGuardSpawned** (spawned by CowGuardSpawner, same guard code): p0 view, p1 chase, p2 event + bit (spawner passes 0), p3 not read (overwritten by the box scan). Box rule as guards (unk2 = 0). +* **CowGuardSpawner** (6 params, t1l8m005 ×8): `spawn<4,…>(v0, v1, 0, v5, CowGuardSpawned)` at its + own position with its chain, then `wait(v3·30)` until the player is > 1.5 cells away and fewer + than v2 of its guards live (the guard does `parent.v6 −= 1` when it dies). So p0 = view range, + p1 = chase range, p2 = max guards, p3 = interval (s), p4 not read, p5 = guard p3 (unused). The + spawner must stand in a box with unk2 = 0 or each guard is removed at once – 1 of 8 originals + (t1l8m005 strat 82) stands 0.12 outside (warning, not verified in game). +* **Alligator** (no params; v0–v6 are locals), **SpawnedAlligator**: odd node count → "Odd number + of WPs!" + remove; outside every box → "Forcing alligator inside first WP box", clamped into the + first box. No unk2 rule. Bites only when `|player.floorHeight − y| < 0.2` and inside its boxes. +* **AlligatorSpawner** (t1l6m003 ×2): p0 max alive (`v3 < v0`, `parent.v3 −= 1` on death), p1 + interval s, p2 → spawned `collisionOn 0x8400` when set. The spawned alligator (spawner's chain) + walks straight ahead (`moveForwardQ`, spawner yaw) with a fade-in until it is inside the + *first* box – both originals stand outside it, facing it. Check: warning if the ray misses. +Placement (every path through `Edits.placeStrat`): one 4×4 box around the object, unk2 = 0, guards +event bit 0. In game (TestLevel 6 on t1l4m002): an Alligator added this way appears and snaps at +the llama (`img/editor/obj_game_alligator.jpg`). + +### Event bits (global 95) + +Setters found by a sweep over the strat code of all 31 levels (`g95 = g95 | (1 << (v − 1))`), +v = parameter: TipoVillage p6, ChacaVillage p6, SmashStatueSmall/Big/Door p0, RiverCrocGame p1, +RiverTriggerBox p0, KidOnCanoe p4, YzmaRiver p2 and p6, Condor p2 (also clears it), GoldIdol p1, +CityWall p4, NakedPeacock p0, LabSlidingDoor p5, LabSlidingDoorJamie p5, LabBigCutscene p1, LabWall +p4, EndGameTriggerBox p0 (`EventBits`). No native code touches global 95. Check: lab level +t1l8m001 – LizardGuardNoCoin bit 8 ← LabBigCutscene p1 = 8, GorillaGuard bit 30 ← LabWall p4 = 30; +CowGuard bit 20 and CowGuardNoCoin bit 1 have no setter in the level (they never appear in the +original either). `ObjectChecks` adds this to the guard info, naming the setter types of the base. + +### Start camera (Llama waypoints) + +Camera strat (spawned by the Llama with `self.hasWaypoints`), case g0 = 4 at level start, L_1330: +walks the Llama's chain; the first node with `playerDistToWp < 2.5` becomes the camera position, +yaw = angle to the player; if the node's wpParam (unk2) ≠ 0, also g47 = x/z distance and g48 = +height difference, and g43 = 3 blocks the approach to the normal distance until the llama moves. +The defaults are g47 = 1.0, g48 = 0.5 (Camera params from the Llama spawn). Originals: one node per +entry door (unk2 4096 on some), none within 2.5 of the village starts. The old editor setup (and +levelbuild) copied the prototype chain onto the llama: village 2's node 2 has unk2 = 4096 → camera +in the llama's head until it moves (seen in game). Fix: `PlayerStart` (one node 2 behind, unk2 0; +moving keeps door nodes). Village fly-by: CutSceneTrigger on the start, radius² 0.5; p6 bit 1 makes +it (L_0148) rewrite its last path node to a point behind the player – set when the start moves. + +The rest (~145 placed types) shows raw values with the original statistics. Next candidates: +PressurePadDoor (bit field), TargetRopePlatform, CataSwingingBlade (speeds/angles), RiverLog. Tooling used (not in the repo): Ghidra script decompiling the handler table, a Python sweep disassembler with an op-name table, "parameter use" context dumps per type. diff --git a/notes/editor-tools-objects.md b/notes/editor-tools-objects.md index bc0c6a3..885ab39 100644 --- a/notes/editor-tools-objects.md +++ b/notes/editor-tools-objects.md @@ -12,7 +12,7 @@ screenshot tour `ObjectsTour` (test classes). |---|---| | **Objects panel** (left dock) | every placeable type of the base with rendered thumbnail, category filter, search (name + schema summary), favourites (right-click, ★ filter), "parameters decoded" marker; drag into the viewport or click → Place tool. Quick setups: Player start, Checkpoint, Exit, Secret, Coin line, Coin arc | | **Place tool** (P) | click = place on the collision floor under the cursor (snap to floor, grid snap), yaw = camera direction / facing the camera / 0° / type default; click + drag an object = move along the ground (its waypoints move along); drag the yellow knob = rotate (angle snap; a checkpoint's respawn node turns with it). Modes for the setups | -| Setups | **Player start** moves the existing player (exactly one) or places a Llama with its chain (camera node 8 cells behind). **Checkpoint** = CheckPointPost + respawn node 0.4 ahead (floor height). **Exit** = LevelDoor in the cell centre + 1×1 FLOOR brush with collision material 16; target picked in the options bar (world/chapter names, "world finished 7,7,7"). **Secret** = Secret with a 3×3 box (2 nodes). Guards (any placement) get one 4×4 box and event bit 0 | +| Setups | **Player start** moves the existing player (exactly one) or places a Llama with one start camera node 2 cells behind (moving keeps door nodes, carries the near nodes and the village fly-by trigger). **Checkpoint** = CheckPointPost + respawn node 0.4 ahead (floor height). **Exit** = LevelDoor in the cell centre + 1×1 FLOOR brush with collision material 16; target picked in the options bar (world/chapter names, "world finished 7,7,7"). **Secret** = Secret with a 3×3 box (2 nodes). Guards, alligators and spawners get one 4×4 box (and guards event bit 0) on every way of adding an object (`Edits.placeStrat`: Place tool, drag from Objects panel / Assets, double-click) | | **Coin tool** (O) | Line: two clicks, coins every *spacing*; Arc: centre + point on the circle, *count* coins over *arc°*; Coin/Coin5, height above floor, follow floor; live preview | | **Waypoint tool** (N) | select an object (or node): click = add node after the selected node / at the chain end (creates the chain if missing), Ctrl+click = insert before, drag = move (keeps height above floor), Backspace/Del = remove and relink; Reverse; orient new nodes along the path | | **Zones & triggers** (J) | handles: 4 corners of box zones (node pairs: Secret, CameraSide/Lift, ObjectDontDropInBox, FallOffNoHurt …), radius knob for RADIUS parameters (CutSceneTrigger, guards, CameraFocusPoint) | @@ -20,7 +20,7 @@ screenshot tour `ObjectsTour` (test classes). | Inspector "Waypoint chain" | node i of n, owner and role, ◀ ▶, select object, delete node, look through | | Viewport layers | *Path directions* (arrows, dashed wrap segment for patrol paths, node yaw ticks), *Zone areas & trigger radii* (boxes for all zone objects, fills for the selection), *Camera frusta* (CameraSide/Lift: computed game camera from height/distance/node yaw; cut-scene cameras: a frustum per node from node yaw/pitch) | | Look through camera (Ctrl+Shift+L, View → Camera) | viewport takes the camera of the selected node / cut-scene camera / camera zone; while active, flying writes back node position + yaw/pitch, or for CameraSide the height/distance params and node yaw (one undo step per pilot session) | -| Problems (Level → Check, Build) | guards the game would remove (no start box / odd node count) and guards waiting for an event bit, chains not stored contiguously (error, Tools → Repair waypoint chains), param count, out-of-range values, missing required waypoints / odd box pairs, types whose originals always have a chain, LevelDoor without material-16 floor in its cell, impossible exit targets and (7,7,7) in TestLevel, far respawn nodes, several player starts | +| Problems (Level → Check, Build) | guards/alligators/spawners the game would remove (no start box / odd node count), alligators outside their boxes, spawners facing away from their first box, guards waiting for an event bit (and whether any object in the level sets it), a start camera inside the llama, chains not stored contiguously (error, Tools → Repair waypoint chains), param count, out-of-range values, missing required waypoints / odd box pairs, types whose originals always have a chain, LevelDoor without material-16 floor in its cell, impossible exit targets and (7,7,7) in TestLevel, far respawn nodes, several player starts | | Copy / paste between levels | Edit → Copy objects (Ctrl+Shift+C) puts JSON (objects + chains + brushes, relative to their centre) on the system clipboard; Paste objects (Ctrl+Shift+V) at the view centre in any level – types are matched by name/variant in the target base (unknown types skipped with a message) | | Prefabs | Tools → Save selection as prefab… / asset browser tab "Prefabs": `/editor/prefabs/.json` + thumbnail; double-click = place at view centre, drag = place under the cursor | | Delete | `edit.delete` is re-registered: a selection of waypoints is removed with the chain relinked | @@ -32,14 +32,17 @@ repair action guard it). ## Files `ObjectsEditorPlugin` (registration, actions, waypoint inspector), `Schemas`/`ParamSchema`/ -`TypeStats` (+ `type-stats.tsv`), `ParamsSection`, `ObjectChecks`, `PlaceTool`, `Setups`, +`TypeStats` (+ `type-stats.tsv`), `ParamsSection`, `ObjectChecks`, `Guards`, `EventBits`, `PlaceTool`, `Setups`, `ExitTarget`, `CoinTool`, `PathTool`, `ChainOps`, `ZoneTool`, `ObjectOverlays`, `CameraView` (zone camera + pilot), `ObjectPalette`, `ObjectClipboard`, `Prefabs`, `LinkDialog`, `Ground` (floor finder on the collision polygons of tiles + brush preview tiles), `Session`, `Roles`, `Draw` (all game↔render conversions in one place). -Shared change: `editor.ui.view.SceneRenderer.brushOutput()` getter (brush preview tiles for the -floor finder). Nothing else outside the package. +Shared changes: `editor.ui.view.SceneRenderer.brushOutput()` getter (brush preview tiles for the +floor finder); `editor.model.WaypointBoxes` + box setup in `Edits.placeStrat`; +`editor.model.cmd.PlayerStart` (also used by the shell's new-level templates); `editor.ui.TypeMenus` +(object type context menu, used by Assets → Objects and the Objects panel; the objects plugin adds +Edit type… / Delete type… for custom types through `editor.tools.strats.CustomTypes`). ## Verified @@ -65,6 +68,15 @@ JumpingBeetleBlue with a 4-node patrol path, CityGuard with two boxes): → tally "Dorf Kapitel 1" at once (`obj_game_village_door.jpg`); at the door's own position 42.5/3.5/49.8 or in the original (door closed) → nothing. +* (2026-11-07, objs2, display :25, TestLevel on village 2 / village 1 / t1l4m002): old player + setup on village 2 = camera in the llama's head until it moves, new setup = camera behind it + from the start (`obj_game_startcam_v2.jpg`); a CityGuard added through the Add-object path + walks in its box; an Alligator added the same way appears and snaps (`obj_game_alligator.jpg`); + original village 1 with the start moved and turned (240°): the fly-by plays at the new start and + ends behind the llama (`obj_game_village1_moved_start.jpg`). Editor: type menu in Assets → + Objects / Objects panel (`obj_type_menu.jpg`, `obj_palette_type_menu.jpg`), event-bit info + (`obj_event_bit_info.jpg`, `obj_guard_event_inspector.jpg`). + ## Known limits * Parameters are decoded for ~35 common types (confidence per type in the schema doc); the rest @@ -77,8 +89,10 @@ JumpingBeetleBlue with a 4-node patrol path, CityGuard with two boxes): loader exactly (`Tile.cellX/cellZ` = ftol(x − 0.5), −ftol(z + 0.5)); `ObjectChecks` tests the door cell like the engine (tiles listed in the cell, any height, swap models), same as `GeometryChecks`. Tests: `GuardsTest`, `OriginalExitsTest`, `OriginalStartsTest`. -* Snap-to-floor (`Ground`) is geometric: it finds polygons that reach into a cell from a - neighbouring tile, which the engine ignores (`FloorFinder` follows the engine for the player). +* Snap-to-floor (`Ground`) follows the engine's floor test (FUN_004036d0, used for strats too): + only tiles listed in the point's cell; then their swap models; a neighbour tile's polygon only + where the cell has no floor at all (`Hit.source`). `GroundTest` compares it with `FloorFinder` + on three originals. The Place tool drag moves only a player start's nodes within 2.5 cells. * Zone heights are estimated (+1.2 above the lower node); the engine tests x/z only. * Camera preview of CameraSide assumes the player in the box centre and default distance 2 / height 1 when the parameters are 0; the game's FOV is approximated by 60°. diff --git a/notes/editor-ui.md b/notes/editor-ui.md index 1026a8f..387e9fe 100644 --- a/notes/editor-ui.md +++ b/notes/editor-ui.md @@ -158,7 +158,7 @@ public; invisible tile instances are no longer baked into chunks; `History.undoL * **New level**: dialog (name, author, base level by name with its loading picture, TestLevel chapter = next free one, template Empty / Flat floor / Island). Templates add brushes, a Llama - (camera node 8 cells behind, as levelbuild) and a LevelDoor on a material-16 floor. + (one start camera node 2 cells behind, `PlayerStart`) and a LevelDoor on a material-16 floor. * **Open original level**: list "Village – Chapter 1" … "Lab – Chapter 5" (+ unused/front-end WADs) with the CD loading pictures (`OriginalLevels`, `Cor`). * **Save** (`.llamalevel`, default folder `/levels` = listed as mods in LlamaMod), Save as, Open diff --git a/notes/img/editor/obj_event_bit_info.jpg b/notes/img/editor/obj_event_bit_info.jpg new file mode 100644 index 0000000..8f25315 Binary files /dev/null and b/notes/img/editor/obj_event_bit_info.jpg differ diff --git a/notes/img/editor/obj_game_alligator.jpg b/notes/img/editor/obj_game_alligator.jpg new file mode 100644 index 0000000..f2f2c89 Binary files /dev/null and b/notes/img/editor/obj_game_alligator.jpg differ diff --git a/notes/img/editor/obj_game_startcam_v2.jpg b/notes/img/editor/obj_game_startcam_v2.jpg new file mode 100644 index 0000000..cf7ffcf Binary files /dev/null and b/notes/img/editor/obj_game_startcam_v2.jpg differ diff --git a/notes/img/editor/obj_game_village1_moved_start.jpg b/notes/img/editor/obj_game_village1_moved_start.jpg new file mode 100644 index 0000000..e27a95b Binary files /dev/null and b/notes/img/editor/obj_game_village1_moved_start.jpg differ diff --git a/notes/img/editor/obj_guard_event_inspector.jpg b/notes/img/editor/obj_guard_event_inspector.jpg new file mode 100644 index 0000000..e6874f6 Binary files /dev/null and b/notes/img/editor/obj_guard_event_inspector.jpg differ diff --git a/notes/img/editor/obj_palette_type_menu.jpg b/notes/img/editor/obj_palette_type_menu.jpg new file mode 100644 index 0000000..d8873b2 Binary files /dev/null and b/notes/img/editor/obj_palette_type_menu.jpg differ diff --git a/notes/img/editor/obj_type_menu.jpg b/notes/img/editor/obj_type_menu.jpg new file mode 100644 index 0000000..c39b682 Binary files /dev/null and b/notes/img/editor/obj_type_menu.jpg differ diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/WaypointBoxes.java b/src/main/java/com/eliasmueller/llamamod/editor/model/WaypointBoxes.java new file mode 100644 index 0000000..3fb6ceb --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/WaypointBoxes.java @@ -0,0 +1,46 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Object types whose strat code reads their waypoints as walking areas: x/z boxes of node pairs + * (notes/editor-object-schemas.md "Guards", "Alligators"). A newly placed one gets a single box + * around it instead of the prototype's boxes (which belong to the prototype's position). + */ +public final class WaypointBoxes { + /** The guard template (CityGuard family): start box must have field unk2 = 0, p2 = event bit. */ + public static final List GUARDS = List.of( + "CityGuard", + "CityGuardWithRubyIdol", + "CowGuard", + "CowGuardNoCoin", + "GorillaGuard", + "LizardGuard", + "LizardGuardNoCoin", + "WarthogGuard"); + + /** Other box walkers: spawners hand their chain to the spawned guard / alligator. */ + public static final List OTHERS = + List.of("CowGuardSpawner", "CowGuardSpawned", "Alligator", "AlligatorSpawner", "SpawnedAlligator"); + + /** Parameter index of a guard's event bit. */ + public static final int GUARD_EVENT_PARAM = 2; + + /** Half size of the box a newly placed box walker gets (cells). */ + public static final double NEW_BOX_HALF = 2.0; + + private WaypointBoxes() {} + + /** Type name without the "(spawn)" suffix of code-only types. */ + public static String base(String type) { + return type != null && type.endsWith("(spawn)") ? type.substring(0, type.length() - 7) : type; + } + + public static boolean isGuard(String type) { + return type != null && GUARDS.contains(base(type)); + } + + public static boolean walksBoxes(String type) { + return isGuard(type) || type != null && OTHERS.contains(base(type)); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java index 8c5aa6a..2deebe8 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java @@ -12,6 +12,7 @@ import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; import com.eliasmueller.llamamod.editor.model.StratCatalog; import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.WaypointBoxes; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -105,7 +106,10 @@ public final class Edits { /** * Places a new object of a catalog type: type fields and default parameters from the * prototype, its waypoint chain copied relative to the new position ("relative" mode of - * levelbuild, without snapping to the ground). + * levelbuild, without snapping to the ground). Box walkers ({@link WaypointBoxes}: guards, + * alligators, their spawners) get one box of 2 nodes around the new position instead (the game + * removes a guard that does not start inside a box), and guards event bit 0 (active from the + * start). */ public static Command placeStrat(Scene s, String type, int variant, double x, double y, double z, Double yawDeg) { StratCatalog.Type t = s.catalog().type(type); @@ -119,7 +123,23 @@ public final class Edits { int fy = fixed(y); int fz = fixed(z); List chain = new ArrayList<>(); - for (int[] c : v.chain()) { + List proto = v.chain(); + boolean boxes = WaypointBoxes.walksBoxes(type); + if (boxes) { + int h = fixed(WaypointBoxes.NEW_BOX_HALF); + int[] a = proto.size() > 0 ? proto.get(0).clone() : new int[9]; + int[] b = proto.size() > 1 ? proto.get(1).clone() : new int[9]; + a[0] = -h; + a[1] = 0; + a[2] = -h; + b[0] = h; + b[1] = 0; + b[2] = h; + a[7] = 0; + b[7] = 0; + proto = List.of(a, b); + } + for (int[] c : proto) { PathNode p = (PathNode) s.create(NodeKind.PATH_NODE); p.setRaw("x", fx + c[0]); p.setRaw("y", fy + c[1]); @@ -148,7 +168,11 @@ public final class Edits { st.setRaw("x", fx); st.setRaw("y", fy); st.setRaw("z", fz); - st.setRaw("params", v.params().clone()); + int[] params = v.params().clone(); + if (boxes && WaypointBoxes.isGuard(type) && params.length > WaypointBoxes.GUARD_EVENT_PARAM) { + params[WaypointBoxes.GUARD_EVENT_PARAM] = 0; + } + st.setRaw("params", params); st.setRaw("waypoint", chain.isEmpty() ? null : chain.getFirst()); st.setTypeName(type); cmds.add(new AddNode(st)); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/PlayerStart.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/PlayerStart.java new file mode 100644 index 0000000..25c504b --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/PlayerStart.java @@ -0,0 +1,151 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Player start (Llama) and its start camera. The Llama's waypoints are camera start positions + * (Camera strat, L_1330 at level start): the first node within {@value #RANGE} cells (x/z) of the + * llama becomes the camera position, looking at the llama; if the node's field unk2 ≠ 0 its x/z + * distance and height to the llama also become the camera distance/height (else the defaults 1.0 + * / 0.5), held until the llama moves. Original levels have one node per entry point (doors), so a + * prototype chain copied onto the llama puts the camera into its head (village 2: node 1 has unk2 + * ≠ 0) or makes it look along +z (village 1: zero distance, no direction). + */ +public final class PlayerStart { + /** Range of the camera start search (cells, x/z). */ + public static final double RANGE = 2.5; + + /** Camera node of a new player start: this far behind the llama, at default height. */ + public static final double BACK = 2.0; + + public static final double UP = 0.5; + + /** CutSceneTrigger parameter 6: bits 1 = end camera behind the player, 2 = not skippable. */ + static final int TRIGGER_FLAGS = 6; + + private PlayerStart() {} + + /** Index of the node the game takes as start camera, or −1 (no node within range). */ + public static int startNode(List chain, double x, double z) { + for (int i = 0; i < chain.size(); i++) { + PathNode p = chain.get(i); + if (Math.hypot(p.gx() - x, p.gz() - z) < RANGE) { + return i; + } + } + return -1; + } + + /** Places a Llama with a single camera node {@link #BACK} behind it (unk2 = 0: default camera). */ + public static Command place(Scene s, double x, double y, double z, double yawDeg) { + Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, x, y, z, yawDeg); + List cmds = new ArrayList<>(); + PathNode first = null; + for (Command c : b.commands()) { + if (c instanceof AddNode a && a.node() instanceof PathNode p) { + if (first != null) { + continue; + } + first = p; + } + cmds.add(c); + } + if (first == null) { + // a Llama prototype without chain: give it one + first = (PathNode) s.create(NodeKind.PATH_NODE); + cmds.add(0, new AddNode(first)); + for (Command c : cmds) { + if (c instanceof AddNode a && a.node() instanceof Strat st) { + st.setRaw("waypoint", first); + } + } + } + cameraNode(first, x, y, z, yawDeg); + first.setRaw("next", null); + return new Batch(b.label(), cmds); + } + + private static void cameraNode(PathNode p, double x, double y, double z, double yawDeg) { + double a = Math.toRadians(yawDeg); + p.setRaw("x", Edits.fixed(x - BACK * Math.sin(a))); + p.setRaw("y", Edits.fixed(y + UP)); + p.setRaw("z", Edits.fixed(z - BACK * Math.cos(a))); + p.setRaw("ry", Edits.angleUnits(yawDeg)); + p.setRaw("unk2", 0); + } + + /** + * Moves the existing player start. Camera nodes within range of the old start move (and turn) + * with it, the others (entry points of doors) stay; so does a CutSceneTrigger at the old start + * (the base level's intro fly-by), set to end behind the llama – otherwise the fly-by ends with + * the camera at the old start, where it stays until the llama moves. If then no node is in range of the new start, + * a camera node {@link #BACK} behind it is appended to the chain. + */ + public static Command move(Scene s, Strat st, double x, double y, double z, double yawDeg) { + Map> v = new LinkedHashMap<>(); + v.put( + st, + Map.of("x", Edits.fixed(x), "y", Edits.fixed(y), "z", Edits.fixed(z), "ry", Edits.angleUnits(yawDeg))); + List chain = st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of(); + double turn = Math.toRadians(yawDeg - st.yawDegrees()); + double c = Math.cos(turn), sn = Math.sin(turn); + int turnUnits = Edits.angleUnits(yawDeg - st.yawDegrees()); + double ox = st.gx(), oy = st.gy(), oz = st.gz(); + java.util.function.BiConsumer carry = (n, g) -> { + double rx = g[0] - ox, rz = g[2] - oz; + Map m = new LinkedHashMap<>(); + m.put("x", Edits.fixed(x + rx * c + rz * sn)); + m.put("y", Edits.fixed(g[1] + y - oy)); + m.put("z", Edits.fixed(z - rx * sn + rz * c)); + m.put("ry", (g[3] < 0 ? 0 : (int) g[3] + turnUnits) & 0xfff); + v.put(n, m); + }; + boolean inRange = false; + for (PathNode p : chain) { + if (Math.hypot(p.gx() - ox, p.gz() - oz) < RANGE) { + carry.accept(p, new double[] {p.gx(), p.gy(), p.gz(), p.ry()}); + inRange = true; + } else if (Math.hypot(p.gx() - x, p.gz() - z) < RANGE) { + inRange = true; + } + } + // the base level's intro fly-by (village 1/2: a CutSceneTrigger on the start, radius 0.7): it + // moves along, and flag bit 0 (p6) makes the game end the fly-by behind the llama instead of + // at its last node (placed for the old start) + for (Strat o : s.strats()) { + if (!o.type().equals("CutSceneTrigger") || Math.hypot(o.gx() - ox, o.gz() - oz) >= RANGE) { + continue; + } + carry.accept(o, new double[] {o.gx(), o.gy(), o.gz(), o.ry()}); + int[] prm = o.params().clone(); + if (prm.length > TRIGGER_FLAGS) { + prm[TRIGGER_FLAGS] = ((prm[TRIGGER_FLAGS] >> 12) | 1) << 12; + v.get(o).put("params", prm); + } + } + Command set = Edits.set("Move player start", v, null); + if (inRange) { + return set; + } + PathNode fresh = (PathNode) s.create(NodeKind.PATH_NODE); + cameraNode(fresh, x, y, z, yawDeg); + List cmds = new ArrayList<>(List.of(set)); + if (chain.isEmpty()) { + cmds.add(new AddNode(fresh)); + cmds.add(SetProp.of(st, "waypoint", fresh)); + } else { + PathNode last = chain.getLast(); + cmds.add(new AddNode(fresh, s.pathNodes().indexOf(last) + 1)); + cmds.add(SetProp.of(last, "next", fresh)); + } + return new Batch("Move player start", cmds); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/EventBits.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/EventBits.java new file mode 100644 index 0000000..896622c --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/EventBits.java @@ -0,0 +1,82 @@ +package com.eliasmueller.llamamod.editor.tools.objects; + +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.WaypointBoxes; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Event (progress) bits of global 95: objects that set a bit with {@code g95 |= 1 << (v − 1)} where + * v is one of their parameters (found by a sweep over the strat code of all original levels, + * notes/editor-object-schemas.md "Event bits"). Guards with an event bit wait for it. + */ +final class EventBits { + /** Setter type → parameter indices holding the bit number (1..32, 0 = none). */ + static final Map SETTERS = new LinkedHashMap<>(); + + static { + SETTERS.put("TipoVillage", new int[] {6}); + SETTERS.put("ChacaVillage", new int[] {6}); + SETTERS.put("SmashStatueSmall", new int[] {0}); + SETTERS.put("SmashStatueBig", new int[] {0}); + SETTERS.put("SmashStatueDoor", new int[] {0}); + SETTERS.put("RiverCrocGame", new int[] {1}); + SETTERS.put("RiverTriggerBox", new int[] {0}); + SETTERS.put("KidOnCanoe", new int[] {4}); + SETTERS.put("YzmaRiver", new int[] {2, 6}); + SETTERS.put("Condor", new int[] {2}); + SETTERS.put("GoldIdol", new int[] {1}); + SETTERS.put("CityWall", new int[] {4}); + SETTERS.put("NakedPeacock", new int[] {0}); + SETTERS.put("LabSlidingDoor", new int[] {5}); + SETTERS.put("LabSlidingDoorJamie", new int[] {5}); + SETTERS.put("LabBigCutscene", new int[] {1}); + SETTERS.put("LabWall", new int[] {4}); + SETTERS.put("EndGameTriggerBox", new int[] {0}); + } + + private EventBits() {} + + /** Bits (1..32) the objects of the level can set. */ + static Set setBits(Scene s) { + Set out = new TreeSet<>(); + for (Strat st : s.strats()) { + int[] idx = SETTERS.get(WaypointBoxes.base(st.type())); + if (idx == null) { + continue; + } + for (int i : idx) { + if (i < st.params().length) { + int b = st.params()[i] >> 12; + if (b > 0 && b <= 32) { + out.add(b); + } + } + } + } + return out; + } + + /** + * "TipoVillage (parameter 6), …": the setter types placeable in this level, or all known + * setters marked as not in this base. + */ + static String setterList(Scene s) { + StringBuilder b = new StringBuilder(); + SETTERS.forEach((t, idx) -> { + if (s.catalog().has(t)) { + b.append(b.isEmpty() ? "" : ", ") + .append(t) + .append(" (parameter ") + .append(idx[0]) + .append(')'); + } + }); + return b.isEmpty() + ? "none of the setter types (" + String.join(", ", SETTERS.keySet()) + ") is in this base level" + : "set by " + b; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Ground.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Ground.java index 7b631de..9972601 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Ground.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Ground.java @@ -12,6 +12,12 @@ import java.util.Map; /** * Floor finder on the collision polygons of all tiles (original tiles and the tiles generated from * brushes), in game space (x east, y up, z = grid row). Cached per scene revision. + * + *

Follows the engine's floor test (FUN_004036d0, used for the player and for objects): only the + * tiles listed in the grid cell of the point count ({@link Tile#cellX}/{@link Tile#cellZ}), so a + * polygon reaching into the cell from a neighbouring tile carries nothing there. If no such tile + * has a floor at the point, the swap models of the cell's tiles (a door object may switch the tile + * to them) are tried, and last the neighbouring tiles' polygons (where the game finds no floor). */ public final class Ground { /** A floor polygon (normal up) in game space with its collision material. */ @@ -35,10 +41,23 @@ public final class Ground { } } - /** Result of a query: floor height and material. */ - public record Hit(double y, int material) {} + /** Where a floor came from: the cell's own tiles (as in game), their swap models, a neighbour tile. */ + public enum Source { + CELL, + SWAP, + NEIGHBOUR + } + + /** Result of a query: floor height, material and source. */ + public record Hit(double y, int material, Source source) { + public Hit(double y, int material) { + this(y, material, Source.CELL); + } + } private final Map> cells = new HashMap<>(); + private final Map> swaps = new HashMap<>(); + private final Map> overlaps = new HashMap<>(); private Ground() {} @@ -56,19 +75,11 @@ public final class Ground { } private void addTile(Tile t) { - for (TileModel.PlacedPolygon cp : t.worldCollisionPolygons()) { - double[][] pts = new double[cp.corners().length][]; - for (int i = 0; i < pts.length; i++) { - double[] w = cp.corners()[i]; - pts[i] = new double[] {w[0], w[1], -w[2]}; - } - double[] pl = plane(pts); - if (pl == null || pl[1] < 0.5) { - continue; - } - Floor f = new Floor(pts, pl, cp.material()); + long own = key(t.cellX(), t.cellZ()); + for (Floor f : floors(t.worldCollisionPolygons())) { + cells.computeIfAbsent(own, q -> new ArrayList<>()).add(f); double x0 = Double.MAX_VALUE, z0 = Double.MAX_VALUE, x1 = -Double.MAX_VALUE, z1 = -Double.MAX_VALUE; - for (double[] p : pts) { + for (double[] p : f.pts) { x0 = Math.min(x0, p[0]); x1 = Math.max(x1, p[0]); z0 = Math.min(z0, p[2]); @@ -76,10 +87,37 @@ public final class Ground { } for (int cx = (int) Math.floor(x0); cx <= (int) Math.floor(x1); cx++) { for (int cz = (int) Math.floor(z0); cz <= (int) Math.floor(z1); cz++) { - cells.computeIfAbsent(key(cx, cz), q -> new ArrayList<>()).add(f); + if (key(cx, cz) != own) { + overlaps.computeIfAbsent(key(cx, cz), q -> new ArrayList<>()) + .add(f); + } } } } + if (t.hasSwap() && !t.noCollision()) { + for (TileModel m : new TileModel[] {t.swapModel(), t.swapAltModel()}) { + if (m != null) { + swaps.computeIfAbsent(own, q -> new ArrayList<>()) + .addAll(floors(m.collisionPolygons(t.transform()))); + } + } + } + } + + private static List floors(List polys) { + List out = new ArrayList<>(); + for (TileModel.PlacedPolygon cp : polys) { + double[][] pts = new double[cp.corners().length][]; + for (int i = 0; i < pts.length; i++) { + double[] w = cp.corners()[i]; + pts[i] = new double[] {w[0], w[1], -w[2]}; + } + double[] pl = plane(pts); + if (pl != null && pl[1] >= 0.5) { + out.add(new Floor(pts, pl, cp.material())); + } + } + return out; } private static double[] plane(double[][] p) { @@ -108,10 +146,27 @@ public final class Ground { /** * Highest floor at (x, z) not more than {@code above} over {@code probeY} (game space); with - * {@code probeY} = NaN the highest floor at all. Null if there is none. + * {@code probeY} = NaN the highest floor at all. Null if there is none. The cell's own tiles + * first (what the game uses), then their swap models, then polygons of neighbouring tiles. */ public Hit below(double x, double z, double probeY, double above) { - List l = cells.get(key((int) Math.floor(x), (int) Math.floor(z))); + long k = key((int) Math.floor(x), (int) Math.floor(z)); + Hit h = best(cells.get(k), Source.CELL, x, z, probeY, above); + if (h == null) { + h = best(swaps.get(k), Source.SWAP, x, z, probeY, above); + } + if (h == null) { + h = best(overlaps.get(k), Source.NEIGHBOUR, x, z, probeY, above); + } + return h; + } + + /** Highest polygon of a neighbouring tile reaching into the cell (what the game ignores there). */ + Hit neighbourBelow(double x, double z, double probeY, double above) { + return best(overlaps.get(key((int) Math.floor(x), (int) Math.floor(z))), Source.NEIGHBOUR, x, z, probeY, above); + } + + private static Hit best(List l, Source src, double x, double z, double probeY, double above) { if (l == null) { return null; } @@ -125,21 +180,22 @@ public final class Ground { continue; } if (best == null || y > best.y) { - best = new Hit(y, f.material); + best = new Hit(y, f.material, src); } } return best; } - /** True if a floor polygon with the material covers any part of the cell (cx, cz). */ + /** + * True if a tile listed in the cell (cx, cz) has a floor polygon with the material, in its + * model or a swap model (the engine's LevelDoor test: any height). + */ public boolean hasMaterial(int cx, int cz, int material) { - List l = cells.get(key(cx, cz)); - if (l == null) { - return false; - } - for (Floor f : l) { - if (f.material == material) { - return true; + for (Map> m : List.of(cells, swaps)) { + for (Floor f : m.getOrDefault(key(cx, cz), List.of())) { + if (f.material == material) { + return true; + } } } return false; diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Guards.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Guards.java index 5e7af91..8c4f15c 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Guards.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Guards.java @@ -3,46 +3,63 @@ package com.eliasmueller.llamamod.editor.tools.objects; import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.WaypointBoxes; import java.util.List; /** - * Guard family (CityGuard, CityGuardWithRubyIdol, CowGuard, GorillaGuard, …: one compiled template). - * Strat code at start: the waypoints are walked in pairs; an odd count ("Uneven number of WPs") or a - * start position outside every pair's x/z box whose first node has field unk2 = 0 ("Guard not in WP - * box") removes the guard before it is ever drawn. Parameter 2 > 0 keeps it hidden until progress - * bit p2 − 1 of global 95 is set; 0 = active as soon as the player exists. + * Objects whose waypoints are walking areas (x/z boxes of node pairs, {@link WaypointBoxes}). + * + *

    + *
  • Guard family (CityGuard, CowGuard, …: one compiled template) and CowGuardSpawned: an odd + * count ("Uneven number of WPs") or a start position outside every pair's box whose first + * node has field unk2 = 0 ("Guard not in WP box") removes the guard before it is drawn. + * Guards: parameter 2 > 0 keeps it hidden until progress bit p2 − 1 of global 95 is set. + *
  • CowGuardSpawner: spawns CowGuardSpawned at its own position with its chain – the same rule + * applies to the spawner's position, else every spawned guard is removed at once. + *
  • Alligator, SpawnedAlligator: odd count ("Odd number of WPs!") removes it; outside every box + * it is clamped into the first box ("Forcing alligator inside first WP box"). No unk2 rule. + *
  • AlligatorSpawner: the spawned alligator gets the spawner's chain; outside the first box it + * walks straight ahead (spawner yaw) until it is inside that box. + *
*/ final class Guards { - static final List TYPES = List.of( - "CityGuard", - "CityGuardWithRubyIdol", - "CowGuard", - "CowGuardNoCoin", - "GorillaGuard", - "LizardGuard", - "LizardGuardNoCoin", - "WarthogGuard"); + static final List TYPES = WaypointBoxes.GUARDS; - static final int EVENT_PARAM = 2; - - /** Half size of the box a newly placed guard gets (cells). */ - static final double NEW_BOX_HALF = 2.0; + static final int EVENT_PARAM = WaypointBoxes.GUARD_EVENT_PARAM; private Guards() {} + /** Guard family (has the event bit parameter). */ static boolean is(String type) { - return type != null && TYPES.contains(type.endsWith("(spawn)") ? type.substring(0, type.length() - 7) : type); + return WaypointBoxes.isGuard(type); + } + + /** Any object whose waypoints are box pairs (guards, cow guard spawner, alligators). */ + static boolean walksBoxes(String type) { + return WaypointBoxes.walksBoxes(type); + } + + private static boolean guardRule(String type) { + String b = WaypointBoxes.base(type); + return is(type) || b.equals("CowGuardSpawner") || b.equals("CowGuardSpawned"); + } + + private static boolean inBox(PathNode a, PathNode b, double x, double z) { + return x >= Math.min(a.x(), b.x()) + && x <= Math.max(a.x(), b.x()) + && z >= Math.min(a.z(), b.z()) + && z <= Math.max(a.z(), b.z()); } /** Index of the first node of the start box (pair), or −1 if the guard starts in none. */ static int startBox(List chain, Strat st) { + return startBox(chain, st, true); + } + + private static int startBox(List chain, Strat st, boolean unk2Rule) { for (int k = 0; k + 1 < chain.size(); k += 2) { PathNode a = chain.get(k), b = chain.get(k + 1); - if (a.unk2() == 0 - && st.x() >= Math.min(a.x(), b.x()) - && st.x() <= Math.max(a.x(), b.x()) - && st.z() >= Math.min(a.z(), b.z()) - && st.z() <= Math.max(a.z(), b.z())) { + if ((!unk2Rule || a.unk2() == 0) && inBox(a, b, st.x(), st.z())) { return k; } } @@ -52,21 +69,70 @@ final class Guards { /** Event bit (0 = none) of a guard. */ static int eventBit(Strat st) { int[] p = st.params(); - return p.length > EVENT_PARAM ? p[EVENT_PARAM] >> 12 : 0; + return is(st.type()) && p.length > EVENT_PARAM ? p[EVENT_PARAM] >> 12 : 0; } - /** Why the game removes the guard at level start, or null if it stays. */ + static List chain(Scene s, Strat st) { + return st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of(); + } + + /** Why the game removes the object (or, for a spawner, what it spawns) at once, or null. */ static String removedBecause(Scene s, Strat st) { - List chain = st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of(); + List chain = chain(s, st); if (chain.isEmpty()) { return "it has no waypoint boxes"; } if (chain.size() % 2 == 1) { return "its waypoint count is odd (" + chain.size() + "; boxes are node pairs)"; } - if (startBox(chain, st) < 0) { + if (guardRule(st.type()) && startBox(chain, st, true) < 0) { return "it does not start inside one of its waypoint boxes"; } return null; } + + /** + * A softer start problem of alligators (null if none): an Alligator outside all boxes is moved + * into the first box; an AlligatorSpawner's alligators walk ahead and may never reach it. + */ + static String startWarning(Scene s, Strat st) { + String b = WaypointBoxes.base(st.type()); + List chain = chain(s, st); + if (chain.size() < 2 || chain.size() % 2 == 1) { + return null; + } + if ((b.equals("Alligator") || b.equals("SpawnedAlligator")) && startBox(chain, st, false) < 0) { + return "it does not start inside a box: the game moves it into the first box"; + } + if (b.equals("AlligatorSpawner")) { + PathNode p = chain.get(0), q = chain.get(1); + if (!inBox(p, q, st.x(), st.z()) && !rayHitsBox(st, p, q)) { + return "its alligators walk straight ahead until they are inside the first box, which is not" + + " in front of it (turn it towards the box)"; + } + } + return null; + } + + /** Whether the ray from the object along its yaw crosses the x/z box of the two nodes. */ + static boolean rayHitsBox(Strat st, PathNode a, PathNode b) { + double yaw = Math.toRadians(st.yawDegrees()); + double dx = Math.sin(yaw), dz = Math.cos(yaw); + double x0 = Math.min(a.gx(), b.gx()), x1 = Math.max(a.gx(), b.gx()); + double z0 = Math.min(a.gz(), b.gz()), z1 = Math.max(a.gz(), b.gz()); + double t0 = 0, t1 = 1e9; + double[][] axes = {{st.gx(), dx, x0, x1}, {st.gz(), dz, z0, z1}}; + for (double[] ax : axes) { + if (Math.abs(ax[1]) < 1e-9) { + if (ax[0] < ax[2] || ax[0] > ax[3]) { + return false; + } + } else { + double ta = (ax[2] - ax[0]) / ax[1], tb = (ax[3] - ax[0]) / ax[1]; + t0 = Math.max(t0, Math.min(ta, tb)); + t1 = Math.min(t1, Math.max(ta, tb)); + } + } + return t0 <= t1; + } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectChecks.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectChecks.java index 5aa9f67..5a02396 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectChecks.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectChecks.java @@ -6,6 +6,7 @@ import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; import com.eliasmueller.llamamod.editor.model.StratCatalog; import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart; import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.ParamDef; import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.TypeSchema; import com.eliasmueller.llamamod.editor.ui.LevelCheck; @@ -13,6 +14,8 @@ import com.eliasmueller.llamamod.editor.ui.Problem; import com.eliasmueller.llamamod.save.Worlds; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Set; import java.util.function.Supplier; /** Problems panel checks for objects, parameters and waypoint chains. */ @@ -33,11 +36,17 @@ final class ObjectChecks implements LevelCheck { broken)); } List doorTiles = null; + Set setBits = null; int players = 0; for (Strat st : s.strats()) { String type = st.type(); if ("player".equals(StratCatalog.category(type))) { players++; + String cam = startCamera(s, st); + if (cam != null) { + out.add(Problem.warning( + label(st) + ": " + cam + " (Place tool → Player start sets a camera node)", st)); + } } TypeSchema sc = Schemas.of(type); TypeStats.Stat stat = TypeStats.of(type); @@ -57,19 +66,38 @@ final class ObjectChecks implements LevelCheck { } } } - String gone = Guards.is(type) ? Guards.removedBecause(s, st) : null; - if (gone != null) { + String gone = Guards.walksBoxes(type) ? Guards.removedBecause(s, st) : null; + String spawned = type.equals("CowGuardSpawner") + ? "guards" + : type.equals("AlligatorSpawner") ? "alligators" : null; + if (gone != null && spawned != null) { + out.add(Problem.warning( + label(st) + ": the game removes its " + spawned + " as soon as they spawn because " + gone + + " (Waypoint tool: give it a box of 2 nodes around its position)", + st)); + } else if (gone != null) { out.add(Problem.error( label(st) + " never appears: the game removes it at level start because " + gone + " (Waypoint tool: give it a box of 2 nodes around its position)", st)); - } else if (Guards.is(type) && Guards.eventBit(st) > 0) { + } else if (Guards.startWarning(s, st) != null) { + out.add(Problem.warning(label(st) + ": " + Guards.startWarning(s, st), st)); + } + if (gone == null && Guards.eventBit(st) > 0) { + int bit = Guards.eventBit(st); + if (setBits == null) { + setBits = EventBits.setBits(s); + } out.add(Problem.info( - label(st) + " stays hidden until event bit " + Guards.eventBit(st) - + " is set by another object; set Event bit to 0 to have it active from the start", + label(st) + " stays hidden until event bit " + bit + " is set by another object" + + (setBits.contains(bit) + ? "" + : ", but no object in this level sets bit " + bit + " (" + + EventBits.setterList(s) + ")") + + "; set Event bit to 0 to have it active from the start", st)); } - if (!Guards.is(type) + if (!Guards.walksBoxes(type) && sc.waypoints().required() && chain.size() < sc.waypoints().min()) { out.add(Problem.warning( @@ -83,7 +111,7 @@ final class ObjectChecks implements LevelCheck { + chain.size(), st)); } - if (sc.waypoints().pairs() && chain.size() % 2 == 1 && !Guards.is(type)) { + if (sc.waypoints().pairs() && chain.size() % 2 == 1 && !Guards.walksBoxes(type)) { out.add(Problem.warning( label(st) + ": box nodes come in pairs, the chain has " + chain.size(), st)); } @@ -159,6 +187,28 @@ final class ObjectChecks implements LevelCheck { return false; } + /** Start camera problem of a player start (see {@link PlayerStart}), or null. */ + static String startCamera(Scene s, Strat st) { + List chain = st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of(); + int k = PlayerStart.startNode(chain, st.gx(), st.gz()); + if (k < 0) { + return null; + } + PathNode p = chain.get(k); + double d = Math.hypot(p.gx() - st.gx(), p.gz() - st.gz()); + if (p.unk2() != 0 && d < 0.5) { + return String.format( + Locale.ROOT, + "the start camera is inside the llama's head – waypoint %d is the camera start (first node within" + + " %.1f cells) and, with unk2 ≠ 0, its distance %.2f becomes the camera distance until the" + + " llama moves", + k + 1, + PlayerStart.RANGE, + d); + } + return null; + } + static String label(Strat s) { return s.type() + " (" + s.id() + ")"; } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPalette.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPalette.java index 31ee8e2..b15cc69 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPalette.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPalette.java @@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.EditorWindow; import com.eliasmueller.llamamod.editor.ui.Icons; import com.eliasmueller.llamamod.editor.ui.NodeIcons; +import com.eliasmueller.llamamod.editor.ui.TypeMenus; import com.eliasmueller.llamamod.editor.ui.view.Thumbnails; import com.eliasmueller.llamamod.util.SwingListeners; import java.awt.BorderLayout; @@ -36,7 +37,9 @@ import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JList; +import javax.swing.JMenuItem; import javax.swing.JPanel; +import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JTextField; import javax.swing.JToggleButton; @@ -144,22 +147,40 @@ final class ObjectPalette extends JPanel { list.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { - if (e.isPopupTrigger() || e.getButton() == MouseEvent.BUTTON3) { - int i = list.locationToIndex(e.getPoint()); - if (i >= 0) { - Session.of(ctx).toggleFavourite(model.get(i).type); - list.repaint(); - if (favOnly.isSelected()) { - reload(); - } - } + menu(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + menu(e); + } + + private void menu(MouseEvent e) { + int i = list.locationToIndex(e.getPoint()); + if (!e.isPopupTrigger() || i < 0) { + return; } + String type = model.get(i).type; + boolean fav = Session.of(ctx).favourites().contains(type); + JPopupMenu m = new JPopupMenu(); + JMenuItem f = new JMenuItem(fav ? "Remove from favourites" : "Add to favourites ★"); + f.addActionListener(a -> { + Session.of(ctx).toggleFavourite(type); + list.repaint(); + if (favOnly.isSelected()) { + reload(); + } + }); + m.add(f); + TypeMenus.fill(ctx, type, m); + m.show(list, e.getX(), e.getY()); } }); JScrollPane sp = new JScrollPane(list); sp.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, UIManager.getColor("Component.borderColor"))); add(sp, BorderLayout.CENTER); - JLabel hint = new JLabel("Click: place with the Place tool (P) · drag into the viewport · right-click: ★"); + JLabel hint = + new JLabel("Click: place with the Place tool (P) · drag into the viewport · right-click: ★, type menu"); hint.setFont(hint.getFont().deriveFont(10.5f)); hint.setForeground(UIManager.getColor("Label.disabledForeground")); hint.setBorder(BorderFactory.createEmptyBorder(3, 8, 4, 6)); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsEditorPlugin.java index 6fb4c37..ba0d5f7 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsEditorPlugin.java @@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.editor.model.Node; import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.tools.strats.CustomTypes; import com.eliasmueller.llamamod.editor.ui.ActionSpec; import com.eliasmueller.llamamod.editor.ui.AssetTab; import com.eliasmueller.llamamod.editor.ui.Dock; @@ -17,6 +18,7 @@ import com.eliasmueller.llamamod.editor.ui.LayerSpec; import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.PanelSpec; import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import com.eliasmueller.llamamod.editor.ui.TypeMenus; import com.eliasmueller.llamamod.editor.ui.tools.EditOps; import java.awt.FlowLayout; import java.awt.Toolkit; @@ -30,7 +32,9 @@ import java.util.function.Predicate; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JLabel; +import javax.swing.JMenuItem; import javax.swing.JPanel; +import javax.swing.JPopupMenu; import javax.swing.SwingUtilities; /** @@ -67,6 +71,7 @@ public final class ObjectsEditorPlugin implements EditorPlugin { x.renderContributor(new ObjectOverlays.Cameras()); x.check(new ObjectChecks(() -> null)); x.assetTab(new AssetTab("prefabs", "Prefabs", "assets", 60, Prefabs::tab)); + TypeMenus.register("objects.customType", ObjectsEditorPlugin::customTypeMenu); x.action(ActionSpec.of( "objects.copy", @@ -297,4 +302,17 @@ public final class ObjectsEditorPlugin implements EditorPlugin { return root; } } + + /** Edit type… / Delete type… for custom strat types (dialogs of editor.tools.strats). */ + static void customTypeMenu(EditorContext ctx, String type, JPopupMenu m) { + if (CustomTypes.index(ctx, type) < 0) { + return; + } + JMenuItem edit = new JMenuItem("Edit type…"); + edit.addActionListener(e -> CustomTypes.edit(ctx, type)); + JMenuItem del = new JMenuItem("Delete type…"); + del.addActionListener(e -> CustomTypes.delete(ctx, type)); + m.add(edit); + m.add(del); + } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ParamsSection.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ParamsSection.java index dad477d..47850a2 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ParamsSection.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ParamsSection.java @@ -134,7 +134,7 @@ final class ParamsSection implements InspectorSection { if (st.type().equals("LevelDoor") || st.type().equals("VillageEndDoor")) { exitRow(st); } - if (Guards.is(st.type())) { + if (Guards.walksBoxes(st.type())) { guardRow(st); } for (int i = 0; i < Math.max(n, sc != null ? sc.paramCount() : 0); i++) { @@ -330,14 +330,20 @@ final class ParamsSection implements InspectorSection { private void guardRow(Strat st) { Scene s = ctx.scene(); String gone = Guards.removedBecause(s, st); + String warn = gone == null ? Guards.startWarning(s, st) : null; int bit = Guards.eventBit(st); String txt = gone != null ? "Never appears: the game removes it at level start because " + gone + "." - : bit > 0 - ? "Starts inside its box. Hidden until event bit " + bit - + " is set by another object (0 = active from the start)." - : "Starts inside its box; active from the start."; - if (gone == null) { + : warn != null + ? "Note: " + warn + "." + : bit > 0 + ? "Starts inside its box. Hidden until event bit " + bit + + " is set by another object (0 = active from the start)" + + (EventBits.setBits(s).contains(bit) + ? "." + : "; no object in this level sets it.") + : "Starts inside its box; active from the start."; + if (gone == null && warn == null) { full(note(txt)); return; } @@ -351,7 +357,7 @@ final class ParamsSection implements InspectorSection { BorderLayout.CENTER); JButton fix = new JButton("Box around it"); fix.setFocusable(false); - fix.setToolTipText("Replace its waypoints with one 4×4 box around the guard"); + fix.setToolTipText("Replace its waypoints with one 4×4 box around it"); fix.addActionListener(e -> { ctx.execute(Setups.guardBox(s, st)); javax.swing.SwingUtilities.invokeLater(this::refresh); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PlaceTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PlaceTool.java index 2ca528a..e44c097 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PlaceTool.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PlaceTool.java @@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.model.StratCatalog; import com.eliasmueller.llamamod.editor.model.cmd.Batch; import com.eliasmueller.llamamod.editor.model.cmd.Command; import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart; import com.eliasmueller.llamamod.editor.render.Camera; import com.eliasmueller.llamamod.editor.render.Legend; import com.eliasmueller.llamamod.editor.render.Overlay; @@ -292,7 +293,13 @@ public final class PlaceTool implements Tool { List nodes = new ArrayList<>(); nodes.add(dragging); if (dragging.waypoint() != null && ctx.prefs().getBool("objects.moveWaypoints", true)) { - nodes.addAll(ctx.scene().chain(dragging.waypoint())); + for (PathNode n : ctx.scene().chain(dragging.waypoint())) { + // a player start's far nodes are camera starts of door entries: they stay + if (dragging != ctx.scene().playerStart() + || Math.hypot(n.gx() - dragging.gx(), n.gz() - dragging.gz()) < PlayerStart.RANGE) { + nodes.add(n); + } + } } ctx.execute(Edits.translate(nodes, dx, dy, dz, mergeKey)); ctx.repaintTool(); @@ -315,6 +322,20 @@ public final class PlaceTool implements Tool { "z", Edits.fixed(st.gz() + r * Math.cos(a)), "ry", Edits.angleUnits(deg))); } + if (st.scene() != null && st == st.scene().playerStart() && st.waypoint() != null) { + // the start camera node(s) turn around the llama + double t = Math.toRadians(deg - st.yawDegrees()), c = Math.cos(t), sn = Math.sin(t); + for (PathNode p : st.scene().chain(st.waypoint())) { + double rx = p.gx() - st.gx(), rz = p.gz() - st.gz(); + if (Math.hypot(rx, rz) < PlayerStart.RANGE) { + v.put( + p, + Map.of( + "x", Edits.fixed(st.gx() + rx * c + rz * sn), + "z", Edits.fixed(st.gz() - rx * sn + rz * c))); + } + } + } return Edits.set("Rotate " + st.type(), v, mergeKey); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Schemas.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Schemas.java index 728cbc5..ae515ed 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Schemas.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Schemas.java @@ -166,7 +166,10 @@ public final class Schemas { .add(); // ---------------------------------------------------------- player, checkpoints, exits - t("Llama", "Player start. The first node of its chain is the start position of the camera.") + t( + "Llama", + "Player start. Its waypoints are start camera positions: the first one within 2.5 cells" + + " of the llama becomes the camera at level start.") .p("Helper", Kind.BOOL, "on: spawns a helper strat at start (STPC 0x434e22 in the village)") .p("Unused", Kind.RAW, "not read by the Llama code") .choice( @@ -178,7 +181,10 @@ public final class Schemas { Role.CAMERA, 0, 0, - "first node = camera start (8 cells behind the llama); the others sit on the llama") + "camera starts (originals: one per entry door): at level start the first node within 2.5" + + " cells (x/z) of the llama is the camera position, looking at the llama; with unk2 ≠ 0" + + " also its distance and height (held until the llama moves – a node on the llama" + + " puts the camera into its head)") .add(); t( "CheckPointPost", @@ -363,6 +369,57 @@ public final class Schemas { .add(); } } + t( + "CowGuardSpawner", + "Spawns CowGuardSpawned guards at its position (they walk inside its waypoint boxes)" + + " while the player is more than 1.5 cells away.") + .p("View range", Kind.RADIUS, 0, 50, "handed on: the spawned guard's view range") + .p("Chase range", Kind.RADIUS, 0, 50, "handed on: the spawned guard's chase range") + .p("Max guards", Kind.COUNT, 0, 20, "spawns again only while fewer of its guards are alive") + .p("Interval", Kind.SECONDS, 0, 120, "wait between spawns") + .p("p4", Kind.RAW, "not read") + .p("p5", Kind.FIXED, "handed on as the guard's parameter 3, which CowGuardSpawned does not read") + .wpPairs( + Role.BOX, + 2, + "walking areas of the spawned guards (they get this chain): boxes of node pairs; the" + + " spawner must stand inside a box whose first node has unk2 = 0, else every" + + " spawned guard is removed at once") + .conf("medium") + .add(); + t("CowGuardSpawned", "Guard made by a CowGuardSpawner (same code as the guards).") + .p("View range", Kind.RADIUS, 0, 50, "") + .p("Chase range", Kind.RADIUS, 0, 50, "") + .p("Event bit", Kind.EVENT_BIT, 0, 32, guardEvent) + .p("p3", Kind.FIXED, "not read") + .wpPairs(Role.BOX, 2, guardBoxes) + .conf("medium") + .add(); + String gatorBoxes = "walking areas: boxes of node pairs (x/z). An odd count removes it; outside every" + + " box the game moves it into the first box. It snaps at the player only on the same floor" + + " height (±0.2) and inside its boxes"; + t("Alligator", "Alligator lying in wait inside its waypoint boxes; snaps at a llama on its floor level.") + .wpPairs(Role.BOX, 2, gatorBoxes) + .conf("medium") + .add(); + t("SpawnedAlligator", "Alligator made by an AlligatorSpawner (gets the spawner's waypoint boxes).") + .wpPairs(Role.BOX, 2, gatorBoxes) + .conf("medium") + .add(); + t( + "AlligatorSpawner", + "Spawns alligators that walk straight ahead (its yaw) into the first waypoint box, then" + + " behave like an Alligator.") + .p("Max alligators", Kind.COUNT, 0, 20, "spawns again only while fewer of its alligators are alive") + .p("Interval", Kind.SECONDS, 0, 120, "wait between spawns (and the player must be 1.5 cells away)") + .p("Floor collision", Kind.BOOL, "on: spawned alligators get strat flags 0x8400 (floor collision)") + .wpPairs( + Role.BOX, + 2, + "boxes of node pairs handed to the spawned alligators; the spawner should face the first" + + " box (alligators walk straight ahead until inside it)") + .conf("medium") + .add(); t("JumpingBeetleBlue", "Jumping beetle following its path.") .p("Jump time", Kind.FIXED, "") .p("p1", Kind.RAW, "unused") diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Session.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Session.java index 2a43ebb..58b5425 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Session.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Session.java @@ -67,15 +67,21 @@ public final class Session { return ground; } - /** Floor height under a game-space point (searching from a little above), or NaN. */ + /** + * Floor height under a game-space point (searching from a little above), or NaN. A floor of the + * cell's own tiles (the one the game uses) wins over a neighbour tile's polygon at any height. + */ public double floorY(double x, double z, double probeY) { Ground g = ground(); if (g == null) { return Double.NaN; } Ground.Hit h = g.below(x, z, probeY, 0.6); - if (h == null) { - h = g.below(x, z, Double.NaN, 0); + if (h == null || h.source() == Ground.Source.NEIGHBOUR) { + Ground.Hit any = g.below(x, z, Double.NaN, 0); + if (h == null || any != null && any.source() != Ground.Source.NEIGHBOUR) { + h = any; + } } return h == null ? Double.NaN : h.y(); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Setups.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Setups.java index 0b14135..8e212f6 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Setups.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Setups.java @@ -5,13 +5,14 @@ import com.eliasmueller.llamamod.editor.model.Node; import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.WaypointBoxes; import com.eliasmueller.llamamod.editor.model.cmd.AddNode; import com.eliasmueller.llamamod.editor.model.cmd.Batch; import com.eliasmueller.llamamod.editor.model.cmd.Command; import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart; import com.eliasmueller.llamamod.editor.model.cmd.SetProp; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -62,8 +63,9 @@ public final class Setups { } /** - * Places an object (yaw in degrees). Guards ({@link Guards}) get one box around them instead of the prototype's - * boxes and event bit 0, so they are active from the start. + * Places an object (yaw in degrees). Box walkers (guards, alligators: {@link Guards}) get one box + * around them instead of the prototype's boxes and guards event bit 0, so they are active from + * the start ({@link Edits#placeStrat}, the same for every way of adding an object). */ public static Made object(Scene s, String type, int variant, double x, double y, double z, double yaw) { return object(s, type, variant, x, y, z, Double.valueOf(yaw)); @@ -72,48 +74,12 @@ public final class Setups { /** As above; yaw null = the prototype's yaw. */ public static Made object(Scene s, String type, int variant, double x, double y, double z, Double yaw) { Command c = Edits.placeStrat(s, type, variant, x, y, z, yaw); - Strat st = lastStrat(c); - if (Guards.is(type)) { - c = guardSetup(s, (Batch) c, st, x, y, z); - } - return new Made(c, st); + return new Made(c, lastStrat(c)); } - private static Command guardSetup(Scene s, Batch placed, Strat st, double x, double y, double z) { - double h = Guards.NEW_BOX_HALF; - List nodes = addedNodes(placed); - List cmds = new ArrayList<>(); - if (nodes.size() >= 2) { - for (Command k : placed.commands()) { - if (!(k instanceof AddNode a && a.node() instanceof PathNode p && nodes.indexOf(p) >= 2)) { - cmds.add(k); - } - } - pos(nodes.get(0), x - h, y, z - h); - pos(nodes.get(1), x + h, y, z + h); - nodes.get(0).setRaw("unk2", 0); - nodes.get(1).setRaw("unk2", 0); - nodes.get(1).setRaw("next", null); - } else { - cmds.addAll(placed.commands()); - cmds.addAll(ChainOps.newChain( - s, - st, - List.of( - ChainOps.newNode(s, x - h, y, z - h, 0, null), - ChainOps.newNode(s, x + h, y, z + h, 0, null)))); - } - int[] p = st.params().clone(); - if (p.length > Guards.EVENT_PARAM) { - p[Guards.EVENT_PARAM] = 0; - st.setRaw("params", p); - } - return new Batch(placed.label(), cmds); - } - - /** Replaces a guard's waypoints with one box of 2 nodes around it (it then starts inside). */ + /** Replaces a box walker's waypoints with one box of 2 nodes around it (it then starts inside). */ public static Command guardBox(Scene s, Strat st) { - double h = Guards.NEW_BOX_HALF; + double h = WaypointBoxes.NEW_BOX_HALF; List cmds = new ArrayList<>(); if (st.waypoint() != null && s.contains(st.waypoint()) @@ -130,40 +96,19 @@ public final class Setups { } /** - * Player start: moves the existing player object if there is one (a level has exactly one), - * else places a Llama with its chain (first node = camera 8 cells behind, rest on the llama). + * Player start: moves the existing player object if there is one (a level has exactly one; its + * camera nodes near the old start move along), else places a Llama with one start camera node + * behind it ({@link PlayerStart}). */ public static Made player(Scene s, double x, double y, double z, double yaw) { Strat st = s.playerStart(); if (st != null) { - Map> v = new LinkedHashMap<>(); - double dx = x - st.gx(), dy = y - st.gy(), dz = z - st.gz(); - v.put( - st, - Map.of("x", Edits.fixed(x), "y", Edits.fixed(y), "z", Edits.fixed(z), "ry", Edits.angleUnits(yaw))); - if (st.waypoint() != null) { - List ch = s.chain(st.waypoint()); - double a = Math.toRadians(yaw); - for (int i = 0; i < ch.size(); i++) { - PathNode p = ch.get(i); - double nx = i == 0 ? x - 8 * Math.sin(a) : p.gx() + dx; - double nz = i == 0 ? z - 8 * Math.cos(a) : p.gz() + dz; - double ny = i == 0 ? y - 0.4 : p.gy() + dy; - v.put(p, Map.of("x", Edits.fixed(nx), "y", Edits.fixed(ny), "z", Edits.fixed(nz))); - } - } - return new Made(Edits.set("Move player start", v, null), st); + return new Made(PlayerStart.move(s, st, x, y, z, yaw), st); } if (!s.catalog().has("Llama")) { throw new IllegalStateException("this base level has no Llama (player start) to copy"); } - Command c = Edits.placeStrat(s, "Llama", 0, x, y, z, yaw); - double a = Math.toRadians(yaw); - int k = 0; - for (PathNode p : addedNodes(c)) { - pos(p, k == 0 ? x - 8 * Math.sin(a) : x, k == 0 ? y - 0.4 : y, k == 0 ? z - 8 * Math.cos(a) : z); - k++; - } + Command c = PlayerStart.place(s, x, y, z, yaw); return new Made(new Batch("Player start", List.of(c)), lastStrat(c)); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/CustomTypes.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/CustomTypes.java new file mode 100644 index 0000000..f7dbd06 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/CustomTypes.java @@ -0,0 +1,31 @@ +package com.eliasmueller.llamamod.editor.tools.strats; + +import com.eliasmueller.llamamod.editor.ui.EditorContext; + +/** Public entry to the custom type dialogs for other packages (object palettes). */ +public final class CustomTypes { + private CustomTypes() {} + + /** Index of a custom type of the open project by name, or −1. */ + public static int index(EditorContext ctx, String type) { + return ctx.document() == null + ? -1 + : StratEdits.customNames(ctx.document().project()).indexOf(type); + } + + /** Opens the script dialog of a custom type ({@link TypeEdits#edit} on OK). */ + public static void edit(EditorContext ctx, String type) { + int k = index(ctx, type); + if (k >= 0) { + TypeDialog.showEdit(ctx, k); + } + } + + /** Asks and deletes a custom type with its objects ({@link TypeEdits#delete}). */ + public static void delete(EditorContext ctx, String type) { + int k = index(ctx, type); + if (k >= 0) { + TypeDialog.delete(ctx, k); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java index 2918d99..6d2a6d2 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java @@ -2,15 +2,13 @@ package com.eliasmueller.llamamod.editor.ui; import com.eliasmueller.llamamod.editor.model.Brush; import com.eliasmueller.llamamod.editor.model.BrushKind; -import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; import com.eliasmueller.llamamod.editor.model.StratCatalog; import com.eliasmueller.llamamod.editor.model.TileModel; -import com.eliasmueller.llamamod.editor.model.cmd.AddNode; -import com.eliasmueller.llamamod.editor.model.cmd.Batch; import com.eliasmueller.llamamod.editor.model.cmd.Command; import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart; import com.eliasmueller.llamamod.editor.render.Camera; import com.eliasmueller.llamamod.editor.render.Gizmo; import com.eliasmueller.llamamod.editor.ui.panels.AssetBrowserPanel; @@ -340,25 +338,9 @@ final class BuiltinEditorPlugin implements EditorPlugin { } } - /** - * Player start like levelbuild: the Llama with its waypoint chain, first node = camera start 8 - * cells behind, the others on the llama. - */ + /** Player start: the Llama with one start camera node behind it ({@link PlayerStart}). */ private static Command placePlayer(Scene s, double x, double y, double z, double yawDeg) { - Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, x, y, z, yawDeg); - double a = Math.toRadians(yawDeg); - int k = 0; - for (Command c : b.commands()) { - if (c instanceof AddNode an && an.node() instanceof PathNode p) { - double nx = k == 0 ? x - 8 * Math.sin(a) : x, nz = k == 0 ? z - 8 * Math.cos(a) : z; - double ny = k == 0 ? y - 0.4 : y; - p.setRaw("x", Edits.fixed(nx)); - p.setRaw("y", Edits.fixed(ny)); - p.setRaw("z", Edits.fixed(nz)); - k++; - } - } - return b; + return PlayerStart.place(s, x, y, z, yawDeg); } // ------------------------------------------------------------------ checks diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/TypeMenus.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/TypeMenus.java new file mode 100644 index 0000000..653be82 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/TypeMenus.java @@ -0,0 +1,43 @@ +package com.eliasmueller.llamamod.editor.ui; + +import java.util.Map; +import java.util.concurrent.ConcurrentSkipListMap; +import javax.swing.JPopupMenu; + +/** + * Context-menu entries for an object type (right-click in the Assets → Objects grid and the + * Objects palette). Plugins register a contributor under an id; each adds its items for the type. + */ +public final class TypeMenus { + /** Adds menu items for {@code type} (catalog type name) to the menu. */ + @FunctionalInterface + public interface Contributor { + void contribute(EditorContext ctx, String type, JPopupMenu menu); + } + + private static final Map CONTRIBUTORS = new ConcurrentSkipListMap<>(); + + private TypeMenus() {} + + public static void register(String id, Contributor c) { + CONTRIBUTORS.put(id, c); + } + + /** Lets every contributor add its entries (a separator before each non-empty group). */ + public static void fill(EditorContext ctx, String type, JPopupMenu menu) { + for (Contributor c : CONTRIBUTORS.values()) { + int before = menu.getComponentCount(); + if (before > 0) { + menu.addSeparator(); + } + try { + c.contribute(ctx, type, menu); + } catch (RuntimeException e) { + e.printStackTrace(); + } + if (before > 0 && menu.getComponentCount() == before + 1) { + menu.remove(before); + } + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/AssetBrowserPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/AssetBrowserPanel.java index 62a2798..cc1649d 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/AssetBrowserPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/AssetBrowserPanel.java @@ -18,6 +18,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorWindow; import com.eliasmueller.llamamod.editor.ui.Icons; import com.eliasmueller.llamamod.editor.ui.NodeIcons; import com.eliasmueller.llamamod.editor.ui.NodeNames; +import com.eliasmueller.llamamod.editor.ui.TypeMenus; import com.eliasmueller.llamamod.editor.ui.Widgets; import com.eliasmueller.llamamod.editor.ui.tools.EditOps; import com.eliasmueller.llamamod.editor.ui.view.SceneRenderer; @@ -55,7 +56,9 @@ import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JList; +import javax.swing.JMenuItem; import javax.swing.JPanel; +import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JSplitPane; import javax.swing.JTabbedPane; @@ -318,8 +321,39 @@ public final class AssetBrowserPanel extends JPanel { list.setToolTipText(hint); AssetDrop.dragSource(list, it -> it.dragKind == null ? null : AssetDrop.payload(it.dragKind, it.dragData)); list.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + typeMenu(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + typeMenu(e); + } + + private void typeMenu(MouseEvent e) { + int i = list.locationToIndex(e.getPoint()); + if (!e.isPopupTrigger() + || i < 0 + || !list.getCellBounds(i, i).contains(e.getPoint()) + || !(model.get(i).data instanceof StratCatalog.Type t)) { + return; + } + list.setSelectedIndex(i); + Item it = model.get(i); + JPopupMenu m = new JPopupMenu(); + JMenuItem place = new JMenuItem("Place at view centre"); + place.addActionListener(a -> placeAtCentre(it)); + m.add(place); + TypeMenus.fill(ctx, t.name(), m); + m.show(list, e.getX(), e.getY()); + } + @Override public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1) { + return; + } Item it = list.getSelectedValue(); if (it == null) { return; diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/GroundTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/GroundTest.java new file mode 100644 index 0000000..f5009fe --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/GroundTest.java @@ -0,0 +1,71 @@ +package com.eliasmueller.llamamod.editor.tools.objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.editor.io.Bases; +import com.eliasmueller.llamamod.editor.io.BrushCompiler; +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.tools.playtest.FloorFinder; +import java.util.ArrayList; +import org.junit.jupiter.api.Test; + +/** Snap-to-floor follows the engine's cell rule (needs -Dllamamod.cd). */ +class GroundTest { + @Test + void cellFloorsMatchTheEngineRuleOfThePlayerFinder() throws Exception { + int same = 0, neighbourOnly = 0, ignoredAbove = 0; + for (String base : new String[] {"t1l1m001", "t1l1m002", "t1l5m001"}) { + Scene s = ObjectToolsTest.load(base); + Ground g = Ground.build(s, null); + for (int cx = 0; cx < s.settings().width(); cx++) { + for (int cz = 0; cz < s.settings().height(); cz++) { + for (double[] o : new double[][] {{0.5, 0.5}, {0.1, 0.9}, {0.85, 0.2}}) { + double x = cx + o[0], z = cz + o[1]; + Ground.Hit h = g.below(x, z, Double.NaN, 0); + FloorFinder.Floor f = FloorFinder.below(s, x, 1000, z); + if (h == null) { + continue; + } + Ground.Hit nb = g.neighbourBelow(x, z, Double.NaN, 0); + if (h.source() == Ground.Source.CELL && nb != null && nb.y() > h.y() + 0.05) { + ignoredAbove++; + } + if (h.source() == Ground.Source.NEIGHBOUR) { + neighbourOnly++; + assertTrue(f == null || f.material() == 30, x + "," + z); + } else if (h.source() == Ground.Source.CELL && f != null && f.material() != 30) { + assertEquals(f.y(), h.y(), 0.02, x + "," + z); + same++; + } + } + } + } + } + assertTrue(same > 500, "cell floors compared: " + same); + // the villages have places where a higher polygon reaches into a cell its tile is not listed in + // (the geometric finder snapped onto it) + assertTrue(ignoredAbove + neighbourOnly > 0, "ignored points: " + ignoredAbove + " / " + neighbourOnly); + System.out.println("GroundTest: " + same + " same, " + ignoredAbove + " higher neighbour polygons ignored, " + + neighbourOnly + " neighbour-only"); + } + + @Test + void brushFloorsAndSwapModels() throws Exception { + Project pr = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m002"); + Scene s = pr.scene(); + Setups.exit(s, 12.2, 0, 26.2, 1, 1, 2).command().apply(s); + var out = BrushCompiler.compile(s, new ArrayList<>()); + Ground g = Ground.build(s, out); + Ground.Hit h = g.below(12.5, 26.5, Double.NaN, 0); + assertNotNull(h); + assertEquals(Ground.Source.CELL, h.source()); + assertEquals(16, h.material()); + // village 1: the door tile's exit floor is only in its swap model (RubyIdolDoor opens it) + Scene v = ObjectToolsTest.load("t1l1m001"); + Ground gv = Ground.build(v, null); + assertTrue(gv.hasMaterial(42, 49, 16)); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/GuardsTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/GuardsTest.java index e25c066..fa7fa9b 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/GuardsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/GuardsTest.java @@ -11,6 +11,8 @@ import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; import com.eliasmueller.llamamod.editor.model.cmd.History; import com.eliasmueller.llamamod.editor.ui.Problem; import java.nio.file.Files; @@ -29,7 +31,10 @@ class GuardsTest { @Test void originalGuardsAndDoorsPassTheChecks() throws Exception { int guards = 0, withEvent = 0; + Map walkers = new TreeMap<>(); + List odd = new ArrayList<>(); Map doorWarnings = new TreeMap<>(); + List unsetBits = new ArrayList<>(); try (var files = Files.list(ObjectToolsTest.cd().resolve("wads"))) { for (Path p : files.filter(f -> f.getFileName().toString().matches("t1l\\dm00\\d\\.wad")) .sorted() @@ -41,12 +46,22 @@ class GuardsTest { guards++; assertNull(Guards.removedBecause(s, st), base + " " + st.id()); withEvent += Guards.eventBit(st) > 0 ? 1 : 0; + } else if (Guards.walksBoxes(st.type())) { + walkers.merge(st.type(), 1, Integer::sum); + String why = Guards.removedBecause(s, st); + if (why != null) { + odd.add(base + " " + st.id()); + } + assertNull(Guards.startWarning(s, st), base + " " + st.id()); } } List out = new ArrayList<>(); new ObjectChecks(() -> null).check(s, out); for (Problem pr : out) { assertTrue(!pr.message().contains("never appears"), base + ": " + pr.message()); + if (pr.message().contains("no object in this level sets")) { + unsetBits.add(base + " " + pr.message().replaceAll(".*until event bit (\\d+).*", "$1")); + } if (pr.message().contains("without exit floor")) { doorWarnings.merge(base, 1, Integer::sum); } @@ -57,6 +72,64 @@ class GuardsTest { assertEquals(4, withEvent); // lab: cow, gorilla and lizard guards wait for scenes // the two spare doors of t1l3m003 have no exit floor (their partners next to them do) assertEquals(Map.of("t1l3m003", 2), doorWarnings); + assertEquals(Map.of("Alligator", 1, "AlligatorSpawner", 2, "CowGuardSpawner", 8), walkers); + // one lab spawner stands 0.12 cells outside its boxes + assertEquals(List.of("t1l8m005 strat:82"), odd); + // lab guards: bits 8 (LabBigCutscene) and 30 (LabWall) have setters, 1 and 20 none in the level + assertEquals(List.of("t1l8m001 20", "t1l8m001 1"), unsetBits); + } + + @Test + void everyWayOfAddingGivesBoxWalkersOneBox() throws Exception { + Scene s = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m002") + .scene(); + // the Add object path (asset drop, Objects panel drag, place at view centre) + Command c = Edits.placeStrat(s, "CityGuard", 0, 22, 0, 18, null); + c.apply(s); + Strat guard = Setups.lastStrat(c); + assertEquals(2, s.chain(guard.waypoint()).size()); + assertNull(Guards.removedBecause(s, guard)); + assertEquals(0, Guards.eventBit(guard)); + assertTrue(ChainOps.contiguous(s)); + assertTrue(problems(s).isEmpty(), problems(s).toString()); + } + + @Test + void alligatorsAndSpawners() throws Exception { + Bases b = Bases.fromCd(ObjectToolsTest.cd()); + Scene s = ObjectToolsTest.load("t1l6m003"); + assertTrue(s.catalog().has("AlligatorSpawner")); + History h = new History(s); + Setups.Made m = Setups.object(s, "AlligatorSpawner", 0, 30, 0, 30, 0); + h.execute(m.command()); + Strat sp = (Strat) m.main(); + List ch = s.chain(sp.waypoint()); + assertEquals(2, ch.size()); + assertNull(Guards.removedBecause(s, sp)); + assertNull(Guards.startWarning(s, sp)); + // box moved behind the spawner: its alligators would walk away from it + for (PathNode p : ch) { + p.setRaw("z", p.z() - 6 * 4096); + } + assertNotNull(Guards.startWarning(s, sp)); + sp.setRaw("ry", 2048); // turned round: facing the box again + assertNull(Guards.startWarning(s, sp)); + // odd node count: spawned alligators are removed at once + h.execute(new Batch("del", ChainOps.remove(s, ch.get(1)))); + assertTrue(problems(s).stream() + .anyMatch(p -> + p.severity() == Problem.Severity.WARNING && p.message().contains("its alligators"))); + + Scene v = Project.create(b, "new", "t1l4m002").scene(); + Setups.Made g = Setups.object(v, "Alligator", 0, 20, 0, 20, 0); + g.command().apply(v); + Strat al = (Strat) g.main(); + assertEquals(2, v.chain(al.waypoint()).size()); + assertTrue(problems(v).isEmpty(), problems(v).toString()); + al.setRaw("x", al.x() + 5 * 4096); + assertTrue(problems(v).stream() + .anyMatch(p -> + p.severity() == Problem.Severity.WARNING && p.message().contains("first box"))); } @Test @@ -124,7 +197,7 @@ class GuardsTest { private static List problems(Scene s) { List out = new ArrayList<>(); new ObjectChecks(() -> null).check(s, out); - out.removeIf(p -> !(p.node() instanceof Strat st) || !Guards.is(st.type())); + out.removeIf(p -> !(p.node() instanceof Strat st) || !Guards.walksBoxes(st.type())); return out; } } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsGameDemo.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsGameDemo.java new file mode 100644 index 0000000..08985ce --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsGameDemo.java @@ -0,0 +1,123 @@ +package com.eliasmueller.llamamod.editor.tools.objects; + +import com.eliasmueller.llamamod.editor.io.Bases; +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.io.ProjectIO; +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.ui.Problem; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Not a unit test: test levels for the start camera and box walkers. A 12×12 plaza, the player start + * in its middle facing +x; "old" = the previous player setup (prototype chain on the llama, first + * node 8 behind); objects added through {@link Edits#placeStrat} (the Add object path) 3 cells ahead. + * Args: CD folder, output .llamalevel, base, chapter, name, old|new, [object type]. + */ +public final class ObjectsGameDemo { + private ObjectsGameDemo() {} + + public static void main(String[] a) throws Exception { + Bases cd = Bases.fromCd(Path.of(a[0])); + boolean orig = a[5].startsWith("orig"); + Project pr = orig + ? new Project( + com.eliasmueller.llamamod.editor.io.Templates.original(cd, a[2]), + com.eliasmueller.llamamod.editor.io.WadImporter.importWad(cd.wad(a[2]), a[2], cd::fileOrNull)) + : Project.create(cd, "new", a[2]); + Scene s = pr.scene(); + if (a[5].equals("origprobe")) { + double nx = Double.parseDouble(a[6]), nz = Double.parseDouble(a[7]); + for (int yaw = 0; yaw < 360; yaw += 30) { + double r = Math.toRadians(yaw); + StringBuilder b2 = new StringBuilder("yaw " + yaw + ":"); + for (double d : new double[] {0, 1, 2}) { + var f = com.eliasmueller.llamamod.editor.tools.playtest.FloorFinder.below( + s, nx - d * Math.sin(r), 20, nz - d * Math.cos(r)); + b2.append(String.format(" %.2f", f == null ? Double.NaN : f.y())); + } + System.out.println(b2); + } + return; + } + if (orig) { + // move the start of the original level: old setup (node 0 eight behind, the rest by the delta) or new + double nx = Double.parseDouble(a[6]), nz = Double.parseDouble(a[7]), yaw = Double.parseDouble(a[8]); + var f = com.eliasmueller.llamamod.editor.tools.playtest.FloorFinder.near(s, nx, 20, nz); + Strat ll = s.playerStart(); + if (a[5].equals("origold")) { + List ch = s.chain(ll.waypoint()); + double dx = nx - ll.gx(), dy = f.y() - ll.gy(), dz = nz - ll.gz(), r = Math.toRadians(yaw); + for (int i = 0; i < ch.size(); i++) { + PathNode p = ch.get(i); + if (i == 0) { + Setups.pos(p, nx - 8 * Math.sin(r), f.y() - 0.4, nz - 8 * Math.cos(r)); + } else { + Setups.pos(p, p.gx() + dx, p.gy() + dy, p.gz() + dz); + } + } + ll.setRaw("x", Edits.fixed(nx)); + ll.setRaw("y", Edits.fixed(f.y())); + ll.setRaw("z", Edits.fixed(nz)); + ll.setRaw("ry", Edits.angleUnits(yaw)); + } else { + Setups.player(s, nx, f.y(), nz, yaw).command().apply(s); + } + List ch = s.chain(ll.waypoint()); + System.out.println("start " + nx + " " + f.y() + " " + nz + " camera node " + + com.eliasmueller.llamamod.editor.model.cmd.PlayerStart.startNode(ch, nx, nz)); + ProjectIO.save(pr, Path.of(a[1]), cd); + System.out.println("saved " + a[1]); + return; + } + s.settings().prop("name").setRaw(s.settings(), a[4]); + s.settings().prop("targetChapter").setRaw(s.settings(), Integer.parseInt(a[3])); + Brush b = s.createBrush(BrushKind.BOX); + Map.of("x0", 10.0, "z0", 10.0, "x1", 22.0, "z1", 22.0, "y0", -1.0, "y1", 0.0) + .forEach(b::setRaw); + new AddNode(b).apply(s); + double px = 14.5, pz = 16.5; + if (a[5].equals("old")) { + Command c = Edits.placeStrat(s, "Llama", 0, px, 0, pz, 90.0); + c.apply(s); + List ch = s.chain(Setups.lastStrat(c).waypoint()); + for (int i = 0; i < ch.size(); i++) { + Setups.pos(ch.get(i), i == 0 ? px - 8 : px, i == 0 ? -0.4 : 0, pz); + } + } else { + Setups.player(s, px, 0, pz, 90).command().apply(s); + } + if (a.length > 6) { + Command c = Edits.placeStrat(s, a[6], 0, px + 3.5, 0, pz, 270.0); + c.apply(s); + } + if (a.length > 7 && a[7].equals("ui")) { + // editor screenshot setup: a custom type and a guard waiting for an event nobody sets + var t = com.eliasmueller.llamamod.editor.tools.strats.StratEdits.cloneOf(s, "Coin", "MyCoin"); + com.eliasmueller.llamamod.editor.tools.strats.StratEdits.addType(pr, t); + Command c = Edits.placeStrat(s, a[6], 0, px + 3.5, 0, pz + 3, 270.0); + c.apply(s); + Strat g = Setups.lastStrat(c); + int[] prm = g.params().clone(); + prm[2] = 5 << 12; + g.setRaw("params", prm); + } + List out = new ArrayList<>(); + new ObjectChecks(() -> null).check(s, out); + out.forEach(p -> System.out.println(p.severity() + " " + p.message())); + for (Strat st : s.strats()) { + System.out.println(st.type() + " chain " + s.chain(st.waypoint()).size()); + } + ProjectIO.save(pr, Path.of(a[1]), cd); + System.out.println("saved " + a[1]); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/PlayerStartTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/PlayerStartTest.java new file mode 100644 index 0000000..14be68a --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/PlayerStartTest.java @@ -0,0 +1,111 @@ +package com.eliasmueller.llamamod.editor.tools.objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.editor.io.Bases; +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.History; +import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** The Llama's waypoints are start camera positions (first node within 2.5 cells). Needs -Dllamamod.cd. */ +class PlayerStartTest { + @Test + void newPlayerStartGetsOneCameraNodeBehind() throws Exception { + Bases b = Bases.fromCd(ObjectToolsTest.cd()); + for (String base : new String[] {"t1l1m001", "t1l1m002"}) { + Scene s = Project.create(b, "new", base).scene(); + Setups.Made m = Setups.player(s, 20.5, 3, 20.5, 90); + m.command().apply(s); + Strat ll = (Strat) m.main(); + List ch = s.chain(ll.waypoint()); + assertEquals(1, ch.size(), base); + assertEquals(0, PlayerStart.startNode(ch, ll.gx(), ll.gz())); + assertEquals(18.5, ch.getFirst().gx(), 1e-3); // 2 cells behind (facing +x) + assertEquals(3.5, ch.getFirst().gy(), 1e-3); + assertEquals(0, ch.getFirst().unk2()); + assertNull(ObjectChecks.startCamera(s, ll), base); + assertTrue(ChainOps.contiguous(s)); + // the Place tool's rotate knob turns the camera node around the llama + PlaceTool.rotateCommand(ll, 180, null).apply(s); + assertEquals(20.5, ch.getFirst().gx(), 1e-3); + assertEquals(22.5, ch.getFirst().gz(), 1e-3); + } + } + + @Test + void prototypeChainOnTheLlamaIsReported() throws Exception { + Bases b = Bases.fromCd(ObjectToolsTest.cd()); + // what the old setup did: prototype chain, first node 8 behind, the rest on the llama + for (String base : new String[] {"t1l1m001", "t1l1m002"}) { + Scene s = Project.create(b, "new", base).scene(); + Command c = Edits.placeStrat(s, "Llama", 0, 20.5, 3, 20.5, 0.0); + c.apply(s); + Strat ll = Setups.lastStrat(c); + List ch = s.chain(ll.waypoint()); + for (int i = 0; i < ch.size(); i++) { + Setups.pos(ch.get(i), 20.5, i == 0 ? 2.6 : 3, i == 0 ? 12.5 : 20.5); + } + String why = ObjectChecks.startCamera(s, ll); + // village 2: node 2 has unk2 ≠ 0 – camera in the head (seen in game); village 1: unk2 = 0, + // the camera starts behind the llama at default distance (seen in game) + if (base.equals("t1l1m002")) { + assertNotNull(why); + assertTrue(why.contains("head"), why); + } else { + assertNull(why, why); + } + } + } + + @Test + void movingTheOriginalStartKeepsDoorNodes() throws Exception { + Scene s = ObjectToolsTest.load("t1l1m001"); + Strat ll = s.playerStart(); + List before = s.chain(ll.waypoint()); + double[][] pos = new double[before.size()][]; + for (int i = 0; i < pos.length; i++) { + pos[i] = new double[] {before.get(i).gx(), before.get(i).gz()}; + } + assertNull(ObjectChecks.startCamera(s, ll)); + Strat intro = s.strats().stream() + .filter(o -> o.type().equals("CutSceneTrigger") && Math.hypot(o.gx() - ll.gx(), o.gz() - ll.gz()) < 1) + .findFirst() + .orElseThrow(); + assertEquals(0, intro.params()[6]); + History h = new History(s); + h.execute(Setups.player(s, 30.5, 4.25, 40.5, 180).command()); + // the intro fly-by trigger moves along and ends behind the llama + assertEquals(30.5, intro.gx(), 0.1); + assertEquals(1 << 12, intro.params()[6]); + List after = s.chain(ll.waypoint()); + assertEquals(before.size() + 1, after.size()); + for (int i = 0; i < pos.length; i++) { + assertEquals(pos[i][0], after.get(i).gx(), 1e-3); + assertEquals(pos[i][1], after.get(i).gz(), 1e-3); + } + int k = PlayerStart.startNode(after, ll.gx(), ll.gz()); + assertEquals(after.size() - 1, k); + assertEquals(42.5, after.get(k).gz(), 1e-3); // behind = +z when facing −z + assertNull(ObjectChecks.startCamera(s, ll)); + assertTrue(ChainOps.contiguous(s)); + // moving it again a little takes the node along instead of adding one + h.execute(Setups.player(s, 31.5, 4.25, 40.5, 90).command()); + after = s.chain(ll.waypoint()); + assertEquals(before.size() + 1, after.size()); + assertEquals(29.5, after.getLast().gx(), 1e-3); + assertEquals(40.5, after.getLast().gz(), 1e-3); + h.undo(); + h.undo(); + assertEquals(before.size(), s.chain(ll.waypoint()).size()); + } +}