Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions docs/proposals/interactive-map-elements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
# Proposal: Interactive map elements (Lotus and Summit doors)

Status: research proposal, no implementation yet.

## The request

Users asked for clickable, stateful map elements, "like valoplant":

- Clicking a Lotus door makes it spin (rotate open/closed).
- Clicking a Summit door turns it black to show it has been dropped.
- Related asks in the same list: a clickable Sage Wall whose blocks can be
broken, and clickable smokes / Viper Wall that fade when clicked to show
they are down.

## What the game and valoplant actually do

In-game mechanics (what we are modeling):

- **Lotus** has two rotating doors (A Main ↔ A Tree, and C Mound ↔ B Main).
A toggle on either side rotates the door 180° over ~8–10 seconds, briefly
opening an otherwise blocked hallway. Doors are also breakable, which
leaves the passage permanently open.
- **Summit** (June 2026) has three droppable walls (A Art ↔ A Garden,
Mid Window ↔ Mid Bottom, B Tower ↔ B Site). Each is connected to a 125 HP
switch; when the switch is destroyed the wall crashes down and
**permanently blocks that route for the rest of the round**.

valoplant.gg's planner mirrors this: door markers are drawn on top of the
map artwork and are directly clickable. Clicking a Lotus door toggles it
between closed and open (the door graphic rotates), and clicking a Summit
door toggles it between "up" (passable) and "dropped" (drawn as a solid
dark/black bar reading as a permanent obstacle). Per their announcement,
Summit's "toggle-able doors also affect intelligent agent pathing and
vision cones" — door state feeds their vision/pathing model, not just the
visuals. The core interaction is a plain single click on the element, with
an immediate visual state change; no menus.

Sources:
- https://valoplant.gg/
- Lotus doors: https://wiki.playvalorant.com/en-us/Lotus,
https://mobalytics.gg/blog/valorant/lotus-map-guide-layout-tip-tricks/
- Summit walls: https://wiki.playvalorant.com/en-us/Summit,
https://www.thespike.gg/valorant/maps/summit
- valoplant Summit announcement (toggle-able doors + pathing/vision):
https://x.com/ValoPlant
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Link the exact ValoPlant announcement.

https://x.com/ValoPlant identifies the account, not the announcement that supports the pathing and vision-cone claim. Link the exact post or mark this behavior as an assumption.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/proposals/interactive-map-elements.md` around lines 44 - 45, Update the
ValoPlant reference in the “toggle-able doors + pathing/vision” entry to link
the exact Summit announcement post rather than the account profile, or
explicitly label the described behavior as an assumption if the announcement
cannot be identified.


## Where Icarus is today

- `lib/interactive_map.dart` (`InteractiveMap` / `_InteractiveMapState`)
renders the canvas as a `Stack` inside an `InteractiveViewer`: dot grid →
map SVG (`SvgPicture.asset`, recolored by `_MapSvgColorMapper` from
`effectiveMapThemePaletteProvider`) → optional overlays (spawn walls,
callouts, ult orbs — each its own per-map SVG asset) → `PlacedWidgetBuilder`
→ drawing layer. Defense side is handled by swapping `*_defense.svg`
assets and `Transform.flip`-ping overlay layers.
- Per-map facts live as code constants in `lib/const/maps.dart` (`MapValue`,
`Maps.mapNames`, `Maps.mapScale`, `Maps.mapViewBox`,
`Maps.visionGeometryPadding`…). Per-map vision data ships as
`assets/maps/<map>_vision.json`.
- Map view state lives in `lib/providers/map_provider.dart` (`MapState`:
`currentMap`, `isAttack`, `showSpawnBarrier`, `showUltOrbs`,
`showRegionNames`).
- A strategy's content is per **page**: `StrategyPage`
(`lib/providers/strategy_page.dart`) holds `drawingData`, `agentData`,
`abilityData`, `textData`, `imageData`, `utilityData`, `isAttack`,
`settings`, `lineUpGroups`, each serialized through its provider's
`objectToJson` / `fromJson` into the page JSON that Hive and .ica export
both consume. `Settings.versionNumber` (currently 94) gates migrations.
- There is already precedent for lightweight per-element visual state:
`AbilityVisualState` on `PlacedAbility` (`lib/const/placed_classes.dart`)
stores `showRangeOutline` / `showRangeFill` / … as additive JSON fields
with defaults, edited via `ability_visibility_context_menu.dart`.
- Positions are normalized world coordinates via
`CoordinateSystem.instance` (`lib/const/coordinate_system.dart`,
world = 1777.8 × 1000 with the map inset by
`mapPaddingNormalizedX ≈ 268.9`), so anything authored in attack-frame
coordinates can be mirrored with the same 180° world rotation
(`Transform.flip(flipX: !isAttack, flipY: !isAttack)`) the spawn-wall,
ult-orb, and drawing layers use. Note the base map itself is *swapped*
(`_map.svg` ↔ `_map_defense.svg`), not flipped.
- `MouseWatch` (`lib/widgets/mouse_watch.dart`) is the existing primitive
that gives a placed element a hover cursor, an optional `onTap`, and a
`ShadContextMenuRegion` — ready-made for a clickable door.

