objects: snap to floor follows the engine's cell rule (cell tiles, swap models, then neighbours), alligator, alligator spawner and cow guard spawner schemas with box checks, every way of adding a box walker gives it a box, guard info names the objects that set its event bit, new and moved player starts carry their start camera node (no camera in the llama's head), edit and delete type in the object palettes' context menu
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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": `<data>/editor/prefabs/<name>.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°.
|
||||
|
||||
@@ -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 `<data>/levels` = listed as mods in LlamaMod), Save as, Open
|
||||
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -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<String> 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<String> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<PathNode> chain = new ArrayList<>();
|
||||
for (int[] c : v.chain()) {
|
||||
List<int[]> 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));
|
||||
|
||||
@@ -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<PathNode> 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<Command> 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<Node, Map<String, Object>> 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<PathNode> 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<Node, double[]> carry = (n, g) -> {
|
||||
double rx = g[0] - ox, rz = g[2] - oz;
|
||||
Map<String, Object> 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<Command> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, int[]> 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<Integer> setBits(Scene s) {
|
||||
Set<Integer> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Long, List<Floor>> cells = new HashMap<>();
|
||||
private final Map<Long, List<Floor>> swaps = new HashMap<>();
|
||||
private final Map<Long, List<Floor>> 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<Floor> floors(List<TileModel.PlacedPolygon> polys) {
|
||||
List<Floor> 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<Floor> 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<Floor> 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<Floor> l = cells.get(key(cx, cz));
|
||||
if (l == null) {
|
||||
return false;
|
||||
}
|
||||
for (Floor f : l) {
|
||||
if (f.material == material) {
|
||||
return true;
|
||||
for (Map<Long, List<Floor>> m : List.of(cells, swaps)) {
|
||||
for (Floor f : m.getOrDefault(key(cx, cz), List.of())) {
|
||||
if (f.material == material) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -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}).
|
||||
*
|
||||
* <ul>
|
||||
* <li>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.
|
||||
* <li>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.
|
||||
* <li>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.
|
||||
* <li>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.
|
||||
* </ul>
|
||||
*/
|
||||
final class Guards {
|
||||
static final List<String> TYPES = List.of(
|
||||
"CityGuard",
|
||||
"CityGuardWithRubyIdol",
|
||||
"CowGuard",
|
||||
"CowGuardNoCoin",
|
||||
"GorillaGuard",
|
||||
"LizardGuard",
|
||||
"LizardGuardNoCoin",
|
||||
"WarthogGuard");
|
||||
static final List<String> 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<PathNode> chain, Strat st) {
|
||||
return startBox(chain, st, true);
|
||||
}
|
||||
|
||||
private static int startBox(List<PathNode> 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<PathNode> 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<PathNode> chain = st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of();
|
||||
List<PathNode> 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<PathNode> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Tile> doorTiles = null;
|
||||
Set<Integer> 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<PathNode> 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() + ")";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
? "<b>Never appears:</b> the game removes it at level start because " + gone + "."
|
||||
: bit > 0
|
||||
? "Starts inside its box. <b>Hidden</b> 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. <b>Hidden</b> 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);
|
||||
|
||||
@@ -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<Node> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<PathNode> nodes = addedNodes(placed);
|
||||
List<Command> 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<Command> 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<Node, Map<String, Object>> 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<PathNode> 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, Contributor> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<String, Integer> walkers = new TreeMap<>();
|
||||
List<String> odd = new ArrayList<>();
|
||||
Map<String, Integer> doorWarnings = new TreeMap<>();
|
||||
List<String> 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<Problem> 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<PathNode> 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<Problem> problems(Scene s) {
|
||||
List<Problem> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<PathNode> 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<PathNode> 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.<String, Object>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<PathNode> 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<Problem> 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]);
|
||||
}
|
||||
}
|
||||
@@ -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<PathNode> 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<PathNode> 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<PathNode> 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<PathNode> 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());
|
||||
}
|
||||
}
|
||||