There is currently no concept of a map-owned, clickable element: everything
interactive is something the user placed.

## Proposed design

### 1. Data model: `MapInteractable` (static, per map)

New file `lib/const/map_interactables.dart`, in the same style as
`maps.dart` — code constants, not runtime JSON:

```dart
enum MapInteractableKind { rotatingDoor, droppableDoor }

class MapInteractable {
final String id; // stable, e.g. "lotus_door_a"
final MapInteractableKind kind;
final Offset position; // normalized world coords, attack frame
final Size size; // normalized door footprint
final double angle; // door orientation on the map, radians
}

class MapInteractables {
static const Map<MapValue, List<MapInteractable>> byMap = {
MapValue.lotus: [ /* 2 rotating doors */ ],
MapValue.summit: [ /* 3 droppable doors */ ],
};
}
```

Maps without entries get no layer at all; adding a future map is purely
additive data.

### 2. State: per page, persisted

Door state is part of the plan ("on this step, the Mid wall is already
down"), so it belongs on `StrategyPage`, not on `MapState`:

- Add `Map<String, int> interactableStates` to `StrategyPage`
(interactable id → state index; 0 = default/closed/up). Absent id means
default, so the field is `{}` for every existing strategy and old .ica
files round-trip untouched — same additive-with-default pattern as
`AbilityVisualState`. A new page slice touches `StrategyPage` in five
places: the field, the constructor, `copyWith` (which deep-copies via the
JSON codec), `toJson`, and `fromJson`.
- Bump `Settings.versionNumber` and regenerate Hive adapters
(`dart run build_runner build --delete-conflicting-outputs`) since
`StrategyPage` is a Hive model. No data migration needed — only a new
optional field with a safe default; .ica export/import go through
Comment on lines +130 to +132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Version bump rejects older clients

The additive field can use a generated Hive-adapter default and tolerant page JSON without a migration, while increasing Settings.versionNumber makes preceding releases reject exported .ica files as too new. Avoid the global version bump unless this feature introduces an actual migration or incompatible archive contract.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

`page.toJson`, so they pick the field up for free.
- New `lib/providers/interactable_state_provider.dart`
(`NotifierProvider<InteractableStateProvider, Map<String, int>>`) with
`toggle(String id)`, `fromHive(...)`, and `objectToJson`/`fromJson`
statics matching the other page-slice codecs. Wire it into
`StrategyProvider._syncCurrentPageToHive()` and `setActivePage()` next to
the existing providers so page switching flushes/hydrates it; route
toggles through `actionProvider.notifier.performTransaction` (the same
path `updateVisualState` uses) so door clicks are undoable and mark the
strategy dirty for auto-save.
Comment on lines +119 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Model Summit as a one-way transition.

toggle(String id) permits up → dropped → up, but Line 26 says a dropped Summit wall permanently blocks the route for the rest of the round. Lines 169-174 repeat this contradiction. Current Summit references also describe dropped walls as non-reopenable during the round. (forbes.com)

Define legal transitions per MapInteractableKind: Lotus closed ↔ open; Summit up → dropped. Let undo restore the previous page snapshot, but do not make a second click reopen the wall.

Also applies to: 163-174

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/proposals/interactive-map-elements.md` around lines 119 - 142, Update
the interactable state transition logic in InteractableStateProvider.toggle to
branch on MapInteractableKind: preserve bidirectional closed/open toggling for
Lotus, but allow Summit only to transition from up to dropped and ignore
subsequent clicks. Keep undo behavior based on restoring the prior page snapshot
rather than permitting a second toggle to reopen Summit walls, and update the
related Summit descriptions to reflect the one-way transition.

Source: MCP tools


Because state is per page, video export naturally shows doors changing
between steps. Animating a door *during* a page transition would need it
represented in `_snapshotAllPlaced()` / `TransitionPlanner.diff`; initially
door state simply snaps with the page (like `isAttack` does today).

### 3. Rendering and interaction: `MapInteractableLayer`

New widget `lib/widgets/map_interactable_layer.dart`, inserted in
`interactive_map.dart`'s stack directly after the map SVG (below callouts
and `PlacedWidgetBuilder`, so placed widgets and drags keep priority):

- Positioned with the same `mapLeft`/`mapWidth` frame as the map SVG and
wrapped in the same `Transform.flip` used by the drawing layer, so
attack-frame coordinates work on both sides for free.
Comment on lines +155 to +157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Inset map frame misplaces full-world coordinates

MapInteractable.position is described as using full-world coordinates, but this layer is scoped to the inset mapLeft/mapWidth map rectangle. The drawing layer can reuse its flip because it fills the full world; applying that transform inside the inset layer offsets both attack-side and defense-side positions. Either make this layer full-world too, or convert each world coordinate to map-local coordinates before positioning it.

Artifacts

Coordinate-frame validation script source

  • The authored Python source reads the live map stack markers and validates direct full-world placement versus map-local conversion, with the takeaway that the proposal needs a coordinate-frame conversion or a full-world layer.

Direct full-world placement in the inset map frame

  • This executed `python3 trex-artifacts/interactive-map-elements-coordinate-validation.py --mode before` capture shows the attack and defense positions are each displaced by 290.400 pixels, with the takeaway that direct placement fails.

Map-local conversion in the inset map frame

  • This executed `python3 trex-artifacts/interactive-map-elements-coordinate-validation.py --mode after` capture shows converted map-local placement matches the expected attack and defense positions exactly, with the takeaway that conversion fixes the frame mismatch.

Flutter narrow-validation command unavailable

  • This captured `flutter test trex-artifacts/interactive-map-elements-coordinate-validation_test.dart -r expanded` command exited 127 because Flutter is not installed, with the takeaway that native Dart execution was blocked by the environment.

Existing defense painter widget-test command unavailable

  • This captured existing defense painter widget-test command exited 127 because Flutter is not installed, with the takeaway that the repository widget test could not run in this environment.

View artifacts

T-Rex Ran code and verified through T-Rex

- Each interactable is a small `Positioned` child using `MouseWatch`
(hover cursor + `onTap` + optional right-click menu for free), with the
hit area padded slightly beyond the visual — doors are only a few px at
min zoom — and a subtle hover highlight so it's discoverable as
clickable.
- **Lotus (`rotatingDoor`)**: a slim rounded bar in the theme highlight
color. Tap toggles closed ↔ open; presentation is an `AnimatedRotation`
(90° visual turn around the door center, ~450 ms, `Curves.easeInOutCubic`)
echoing the in-game spin without holding the user up ("the user is
mid-thought"). Open state renders the bar perpendicular to the doorway,
reading as a gap.
- **Summit (`droppableDoor`)**: same bar; tap toggles up ↔ dropped.
Dropped is drawn as a solid near-black bar (a fixed dark neutral, e.g.
`#0a0a0a` with a faint outline — *not* palette-derived, since map themes
vary and "black = blocked" must read in every palette), animated with a
short color/opacity tween. Up state is drawn hollow/outlined in the
detail color so the route reads as open.

Door colors otherwise follow `effectiveMapThemePaletteProvider` so themed
maps stay coherent (DESIGN.md palette).

Screenshot and video export render the same stack, so exports show door
state with no extra work.
Comment on lines +179 to +180

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Exports omit the interactable layer

ScreenshotView builds an independent rendering stack, and video export constructs that view for each frame. Adding MapInteractableLayer only to InteractiveMap therefore leaves exported screenshots and videos without persisted door state. Pass the page's interactableStates into ScreenshotView, render the layer in its export stack, and specify its behavior for transition frames instead of claiming this needs no export work.

Artifacts

Export-stack validation output showing ScreenshotView does not use InteractiveMap

  • Ran the narrow validation from `/home/user/repo`; it shows the independent ScreenshotView stack is used for video and lacks the proposed interactable layer, so InteractiveMap-only work will not export.

Validation script for the interactive-map export stack

  • The executable validation source reads the proposal and renderer wiring, asserts the relevant composition relationships, and prints the observed export-path result.

View artifacts

T-Rex Ran code and verified through T-Rex


### 4. Extensibility: Sage Wall and smokes

These are *placed* elements, so they ride the existing
`AbilityVisualState` rail rather than the map layer:

- Add `isFaded` (or `expiredOpacity`) to `AbilityVisualState` — additive
JSON field, default false, committed through
`abilityProvider.notifier.updateVisualState` inside
`actionProvider.performTransaction` exactly like the existing outline/fill
toggles in `ability_visibility_context_menu.dart`, so it is persisted and
undoable. Smokes (`CircleAbility`) and walls
(`ResizableSquareAbility(isWall: true)` — Viper, Harbor, Neon) render at
~30% opacity when faded, communicating "this util is down" without
deleting it from the page.
- Sage Wall (`RotatableImageAbility` in `lib/const/agents.dart:779`,
rendered by `RotatableImageWidget`) gets a per-segment
`List<bool> brokenSegments` on `PlacedAbility` (same additive pattern as
`armLengthsMeters` for Deadlock's mesh); broken segments render as
gaps/cracked tint. Clicking a segment toggles it.

Nothing in the `MapInteractable` design is coupled to doors: a future
`kind` (e.g. destructible Lotus door → stuck-open state) is one enum value
and one renderer branch.

### 5. Files that change

| File | Change |
| --- | --- |
| `lib/const/map_interactables.dart` | new — static door definitions for Lotus and Summit |
| `lib/widgets/map_interactable_layer.dart` | new — rendering + tap handling |
| `lib/interactive_map.dart` | insert layer into the stack |
| `lib/providers/interactable_state_provider.dart` | new — per-page state + toggle |
| `lib/providers/strategy_page.dart` | `interactableStates` field + (de)serialization |
| `lib/providers/strategy_provider.dart` | wire new provider into `_syncCurrentPageToHive` / `setActivePage` / page copy |
| `lib/const/settings.dart` | bump `versionNumber` |
| `lib/hive/*` (generated) | regenerate adapters via build_runner |
| `lib/const/placed_classes.dart` | Phase 3: `AbilityVisualState.isFaded`, `brokenSegments` |

### 6. Phased plan

1. **Phase 1 — Summit doors (simplest visual):** data model, per-page
state, layer, tap-to-toggle with black "dropped" rendering. Calibrate
the three door positions against `assets/maps/summit_map.svg`
(`Maps.mapViewBox[MapValue.summit]` = 435×473). Verify .ica round-trip
and old-file import.
2. **Phase 2 — Lotus doors:** add the two rotating doors with the spin
animation; verify defense-side mirroring.
3. **Phase 3 — placed-element states:** smoke/Viper Wall fade toggle and
Sage Wall segment breaking on `AbilityVisualState` / `PlacedAbility`.
4. **Phase 4 (stretch) — systems integration:** feed door state into the
view-cone geometry (`lib/view_cone/`, `*_vision.json`) so a dropped
Summit wall blocks vision cones — the part of valoplant's feature users
don't see but feel — and animate door state changes during page
transitions in video export.

### 7. Open questions / risks

- **Door coordinates** need hand-calibration against our cropped SVGs
(same process as `visionGeometryAlignment` tuning). Small, but fiddly.
- **Gesture priority:** the layer sits above the map SVG's
tap-clears-ability-bar `GestureDetector`; door taps must not also clear
the ability bar, and must lose to drags of placed widgets. Keeping the
layer below `PlacedWidgetBuilder` and using opaque hit-test only on the
door rects handles this, but needs testing at high zoom.
- **Vision integration (Phase 4)** requires editable wall segments in the
vision geometry, which today is static per map — scoped out of the
initial phases deliberately.
- **Server-readiness:** `interactableStates` is a flat `Map<String, int>`
keyed by stable ids with defaults on absence — trivially readable by
code that has never seen our widgets, per the data-layer philosophy.
Loading