diff --git a/assets/weapons/README.md b/assets/weapons/README.md new file mode 100644 index 00000000..720372f5 --- /dev/null +++ b/assets/weapons/README.md @@ -0,0 +1,27 @@ +# Weapon silhouettes + +Bundled in-game kill-feed icons retrieved from [Valorant-API](https://valorant-api.com/v1/weapons) on 2026-09-13. Valorant and the artwork belong to Riot Games. Valorant-API is an unofficial asset source. + +The PNGs are unmodified and retain transparency. `WeaponIcon` fits their visible bounds at render time so wide transparent margins do not shrink pistols. No runtime downloads are needed. + +| File | Source | +| --- | --- | +| odin.png | [Odin](https://media.valorant-api.com/weapons/63e6c2b6-4a8e-869c-3d4c-e38355226584/killstreamicon.png) | +| ares.png | [Ares](https://media.valorant-api.com/weapons/55d8a0f4-4274-ca67-fe2c-06ab45efdf58/killstreamicon.png) | +| vandal.png | [Vandal](https://media.valorant-api.com/weapons/9c82e19d-4575-0200-1a81-3eacf00cf872/killstreamicon.png) | +| bulldog.png | [Bulldog](https://media.valorant-api.com/weapons/ae3de142-4d85-2547-dd26-4e90bed35cf7/killstreamicon.png) | +| phantom.png | [Phantom](https://media.valorant-api.com/weapons/ee8e8d15-496b-07ac-e5f6-8fae5d4c7b1a/killstreamicon.png) | +| judge.png | [Judge](https://media.valorant-api.com/weapons/ec845bf4-4f79-ddda-a3da-0db3774b2794/killstreamicon.png) | +| bucky.png | [Bucky](https://media.valorant-api.com/weapons/910be174-449b-c412-ab22-d0873436b21b/killstreamicon.png) | +| frenzy.png | [Frenzy](https://media.valorant-api.com/weapons/44d4e95c-4157-0037-81b2-17841bf2e8e3/killstreamicon.png) | +| classic.png | [Classic](https://media.valorant-api.com/weapons/29a0cfab-485b-f5d5-779a-b59f85e204a8/killstreamicon.png) | +| bandit.png | [Bandit](https://media.valorant-api.com/weapons/410b2e0b-4ceb-1321-1727-20858f7f3477/killstreamicon.png) | +| ghost.png | [Ghost](https://media.valorant-api.com/weapons/1baa85b4-4c70-1284-64bb-6481dfc3bb4e/killstreamicon.png) | +| sheriff.png | [Sheriff](https://media.valorant-api.com/weapons/e336c6b8-418d-9340-d77f-7a9e4cfe0702/killstreamicon.png) | +| shorty.png | [Shorty](https://media.valorant-api.com/weapons/42da8ccc-40d5-affc-beec-15aa47b42eda/killstreamicon.png) | +| operator.png | [Operator](https://media.valorant-api.com/weapons/a03b24d3-4319-996d-0f8c-94bbfba1dfc7/killstreamicon.png) | +| guardian.png | [Guardian](https://media.valorant-api.com/weapons/4ade7faa-4cf1-8376-95ef-39884480959b/killstreamicon.png) | +| outlaw.png | [Outlaw](https://media.valorant-api.com/weapons/5f0aaf7a-4289-3998-d5ff-eb9a5cf7ef5c/killstreamicon.png) | +| marshal.png | [Marshal](https://media.valorant-api.com/weapons/c4883e50-4494-202c-3ec3-6b8a9284f00b/killstreamicon.png) | +| spectre.png | [Spectre](https://media.valorant-api.com/weapons/462080d1-4035-2937-7c09-27aa2a5c27a7/killstreamicon.png) | +| stinger.png | [Stinger](https://media.valorant-api.com/weapons/f7e1b454-4ad4-1063-ec0a-159e56b58941/killstreamicon.png) | diff --git a/assets/weapons/ares.png b/assets/weapons/ares.png new file mode 100644 index 00000000..b634c9bb Binary files /dev/null and b/assets/weapons/ares.png differ diff --git a/assets/weapons/bandit.png b/assets/weapons/bandit.png new file mode 100644 index 00000000..4ae74d5a Binary files /dev/null and b/assets/weapons/bandit.png differ diff --git a/assets/weapons/bucky.png b/assets/weapons/bucky.png new file mode 100644 index 00000000..f4c9ec5b Binary files /dev/null and b/assets/weapons/bucky.png differ diff --git a/assets/weapons/bulldog.png b/assets/weapons/bulldog.png new file mode 100644 index 00000000..ae1b4622 Binary files /dev/null and b/assets/weapons/bulldog.png differ diff --git a/assets/weapons/classic.png b/assets/weapons/classic.png new file mode 100644 index 00000000..5b44b4fa Binary files /dev/null and b/assets/weapons/classic.png differ diff --git a/assets/weapons/frenzy.png b/assets/weapons/frenzy.png new file mode 100644 index 00000000..de8cb7a2 Binary files /dev/null and b/assets/weapons/frenzy.png differ diff --git a/assets/weapons/ghost.png b/assets/weapons/ghost.png new file mode 100644 index 00000000..8463fac2 Binary files /dev/null and b/assets/weapons/ghost.png differ diff --git a/assets/weapons/guardian.png b/assets/weapons/guardian.png new file mode 100644 index 00000000..3e9b1b7a Binary files /dev/null and b/assets/weapons/guardian.png differ diff --git a/assets/weapons/judge.png b/assets/weapons/judge.png new file mode 100644 index 00000000..6b4a6831 Binary files /dev/null and b/assets/weapons/judge.png differ diff --git a/assets/weapons/marshal.png b/assets/weapons/marshal.png new file mode 100644 index 00000000..5841d1cb Binary files /dev/null and b/assets/weapons/marshal.png differ diff --git a/assets/weapons/odin.png b/assets/weapons/odin.png new file mode 100644 index 00000000..e288c697 Binary files /dev/null and b/assets/weapons/odin.png differ diff --git a/assets/weapons/operator.png b/assets/weapons/operator.png new file mode 100644 index 00000000..2c777a47 Binary files /dev/null and b/assets/weapons/operator.png differ diff --git a/assets/weapons/outlaw.png b/assets/weapons/outlaw.png new file mode 100644 index 00000000..7b971f6a Binary files /dev/null and b/assets/weapons/outlaw.png differ diff --git a/assets/weapons/phantom.png b/assets/weapons/phantom.png new file mode 100644 index 00000000..951487ab Binary files /dev/null and b/assets/weapons/phantom.png differ diff --git a/assets/weapons/sheriff.png b/assets/weapons/sheriff.png new file mode 100644 index 00000000..7b3a9e65 Binary files /dev/null and b/assets/weapons/sheriff.png differ diff --git a/assets/weapons/shorty.png b/assets/weapons/shorty.png new file mode 100644 index 00000000..5e53192f Binary files /dev/null and b/assets/weapons/shorty.png differ diff --git a/assets/weapons/spectre.png b/assets/weapons/spectre.png new file mode 100644 index 00000000..b7d4fc7f Binary files /dev/null and b/assets/weapons/spectre.png differ diff --git a/assets/weapons/stinger.png b/assets/weapons/stinger.png new file mode 100644 index 00000000..d9287e8b Binary files /dev/null and b/assets/weapons/stinger.png differ diff --git a/assets/weapons/vandal.png b/assets/weapons/vandal.png new file mode 100644 index 00000000..4d3662da Binary files /dev/null and b/assets/weapons/vandal.png differ diff --git a/docs/verification/agent-firearms/README.md b/docs/verification/agent-firearms/README.md new file mode 100644 index 00000000..97f311d7 --- /dev/null +++ b/docs/verification/agent-firearms/README.md @@ -0,0 +1,28 @@ +# Agent firearm verification + +Windows captures from commit `9a1c96ddc7416f5ce4f1cc29ad45113a67455126`, taken on 2026-09-13. The native test app uses in-memory providers and never opens a user's library. + +Run from PowerShell: + +```powershell +$env:ICARUS_WEAPON_TEST_ARTIFACT_DIR = "$PWD/firearm-captures" +flutter test integration_test/agent_weapon_test.dart -d windows +``` + +All 11 scenarios passed. [The native output](windows-test-output.txt) includes `gun overhang paints outside the portrait and has no hit target`. That scenario checks the badge bounds, moves and right-clicks outside the portrait, then requires actual white pixels outside the portrait in the rendered image. The pixel assertion is unconditional. + +The screenshots below were also inspected visually. Every gun is visible at the portrait's bottom-right, and all portraits keep the same dimensions. The white silhouette has a dark outline for contrast against the portrait. + +![All 19 firearm badges beside an agent without a firearm](all-firearms.png) + +![Right-click category menu with sidearms](sidearms-menu.png) + +The Windows CI run also passed all 493 unit/widget tests, including the overhang pixel assertion, with one existing skipped test: [CI run](https://github.com/SunkenInTime/icarus/actions/runs/34738623168). + +## Isolated pixel check + +Greptile's first review exposed a test timing issue. Running only the overhang test started with a cold image cache and captured before the firearm asset decoded. The full suite had already loaded it during the menu scenarios. The test now awaits `precacheImage` before inspecting pixels. This isolated command reproduces the original failure before that fix and passes after it: + +```powershell +flutter test test/agent_weapon_widgets_test.dart --plain-name 'gun overhang paints outside the portrait and has no hit target' +``` diff --git a/docs/verification/agent-firearms/all-firearms.png b/docs/verification/agent-firearms/all-firearms.png new file mode 100644 index 00000000..57ca1fc2 Binary files /dev/null and b/docs/verification/agent-firearms/all-firearms.png differ diff --git a/docs/verification/agent-firearms/sidearms-menu.png b/docs/verification/agent-firearms/sidearms-menu.png new file mode 100644 index 00000000..4a4e3e56 Binary files /dev/null and b/docs/verification/agent-firearms/sidearms-menu.png differ diff --git a/docs/verification/agent-firearms/windows-test-output.txt b/docs/verification/agent-firearms/windows-test-output.txt new file mode 100644 index 00000000..b0715605 --- /dev/null +++ b/docs/verification/agent-firearms/windows-test-output.txt @@ -0,0 +1,26 @@ +00:00 +0: loading C:/Users/shawn/.codex/worktrees/icarus-release/agent-firearms/integration_test/agent_weapon_test.dart +Building Windows application... +CMake Warning (dev) at flutter/ephemeral/.plugin_symlinks/flutter_inappwebview_windows/windows/CMakeLists.txt:31 (add_custom_command): + The following keywords are not supported when using + add_custom_command(TARGET): DEPENDS. + + Policy CMP0175 is not set: add_custom_command() rejects invalid arguments. + Run "cmake --help-policy CMP0175" for policy details. Use the cmake_policy + command to set the policy and suppress this warning. +This warning is for project developers. Use -Wno-dev to suppress it. + +Building Windows application... 47.3s +√ Built build\windows\x64\runner\Debug\icarus.exe +00:00 +0: plain agent selects, changes and removes a firearm by menu +00:05 +1: plain firearm preserves drag anchor at different sizes and zooms +00:07 +2: cone agent selects, changes and removes a firearm by menu +00:12 +3: cone firearm preserves drag anchor at different sizes and zooms +00:14 +4: circle agent selects, changes and removes a firearm by menu +00:18 +5: circle firearm preserves drag anchor at different sizes and zooms +00:21 +6: gun overhang paints outside the portrait and has no hit target +00:22 +7: lineup origin offers the same firearm menu +00:23 +8: lineup draft firearm edits do not change the source agent +00:25 +9: shared page and video renderer fades weapon changes at one anchor +00:27 +10: every bundled firearm renders beside an unchanged portrait +00:28 +11: (tearDownAll) +00:28 +11: All tests passed! diff --git a/integration_test/agent_weapon_test.dart b/integration_test/agent_weapon_test.dart new file mode 100644 index 00000000..8c621b46 --- /dev/null +++ b/integration_test/agent_weapon_test.dart @@ -0,0 +1,10 @@ +import 'package:integration_test/integration_test.dart'; + +import '../test/agent_weapon_widgets_test.dart' as scenarios; + +/// Runs the same interactions on the native desktop engine. The harness uses +/// in-memory providers and never opens the user's Hive library or other windows. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + scenarios.main(); +} diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 034be510..611f3bd3 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/action_provider.dart'; @@ -730,6 +731,15 @@ class LineUpProvider extends Notifier { state = state.copyWith(placement: state.placement!.copyWith(draftAgent: draft)); } + void setDraftAgentWeapon(WeaponType weapon) { + final placement = state.placement; + final draft = placement?.draftAgent; + if (placement == null || draft == null || draft.weapon == weapon) return; + state = state.copyWith( + placement: placement.copyWith(draftAgent: draft.copyWith(weapon: weapon)), + ); + } + void updateDraftAbilityPosition(Offset position) { final draft = state.placement?.draftAbility; if (draft == null) return; @@ -818,6 +828,30 @@ class LineUpProvider extends Notifier { return link; } + void setOriginWeapon(String originId, WeaponType weapon) { + final index = state.origins.indexWhere((origin) => origin.id == originId); + if (index < 0 || state.origins[index].agent.weapon == weapon) return; + final previous = state.origins[index].agent.weapon; + _applyOriginWeapon(originId, weapon); + ref.read(actionProvider.notifier).addAction(WeaponSelectionAction( + id: originId, + group: ActionGroup.lineUp, + before: previous, + after: weapon, + )); + } + + void _applyOriginWeapon(String originId, WeaponType weapon) { + final index = state.origins.indexWhere((origin) => origin.id == originId); + if (index < 0) return; + final origins = [...state.origins]; + final origin = origins[index]; + origins[index] = origin.copyWith( + agent: origin.agent.copyWith(weapon: weapon), + ); + state = state.copyWith(origins: origins); + } + // --- Edits (no action recorded; callers wrap in performTransaction) ------- void updateLink(LineUpLink link) { @@ -956,6 +990,10 @@ class LineUpProvider extends Notifier { } void undoAction(UserAction action) { + if (action is WeaponSelectionAction) { + _applyOriginWeapon(action.id, action.before); + return; + } switch (action.type) { case ActionType.addition: _removeForAction(action.id); @@ -971,6 +1009,10 @@ class LineUpProvider extends Notifier { } void redoAction(UserAction action) { + if (action is WeaponSelectionAction) { + _applyOriginWeapon(action.id, action.after); + return; + } switch (action.type) { case ActionType.addition: _restoreForAction(action.id); diff --git a/lib/const/placed_classes.dart b/lib/const/placed_classes.dart index d0aadb6e..950519fe 100644 --- a/lib/const/placed_classes.dart +++ b/lib/const/placed_classes.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:icarus/const/abilities.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/json_converters.dart'; import 'package:icarus/const/utilities.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -312,12 +313,16 @@ sealed class PlacedAgentNode extends PlacedWidget { @JsonKey(defaultValue: AgentState.none) AgentState state; + @JsonKey(defaultValue: WeaponType.none) + WeaponType weapon; + PlacedAgentNode({ required this.type, required super.position, required super.id, this.isAlly = true, this.state = AgentState.none, + this.weapon = WeaponType.none, }); String get kind; @@ -362,6 +367,7 @@ class PlacedAgent extends PlacedAgentNode { super.isAlly = true, this.lineUpID, super.state = AgentState.none, + super.weapon = WeaponType.none, }); @override @@ -383,6 +389,7 @@ class PlacedAgent extends PlacedAgentNode { bool? isAlly, String? lineUpID, AgentState? state, + WeaponType? weapon, }) { final copied = PlacedAgent( type: type ?? this.type, @@ -391,6 +398,7 @@ class PlacedAgent extends PlacedAgentNode { isAlly: isAlly ?? this.isAlly, lineUpID: lineUpID ?? this.lineUpID, state: state ?? this.state, + weapon: weapon ?? this.weapon, ); copied.isDeleted = isDeleted; return copied; @@ -451,6 +459,7 @@ class PlacedViewConeAgent extends PlacedAgentNode { this.visionElevation, super.isAlly = true, super.state = AgentState.none, + super.weapon = WeaponType.none, }) : assert( UtilityData.isViewConePresetType(presetType), 'presetType must be a view cone preset.', @@ -546,6 +555,7 @@ class PlacedViewConeAgent extends PlacedAgentNode { String? id, bool? isAlly, AgentState? state, + WeaponType? weapon, UtilityType? presetType, double? rotation, double? length, @@ -557,6 +567,7 @@ class PlacedViewConeAgent extends PlacedAgentNode { id: id ?? this.id, isAlly: isAlly ?? this.isAlly, state: state ?? this.state, + weapon: weapon ?? this.weapon, presetType: presetType ?? this.presetType, rotation: rotation ?? this.rotation, length: length ?? this.length, @@ -584,6 +595,7 @@ class PlacedCircleAgent extends PlacedAgentNode { this.opacityPercent = 100, super.isAlly = true, super.state = AgentState.none, + super.weapon = WeaponType.none, }); @override @@ -674,6 +686,7 @@ class PlacedCircleAgent extends PlacedAgentNode { String? id, bool? isAlly, AgentState? state, + WeaponType? weapon, double? diameterMeters, int? colorValue, int? opacityPercent, @@ -684,6 +697,7 @@ class PlacedCircleAgent extends PlacedAgentNode { id: id ?? this.id, isAlly: isAlly ?? this.isAlly, state: state ?? this.state, + weapon: weapon ?? this.weapon, diameterMeters: diameterMeters ?? this.diameterMeters, colorValue: colorValue ?? this.colorValue, opacityPercent: opacityPercent ?? this.opacityPercent, diff --git a/lib/const/placed_classes.g.dart b/lib/const/placed_classes.g.dart index bbf236e8..ec5ef96a 100644 --- a/lib/const/placed_classes.g.dart +++ b/lib/const/placed_classes.g.dart @@ -80,6 +80,8 @@ PlacedAgent _$PlacedAgentFromJson(Map json) => PlacedAgent( state: json['state'] == null ? AgentState.none : const AgentStateCompatConverter().fromJson(json['state']), + weapon: $enumDecodeNullable(_$WeaponTypeEnumMap, json['weapon']) ?? + WeaponType.none, )..isDeleted = json['isDeleted'] as bool? ?? false; Map _$PlacedAgentToJson(PlacedAgent instance) => @@ -90,9 +92,33 @@ Map _$PlacedAgentToJson(PlacedAgent instance) => 'type': const AgentTypeCompatConverter().toJson(instance.type), 'isAlly': instance.isAlly, 'state': const AgentStateCompatConverter().toJson(instance.state), + 'weapon': _$WeaponTypeEnumMap[instance.weapon]!, 'lineUpID': instance.lineUpID, }; +const _$WeaponTypeEnumMap = { + WeaponType.none: 'none', + WeaponType.classic: 'classic', + WeaponType.shorty: 'shorty', + WeaponType.frenzy: 'frenzy', + WeaponType.ghost: 'ghost', + WeaponType.bandit: 'bandit', + WeaponType.sheriff: 'sheriff', + WeaponType.bulldog: 'bulldog', + WeaponType.guardian: 'guardian', + WeaponType.phantom: 'phantom', + WeaponType.vandal: 'vandal', + WeaponType.bucky: 'bucky', + WeaponType.judge: 'judge', + WeaponType.stinger: 'stinger', + WeaponType.spectre: 'spectre', + WeaponType.marshal: 'marshal', + WeaponType.outlaw: 'outlaw', + WeaponType.operator: 'operator', + WeaponType.ares: 'ares', + WeaponType.odin: 'odin', +}; + PlacedViewConeAgent _$PlacedViewConeAgentFromJson(Map json) => PlacedViewConeAgent( type: const AgentTypeCompatConverter().fromJson(json['type']), @@ -108,6 +134,8 @@ PlacedViewConeAgent _$PlacedViewConeAgentFromJson(Map json) => state: json['state'] == null ? AgentState.none : const AgentStateCompatConverter().fromJson(json['state']), + weapon: $enumDecodeNullable(_$WeaponTypeEnumMap, json['weapon']) ?? + WeaponType.none, )..isDeleted = json['isDeleted'] as bool? ?? false; Map _$PlacedViewConeAgentToJson( @@ -119,6 +147,7 @@ Map _$PlacedViewConeAgentToJson( 'type': const AgentTypeCompatConverter().toJson(instance.type), 'isAlly': instance.isAlly, 'state': const AgentStateCompatConverter().toJson(instance.state), + 'weapon': _$WeaponTypeEnumMap[instance.weapon]!, 'presetType': const UtilityTypeCompatConverter().toJson(instance.presetType), 'rotation': instance.rotation, @@ -139,6 +168,8 @@ PlacedCircleAgent _$PlacedCircleAgentFromJson(Map json) => state: json['state'] == null ? AgentState.none : const AgentStateCompatConverter().fromJson(json['state']), + weapon: $enumDecodeNullable(_$WeaponTypeEnumMap, json['weapon']) ?? + WeaponType.none, )..isDeleted = json['isDeleted'] as bool? ?? false; Map _$PlacedCircleAgentToJson(PlacedCircleAgent instance) => @@ -149,6 +180,7 @@ Map _$PlacedCircleAgentToJson(PlacedCircleAgent instance) => 'type': const AgentTypeCompatConverter().toJson(instance.type), 'isAlly': instance.isAlly, 'state': const AgentStateCompatConverter().toJson(instance.state), + 'weapon': _$WeaponTypeEnumMap[instance.weapon]!, 'diameterMeters': instance.diameterMeters, 'colorValue': instance.colorValue, 'opacityPercent': instance.opacityPercent, diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 2b8b9122..fa667906 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -40,6 +40,11 @@ class Settings { static const double agentSize = 35; static const double agentSizeMin = 15; static const double agentSizeMax = 45; + static const double agentWeaponWidthRatio = 0.70; + static const double agentWeaponHeightRatio = 0.40; + static const double agentWeaponRightOverhangRatio = 0.18; + static const double agentWeaponBottomOverhangRatio = 0.12; + static const double agentWeaponOutlineWidthRatio = 0.035; static const double abilitySize = 25; static const double abilitySizeMin = 15; @@ -100,7 +105,7 @@ class Settings { static final Uri dicordLink = Uri.parse("https://discord.gg/PN2uKwCqYB"); static const Duration autoSaveOffset = Duration(seconds: 15); - static const int versionNumber = 99; + static const int versionNumber = 100; static const String versionName = "4.6.1"; static final Uri desktopUpdaterArchiveUrl = buildDesktopUpdaterArchiveUrl(kResolvedUpdateChannel); diff --git a/lib/const/transition_data.dart b/lib/const/transition_data.dart index a91d00de..8716fb08 100644 --- a/lib/const/transition_data.dart +++ b/lib/const/transition_data.dart @@ -4,6 +4,7 @@ import 'package:icarus/const/agents.dart'; import 'package:icarus/const/abilities.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; @@ -481,7 +482,10 @@ class PageTransitionEntry { return null; } - /// Every property a [PageTransitionEntry.move] can tween, as one comparable + static WeaponType? weaponOf(PlacedWidget w) => + w is PlacedAgentNode ? w.weapon : null; + + /// Every property a [PageTransitionEntry.move] can animate, as one comparable /// list. Kept next to the `*Of` helpers so adding a tweened property means /// updating both in the same place. static List _tweenedPropsOf(PlacedWidget w) => [ @@ -491,6 +495,7 @@ class PageTransitionEntry { scaleOf(w), textSizeOf(w), agentStateOf(w), + weaponOf(w), customDiameterOf(w), customWidthOf(w), customLengthOf(w), diff --git a/lib/const/weapons.dart b/lib/const/weapons.dart new file mode 100644 index 00000000..ab4183cb --- /dev/null +++ b/lib/const/weapons.dart @@ -0,0 +1,47 @@ +/// Firearms available for a placed agent. Names are the stable JSON values; +/// append new entries rather than reordering the existing Hive enum values. +enum WeaponType { + none('None', null), + classic('Classic', WeaponCategory.sidearms), + shorty('Shorty', WeaponCategory.sidearms), + frenzy('Frenzy', WeaponCategory.sidearms), + ghost('Ghost', WeaponCategory.sidearms), + bandit('Bandit', WeaponCategory.sidearms), + sheriff('Sheriff', WeaponCategory.sidearms), + bulldog('Bulldog', WeaponCategory.rifles), + guardian('Guardian', WeaponCategory.rifles), + phantom('Phantom', WeaponCategory.rifles), + vandal('Vandal', WeaponCategory.rifles), + bucky('Bucky', WeaponCategory.shotguns), + judge('Judge', WeaponCategory.shotguns), + stinger('Stinger', WeaponCategory.smgs), + spectre('Spectre', WeaponCategory.smgs), + marshal('Marshal', WeaponCategory.snipers), + outlaw('Outlaw', WeaponCategory.snipers), + operator('Operator', WeaponCategory.snipers), + ares('Ares', WeaponCategory.heavies), + odin('Odin', WeaponCategory.heavies); + + const WeaponType(this.displayName, this.category); + + final String displayName; + final WeaponCategory? category; + + String get iconPath => 'assets/weapons/$name.png'; +} + +enum WeaponCategory { + sidearms('Sidearms'), + rifles('Rifles'), + shotguns('Shotguns'), + smgs('SMGs'), + snipers('Snipers'), + heavies('Heavies'); + + const WeaponCategory(this.label); + + final String label; + + Iterable get weapons => + WeaponType.values.where((weapon) => weapon.category == this); +} diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 11f8253c..0ba01696 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -6,6 +6,7 @@ import 'dart:ui' show Offset; import 'package:flutter/material.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/bounding_box.dart'; import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/folder_icons.dart'; @@ -53,6 +54,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), + AdapterSpec(), ]) part 'hive_adapters.g.dart'; diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index aaf50d68..e3d6e5a6 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -161,13 +161,14 @@ class PlacedAgentAdapter extends TypeAdapter { isAlly: fields[1] == null ? true : fields[1] as bool, lineUpID: fields[5] as String?, state: fields[6] == null ? AgentState.none : fields[6] as AgentState, + weapon: fields[7] == null ? WeaponType.none : fields[7] as WeaponType, )..isDeleted = fields[3] as bool; } @override void write(BinaryWriter writer, PlacedAgent obj) { writer - ..writeByte(7) + ..writeByte(8) ..writeByte(0) ..write(obj.type) ..writeByte(1) @@ -181,7 +182,9 @@ class PlacedAgentAdapter extends TypeAdapter { ..writeByte(5) ..write(obj.lineUpID) ..writeByte(6) - ..write(obj.state); + ..write(obj.state) + ..writeByte(7) + ..write(obj.weapon); } @override @@ -1421,13 +1424,14 @@ class PlacedViewConeAgentAdapter extends TypeAdapter { visionElevation: (fields[9] as num?)?.toDouble(), isAlly: fields[4] == null ? true : fields[4] as bool, state: fields[5] == null ? AgentState.none : fields[5] as AgentState, + weapon: fields[10] == null ? WeaponType.none : fields[10] as WeaponType, )..isDeleted = fields[7] as bool; } @override void write(BinaryWriter writer, PlacedViewConeAgent obj) { writer - ..writeByte(10) + ..writeByte(11) ..writeByte(0) ..write(obj.presetType) ..writeByte(1) @@ -1447,7 +1451,9 @@ class PlacedViewConeAgentAdapter extends TypeAdapter { ..writeByte(8) ..write(obj.position) ..writeByte(9) - ..write(obj.visionElevation); + ..write(obj.visionElevation) + ..writeByte(10) + ..write(obj.weapon); } @override @@ -1480,13 +1486,14 @@ class PlacedCircleAgentAdapter extends TypeAdapter { opacityPercent: fields[2] == null ? 100 : (fields[2] as num).toInt(), isAlly: fields[4] == null ? true : fields[4] as bool, state: fields[5] == null ? AgentState.none : fields[5] as AgentState, + weapon: fields[9] == null ? WeaponType.none : fields[9] as WeaponType, )..isDeleted = fields[7] as bool; } @override void write(BinaryWriter writer, PlacedCircleAgent obj) { writer - ..writeByte(9) + ..writeByte(10) ..writeByte(0) ..write(obj.diameterMeters) ..writeByte(1) @@ -1504,7 +1511,9 @@ class PlacedCircleAgentAdapter extends TypeAdapter { ..writeByte(7) ..write(obj.isDeleted) ..writeByte(8) - ..write(obj.position); + ..write(obj.position) + ..writeByte(9) + ..write(obj.weapon); } @override @@ -1779,3 +1788,112 @@ class LineUpLinkAdapter extends TypeAdapter { runtimeType == other.runtimeType && typeId == other.typeId; } + +class WeaponTypeAdapter extends TypeAdapter { + @override + final typeId = 38; + + @override + WeaponType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return WeaponType.none; + case 1: + return WeaponType.classic; + case 2: + return WeaponType.shorty; + case 3: + return WeaponType.frenzy; + case 4: + return WeaponType.ghost; + case 5: + return WeaponType.bandit; + case 6: + return WeaponType.sheriff; + case 7: + return WeaponType.bulldog; + case 8: + return WeaponType.guardian; + case 9: + return WeaponType.phantom; + case 10: + return WeaponType.vandal; + case 11: + return WeaponType.bucky; + case 12: + return WeaponType.judge; + case 13: + return WeaponType.stinger; + case 14: + return WeaponType.spectre; + case 15: + return WeaponType.marshal; + case 16: + return WeaponType.outlaw; + case 17: + return WeaponType.operator; + case 18: + return WeaponType.ares; + case 19: + return WeaponType.odin; + default: + return WeaponType.none; + } + } + + @override + void write(BinaryWriter writer, WeaponType obj) { + switch (obj) { + case WeaponType.none: + writer.writeByte(0); + case WeaponType.classic: + writer.writeByte(1); + case WeaponType.shorty: + writer.writeByte(2); + case WeaponType.frenzy: + writer.writeByte(3); + case WeaponType.ghost: + writer.writeByte(4); + case WeaponType.bandit: + writer.writeByte(5); + case WeaponType.sheriff: + writer.writeByte(6); + case WeaponType.bulldog: + writer.writeByte(7); + case WeaponType.guardian: + writer.writeByte(8); + case WeaponType.phantom: + writer.writeByte(9); + case WeaponType.vandal: + writer.writeByte(10); + case WeaponType.bucky: + writer.writeByte(11); + case WeaponType.judge: + writer.writeByte(12); + case WeaponType.stinger: + writer.writeByte(13); + case WeaponType.spectre: + writer.writeByte(14); + case WeaponType.marshal: + writer.writeByte(15); + case WeaponType.outlaw: + writer.writeByte(16); + case WeaponType.operator: + writer.writeByte(17); + case WeaponType.ares: + writer.writeByte(18); + case WeaponType.odin: + writer.writeByte(19); + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WeaponTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index abc02400..218c5438 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -1,7 +1,7 @@ # Generated by Hive CE # Manual modifications may be necessary for certain migrations # Check in to version control -nextTypeId: 38 +nextTypeId: 39 types: StrategyData: typeId: 0 @@ -55,7 +55,7 @@ types: index: 2 PlacedAgent: typeId: 2 - nextIndex: 7 + nextIndex: 8 fields: type: index: 0 @@ -71,6 +71,8 @@ types: index: 5 state: index: 6 + weapon: + index: 7 PlacedAbility: typeId: 3 nextIndex: 10 @@ -491,7 +493,7 @@ types: index: 18 PlacedViewConeAgent: typeId: 29 - nextIndex: 10 + nextIndex: 11 fields: presetType: index: 0 @@ -513,9 +515,11 @@ types: index: 8 visionElevation: index: 9 + weapon: + index: 10 PlacedCircleAgent: typeId: 30 - nextIndex: 9 + nextIndex: 10 fields: diameterMeters: index: 0 @@ -535,6 +539,8 @@ types: index: 7 position: index: 8 + weapon: + index: 9 AbilityVisualState: typeId: 32 nextIndex: 7 @@ -607,3 +613,47 @@ types: index: 5 images: index: 6 + WeaponType: + typeId: 38 + nextIndex: 20 + fields: + none: + index: 0 + classic: + index: 1 + shorty: + index: 2 + frenzy: + index: 3 + ghost: + index: 4 + bandit: + index: 5 + sheriff: + index: 6 + bulldog: + index: 7 + guardian: + index: 8 + phantom: + index: 9 + vandal: + index: 10 + bucky: + index: 11 + judge: + index: 12 + stinger: + index: 13 + spectre: + index: 14 + marshal: + index: 15 + outlaw: + index: 16 + operator: + index: 17 + ares: + index: 18 + odin: + index: 19 diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index a3e0419a..4fda4f8f 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -40,6 +40,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(StrategySettingsAdapter()); registerAdapter(TraversalSpeedProfileAdapter()); registerAdapter(UtilityTypeAdapter()); + registerAdapter(WeaponTypeAdapter()); } } @@ -77,5 +78,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(StrategySettingsAdapter()); registerAdapter(TraversalSpeedProfileAdapter()); registerAdapter(UtilityTypeAdapter()); + registerAdapter(WeaponTypeAdapter()); } } diff --git a/lib/migrations/agent_weapon_migration.dart b/lib/migrations/agent_weapon_migration.dart new file mode 100644 index 00000000..ef290df3 --- /dev/null +++ b/lib/migrations/agent_weapon_migration.dart @@ -0,0 +1,13 @@ +import 'package:icarus/providers/strategy_provider.dart'; + +/// Version 100 adds an optional firearm to every kind of placed agent, +/// including lineup origins. Hive and JSON readers supply WeaponType.none +/// when the field is absent. No positions, sizes, or lineup links change. +class AgentWeaponMigration { + static const int version = 100; + + static StrategyData migrate(StrategyData strategy) { + if (strategy.versionNumber >= version) return strategy; + return strategy.copyWith(versionNumber: version); + } +} diff --git a/lib/providers/action_provider.dart b/lib/providers/action_provider.dart index 5afc3b32..8c5775e4 100644 --- a/lib/providers/action_provider.dart +++ b/lib/providers/action_provider.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; @@ -107,6 +108,20 @@ class UserAction { final actionProvider = NotifierProvider>(ActionProvider.new); +/// Records only the firearm, so undoing it never restores an older copy of an +/// agent's movement history or a lineup's graph. Shared by both agent groups. +class WeaponSelectionAction extends UserAction { + WeaponSelectionAction({ + required super.id, + required super.group, + required this.before, + required this.after, + }) : super(type: ActionType.edit); + + final WeaponType before; + final WeaponType after; +} + class ActionProvider extends Notifier> { static const List _clearableGroups = [ ActionGroup.agent, diff --git a/lib/providers/agent_provider.dart b/lib/providers/agent_provider.dart index 1682627f..d31d6fd2 100644 --- a/lib/providers/agent_provider.dart +++ b/lib/providers/agent_provider.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/action_provider.dart'; @@ -83,6 +84,26 @@ class AgentProvider extends Notifier> { state = newState; } + void setWeapon(String id, WeaponType weapon) { + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0 || state[index].weapon == weapon) return; + final previous = state[index].weapon; + _applyWeapon(id, weapon); + ref.read(actionProvider.notifier).addAction(WeaponSelectionAction( + id: id, + group: ActionGroup.agent, + before: previous, + after: weapon, + )); + } + + void _applyWeapon(String id, WeaponType weapon) { + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; + state[index].weapon = weapon; + state = [...state]; + } + void updatePosition(Offset position, String id) { final newState = [...state]; @@ -218,6 +239,7 @@ class AgentProvider extends Notifier> { type: node.type, isAlly: node.isAlly, state: node.state, + weapon: node.weapon, presetType: presetType, rotation: rotation, length: length, @@ -244,6 +266,7 @@ class AgentProvider extends Notifier> { type: node.type, isAlly: node.isAlly, state: node.state, + weapon: node.weapon, )..isDeleted = node.isDeleted; ref.read(actionProvider.notifier).addAction( @@ -271,6 +294,7 @@ class AgentProvider extends Notifier> { type: node.type, isAlly: node.isAlly, state: node.state, + weapon: node.weapon, diameterMeters: diameterMeters, colorValue: colorValue, opacityPercent: opacityPercent, @@ -284,6 +308,10 @@ class AgentProvider extends Notifier> { } void undoAction(UserAction action) { + if (action is WeaponSelectionAction) { + _applyWeapon(action.id, action.before); + return; + } switch (action.type) { case ActionType.addition: removeAgent(action.id); @@ -320,6 +348,10 @@ class AgentProvider extends Notifier> { } void redoAction(UserAction action) { + if (action is WeaponSelectionAction) { + _applyWeapon(action.id, action.after); + return; + } final newState = [...state]; try { diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 1897b02f..6e2433e0 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -19,6 +19,7 @@ import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/migrations/ability_vision_cone_migration.dart'; +import 'package:icarus/migrations/agent_weapon_migration.dart'; import 'package:icarus/migrations/ability_scale_migration.dart'; import 'package:icarus/migrations/canonical_coordinates_migration.dart'; import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; @@ -647,10 +648,11 @@ class StrategyProvider extends Notifier { pageNameMigrated, force: needsCanonicalCoordinatesMigration, ); - return migrateSunsetScale( + final sunsetMigrated = migrateSunsetScale( canonicalMigrated, force: originalVersion < SunsetScaleMigration.version, ); + return AgentWeaponMigration.migrate(sunsetMigrated); } static StrategyData migrateSunsetScale( diff --git a/lib/widgets/draggable_widgets/agents/agent_weapon_badge.dart b/lib/widgets/draggable_widgets/agents/agent_weapon_badge.dart new file mode 100644 index 00000000..9c94b385 --- /dev/null +++ b/lib/widgets/draggable_widgets/agents/agent_weapon_badge.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/weapon_icon.dart'; + +class AgentWeaponBadge extends StatelessWidget { + const AgentWeaponBadge({ + super.key, + required this.weapon, + required this.agentSize, + this.previousWeapon, + this.transitionProgress = 1, + }); + + final WeaponType weapon; + final double agentSize; + final WeaponType? previousWeapon; + final double transitionProgress; + + @override + Widget build(BuildContext context) { + final width = agentSize * Settings.agentWeaponWidthRatio; + final height = agentSize * Settings.agentWeaponHeightRatio; + final previous = previousWeapon ?? weapon; + final progress = transitionProgress.clamp(0.0, 1.0); + final changing = previous != weapon; + + Widget icon(WeaponType value, double opacity) => Positioned.fill( + child: Opacity( + opacity: opacity, + child: WeaponIcon( + weapon: value, + width: width, + height: height, + outlineColor: Settings.tacticalVioletTheme.background, + outlineWidth: agentSize * Settings.agentWeaponOutlineWidthRatio, + ), + ), + ); + + return IgnorePointer( + child: SizedBox( + width: width, + height: height, + child: Stack( + children: [ + if (changing && previous != WeaponType.none && progress < 1) + icon(previous, 1 - progress), + if (weapon != WeaponType.none && (!changing || progress > 0)) + icon(weapon, changing ? progress : 1), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart b/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart new file mode 100644 index 00000000..067bf107 --- /dev/null +++ b/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/weapon_icon.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +List buildAgentWeaponMenu({ + required WeaponType selectedWeapon, + required ValueChanged onSelected, +}) { + Widget selectionMark(WeaponType weapon) => SizedBox( + width: 16, + height: 16, + child: selectedWeapon == weapon + ? const Icon(LucideIcons.check, size: 16) + : null, + ); + + return [ + for (final category in WeaponCategory.values) + ShadContextMenuItem( + trailing: const Icon(LucideIcons.chevronRight, size: 16), + items: [ + for (final weapon in category.weapons) + ShadContextMenuItem( + leading: WeaponIcon(weapon: weapon, width: 36, height: 20), + trailing: selectionMark(weapon), + onPressed: () => onSelected(weapon), + child: Text(weapon.displayName), + ), + ], + child: Text(category.label), + ), + ShadContextMenuItem( + trailing: selectionMark(WeaponType.none), + onPressed: () => onSelected(WeaponType.none), + child: const Text('None'), + ), + ]; +} diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index 6be6ff9e..f13e0a17 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/agent_weapon_menu.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/agent_weapon_badge.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; @@ -85,6 +88,10 @@ class AgentWidget extends ConsumerWidget { required this.isAlly, this.lineUpId, this.state = AgentState.none, + this.weapon = WeaponType.none, + this.previousWeapon, + this.weaponTransitionProgress = 1, + this.onWeaponSelected, this.forcedAgentSize, this.deadStateProgress, this.isInteractive = true, @@ -95,6 +102,12 @@ class AgentWidget extends ConsumerWidget { final bool isAlly; final AgentData agent; final AgentState state; + final WeaponType weapon; + final WeaponType? previousWeapon; + final double weaponTransitionProgress; + + /// Lineup drafts supply their own editor because they are not placed agents. + final ValueChanged? onWeaponSelected; final double? forcedAgentSize; final double? deadStateProgress; final bool isInteractive; @@ -299,6 +312,25 @@ class AgentWidget extends ConsumerWidget { .update(InteractionState.lineUpPlacing); }, ), + if (canInteract && + (placedAgentNode != null || + lineUpId != null || + onWeaponSelected != null)) + ...buildAgentWeaponMenu( + selectedWeapon: weapon, + onSelected: (selectedWeapon) { + if (onWeaponSelected != null) { + onWeaponSelected!(selectedWeapon); + } else if (lineUpId != null) { + ref.read(lineUpProvider.notifier).setOriginWeapon( + lineUpId!, + selectedWeapon, + ); + } else if (id != null) { + ref.read(agentProvider.notifier).setWeapon(id!, selectedWeapon); + } + }, + ), ...adjacentPageCopyItems, ]; @@ -334,6 +366,28 @@ class AgentWidget extends ConsumerWidget { ); } + // Only the square portrait participates in layout and hit testing. + // A positioned decoration can paint outside it without moving its anchor. + if (weapon != WeaponType.none || + (previousWeapon != null && previousWeapon != WeaponType.none)) { + agentCard = Stack( + clipBehavior: Clip.none, + children: [ + agentCard, + Positioned( + right: -scaledSize * Settings.agentWeaponRightOverhangRatio, + bottom: -scaledSize * Settings.agentWeaponBottomOverhangRatio, + child: AgentWeaponBadge( + weapon: weapon, + agentSize: scaledSize, + previousWeapon: previousWeapon, + transitionProgress: weaponTransitionProgress, + ), + ), + ], + ); + } + if (!canInteract) { return RepaintBoundary(child: agentCard); } diff --git a/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart index 336abb71..9b440556 100644 --- a/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -52,11 +53,15 @@ class CircleAgentComposite extends ConsumerWidget { super.key, required this.agent, this.forcedAgentSize, + this.previousWeapon, + this.weaponTransitionProgress = 1, this.isInteractive = true, }); final PlacedCircleAgent agent; final double? forcedAgentSize; + final WeaponType? previousWeapon; + final double weaponTransitionProgress; final bool isInteractive; @override @@ -103,6 +108,9 @@ class CircleAgentComposite extends ConsumerWidget { isAlly: agent.isAlly, id: agent.id, agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, + previousWeapon: previousWeapon, + weaponTransitionProgress: weaponTransitionProgress, forcedAgentSize: agentSize, isInteractive: isInteractive, ), diff --git a/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart index 6a18c4b0..f2964947 100644 --- a/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; @@ -53,6 +54,7 @@ class PlacedLineupAgentWidget extends ConsumerWidget { isAlly: agent.isAlly, id: '', agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, ), ), ), @@ -63,6 +65,9 @@ class PlacedLineupAgentWidget extends ConsumerWidget { isAlly: agent.isAlly, id: agent.id, agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, + onWeaponSelected: + ref.read(lineUpProvider.notifier).setDraftAgentWeapon, ), ), ) @@ -72,6 +77,7 @@ class PlacedLineupAgentWidget extends ConsumerWidget { isAlly: agent.isAlly, id: agent.id, agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, ), ), ); diff --git a/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart index 55de65a2..26b004a4 100644 --- a/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; @@ -50,6 +51,8 @@ class ViewConeAgentComposite extends ConsumerWidget { required this.rotation, required this.length, this.forcedAgentSize, + this.previousWeapon, + this.weaponTransitionProgress = 1, this.applyRotation = true, this.clipToGeometry = true, this.isInteractive = true, @@ -59,6 +62,8 @@ class ViewConeAgentComposite extends ConsumerWidget { final double rotation; final double length; final double? forcedAgentSize; + final WeaponType? previousWeapon; + final double weaponTransitionProgress; final bool applyRotation; final bool clipToGeometry; final bool isInteractive; @@ -117,6 +122,9 @@ class ViewConeAgentComposite extends ConsumerWidget { isAlly: agent.isAlly, id: agent.id, agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, + previousWeapon: previousWeapon, + weaponTransitionProgress: weaponTransitionProgress, forcedAgentSize: agentSize, isInteractive: isInteractive, ), diff --git a/lib/widgets/draggable_widgets/agents/weapon_icon.dart b/lib/widgets/draggable_widgets/agents/weapon_icon.dart new file mode 100644 index 00000000..5a59e404 --- /dev/null +++ b/lib/widgets/draggable_widgets/agents/weapon_icon.dart @@ -0,0 +1,99 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:icarus/const/weapons.dart'; + +/// Fits the visible silhouette, rather than the transparent kill-feed canvas. +/// The bundled source images stay untouched; these bounds only affect painting. +class WeaponIcon extends StatelessWidget { + const WeaponIcon({ + super.key, + required this.weapon, + required this.width, + required this.height, + this.color = Colors.white, + this.outlineColor, + this.outlineWidth = 0, + }); + + final WeaponType weapon; + final double width; + final double height; + final Color color; + final Color? outlineColor; + final double outlineWidth; + + @override + Widget build(BuildContext context) { + if (weapon == WeaponType.none) return const SizedBox.shrink(); + final (size, bounds) = _artworkBounds[weapon]!; + final icon = SizedBox( + width: width, + height: height, + child: FittedBox( + fit: BoxFit.contain, + child: ClipRect( + child: SizedBox( + width: bounds.width, + height: bounds.height, + child: Stack( + children: [ + Positioned( + left: -bounds.left, + top: -bounds.top, + width: size.width, + height: size.height, + child: Image.asset( + weapon.iconPath, + color: color, + filterQuality: FilterQuality.medium, + excludeFromSemantics: true, + ), + ), + ], + ), + ), + ), + ), + ); + if (outlineColor == null || outlineWidth <= 0) return icon; + return Stack( + clipBehavior: Clip.none, + children: [ + ImageFiltered( + imageFilter: ui.ImageFilter.dilate( + radiusX: outlineWidth, + radiusY: outlineWidth, + ), + child: ColorFiltered( + colorFilter: ColorFilter.mode(outlineColor!, BlendMode.srcIn), + child: icon, + ), + ), + icon, + ], + ); + } +} + +const _artworkBounds = { + WeaponType.classic: (Size(256, 96), Rect.fromLTRB(72, 6, 183, 90)), + WeaponType.shorty: (Size(256, 96), Rect.fromLTRB(1, 12, 255, 90)), + WeaponType.frenzy: (Size(256, 96), Rect.fromLTRB(72, 6, 186, 92)), + WeaponType.ghost: (Size(256, 96), Rect.fromLTRB(1, 10, 256, 89)), + WeaponType.bandit: (Size(256, 140), Rect.fromLTRB(20, 5, 237, 134)), + WeaponType.sheriff: (Size(256, 96), Rect.fromLTRB(32, 3, 214, 94)), + WeaponType.bulldog: (Size(384, 96), Rect.fromLTRB(36, 1, 346, 95)), + WeaponType.guardian: (Size(384, 96), Rect.fromLTRB(2, 7, 382, 87)), + WeaponType.phantom: (Size(384, 96), Rect.fromLTRB(2, 4, 383, 92)), + WeaponType.vandal: (Size(384, 96), Rect.fromLTRB(34, 1, 342, 96)), + WeaponType.bucky: (Size(384, 96), Rect.fromLTRB(2, 10, 382, 84)), + WeaponType.judge: (Size(384, 96), Rect.fromLTRB(34, 0, 350, 95)), + WeaponType.stinger: (Size(256, 96), Rect.fromLTRB(3, 1, 251, 95)), + WeaponType.spectre: (Size(256, 96), Rect.fromLTRB(0, 3, 255, 94)), + WeaponType.marshal: (Size(512, 96), Rect.fromLTRB(1, 3, 510, 92)), + WeaponType.outlaw: (Size(512, 96), Rect.fromLTRB(35, 0, 480, 96)), + WeaponType.operator: (Size(512, 96), Rect.fromLTRB(18, 0, 494, 95)), + WeaponType.ares: (Size(512, 96), Rect.fromLTRB(18, 1, 495, 96)), + WeaponType.odin: (Size(384, 96), Rect.fromLTRB(18, 1, 361, 96)), +}; diff --git a/lib/widgets/draggable_widgets/placed_widget_builder.dart b/lib/widgets/draggable_widgets/placed_widget_builder.dart index 46510871..0defd84d 100644 --- a/lib/widgets/draggable_widgets/placed_widget_builder.dart +++ b/lib/widgets/draggable_widgets/placed_widget_builder.dart @@ -582,6 +582,7 @@ class _AgentListState extends ConsumerState<_AgentList> { isAlly: agent.isAlly, id: "", agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, ), ), ), @@ -616,6 +617,7 @@ class _AgentListState extends ConsumerState<_AgentList> { isAlly: agent.isAlly, id: agent.id, agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, ), ), ), diff --git a/lib/widgets/line_up_widget.dart b/lib/widgets/line_up_widget.dart index f4468529..f82c1e5e 100644 --- a/lib/widgets/line_up_widget.dart +++ b/lib/widgets/line_up_widget.dart @@ -57,6 +57,7 @@ class LineUpOriginAgentWidget extends ConsumerWidget { lineUpId: origin.id, agent: AgentData.agents[origin.agent.type]!, isAlly: origin.agent.isAlly, + weapon: origin.agent.weapon, id: origin.agent.id, isInteractive: interactive, ), diff --git a/lib/widgets/page_transition_overlay.dart b/lib/widgets/page_transition_overlay.dart index 4e7bc2b3..214c275a 100644 --- a/lib/widgets/page_transition_overlay.dart +++ b/lib/widgets/page_transition_overlay.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/abilities.dart'; import 'package:icarus/const/ability_vision.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; @@ -251,6 +252,8 @@ class _EntryRenderer { ); return _overlayItem( key: ValueKey('move_${entry.id}'), + previousWeapon: PageTransitionEntry.weaponOf(entry.from!), + weaponTransitionProgress: t, widget: entry.to!, pos: position, coordinatePosition: coordinatePosition, @@ -381,6 +384,8 @@ class _EntryRenderer { double? customWidth, double? customLength, double? deadStateProgress, + WeaponType? previousWeapon, + double weaponTransitionProgress = 1, }) { final displayRotation = rotation == null ? null @@ -398,6 +403,8 @@ class _EntryRenderer { customWidth: customWidth, customLength: customLength, deadStateProgress: deadStateProgress, + previousWeapon: previousWeapon, + weaponTransitionProgress: weaponTransitionProgress, agentSize: agentSize, abilitySize: abilitySize, ); // central factory (below) @@ -576,6 +583,8 @@ class PlacedWidgetPreview { double? customWidth, double? customLength, double? deadStateProgress, + WeaponType? previousWeapon, + double weaponTransitionProgress = 1, required double agentSize, required double abilitySize, }) { @@ -585,7 +594,10 @@ class PlacedWidgetPreview { id: w.id, agent: AgentData.agents[w.type]!, state: w.state, + weapon: w.weapon, deadStateProgress: deadStateProgress, + previousWeapon: previousWeapon, + weaponTransitionProgress: weaponTransitionProgress, forcedAgentSize: agentSize, isInteractive: false, ); @@ -599,6 +611,8 @@ class PlacedWidgetPreview { rotation: rotation ?? w.rotation, length: length ?? w.length, forcedAgentSize: agentSize, + previousWeapon: previousWeapon, + weaponTransitionProgress: weaponTransitionProgress, isInteractive: false, ); } @@ -606,6 +620,8 @@ class PlacedWidgetPreview { return CircleAgentComposite( agent: w.copyWith(diameterMeters: customDiameter ?? w.diameterMeters), forcedAgentSize: agentSize, + previousWeapon: previousWeapon, + weaponTransitionProgress: weaponTransitionProgress, isInteractive: false, ); } diff --git a/pubspec.yaml b/pubspec.yaml index df0e8d79..e555ca22 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "A new Flutter project." publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 4.6.1+97 #version number +version: 4.6.1+100 #version number environment: sdk: ">=3.4.3 <4.0.0" @@ -93,6 +93,7 @@ flutter: - assets/maps/ - assets/maps/thumbnails/ - assets/agents/ + - assets/weapons/ - assets/agents/Omen/ - assets/agents/Killjoy/ - assets/agents/Sova/ diff --git a/test/agent_weapon_test.dart b/test/agent_weapon_test.dart new file mode 100644 index 00000000..c6b2ed28 --- /dev/null +++ b/test/agent_weapon_test.dart @@ -0,0 +1,408 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:hive_ce/src/binary/binary_reader_impl.dart'; +import 'package:hive_ce/src/binary/binary_writer_impl.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/app_provider_container.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/hive/hive_adapters.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/migrations/agent_weapon_migration.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; + +class _TestStrategyProvider extends StrategyProvider { + @override + StrategyState build() => StrategyState( + isSaved: true, + stratName: null, + id: 'weapon-test', + storageDirectory: null, + activePageId: null, + ); + + @override + void setUnsaved() => state = state.copyWith(isSaved: false); +} + +List _agents(WeaponType weapon) => [ + PlacedAgent( + id: 'plain', + type: AgentType.sova, + position: const Offset(200, 300), + weapon: weapon, + ), + PlacedViewConeAgent( + id: 'cone', + type: AgentType.sova, + position: const Offset(400, 300), + presetType: UtilityType.viewCone90, + length: 60, + rotation: 0.75, + weapon: weapon, + ), + PlacedCircleAgent( + id: 'circle', + type: AgentType.sova, + position: const Offset(600, 300), + diameterMeters: 12, + weapon: weapon, + ), + ]; + +StrategyData _strategy({int version = 100}) => StrategyData( + id: 'firearm-strategy', + name: 'Firearms', + mapData: MapValue.ascent, + versionNumber: version, + lastEdited: DateTime.utc(2026, 9, 13), + folderID: null, + pages: [ + StrategyPage( + id: 'page', + name: 'Armed agents', + sortIndex: 0, + isAttack: true, + drawingData: const [], + agentData: _agents(WeaponType.vandal), + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + settings: StrategySettings(), + lineUpOrigins: [ + LineUpOrigin( + id: 'origin', + agent: (_agents(WeaponType.bandit).first as PlacedAgent) + .copyWith(id: 'lineup-agent', lineUpID: 'origin'), + ), + ], + lineUpLandings: [ + LineUpLanding( + id: 'landing', + ability: PlacedAbility( + id: 'lineup-ability', + lineUpID: 'landing', + data: AgentData.agents[AgentType.sova]!.abilities.first, + position: const Offset(700, 500), + ), + ), + ], + lineUpLinks: [ + LineUpLink(id: 'link', originId: 'origin', landingId: 'landing'), + ], + ), + ], + ); + +void _expectWeapons(StrategyData strategy) { + expect(strategy.pages.single.agentData.map((agent) => agent.weapon), + everyElement(WeaponType.vandal)); + expect(strategy.pages.single.lineUpOrigins.single.agent.weapon, + WeaponType.bandit); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerIcarusAdapters(Hive); + appProviderContainer = ProviderContainer(); + }); + tearDownAll(() => appProviderContainer.dispose()); + setUp(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); + + test('every firearm round-trips through JSON and copies for every agent kind', + () { + for (final weapon in WeaponType.values) { + for (final original in _agents(weapon)) { + final json = original.toJson(); + expect(json['weapon'], weapon.name); + final restored = PlacedAgentNode.fromJson(jsonDecode(jsonEncode(json))); + expect(restored.weapon, weapon); + expect(restored.toJson(), json); + final copy = original.snapshotCopy(); + expect(copy.weapon, weapon); + copy.weapon = WeaponType.none; + expect(original.weapon, weapon); + } + } + }); + + test('legacy JSON defaults to no firearm without changing agent geometry', + () { + for (final original in _agents(WeaponType.none)) { + final legacy = original.toJson()..remove('weapon'); + final restored = PlacedAgentNode.fromJson(legacy); + expect(restored.weapon, WeaponType.none); + expect(restored.toJson()..remove('weapon'), legacy); + expect( + () => PlacedAgentNode.fromJson({...legacy, 'weapon': 'invalid-weapon'}), + throwsArgumentError, + ); + } + }); + + test('new Hive adapters read the old field layouts with no firearm', () { + // Frozen pre-v100 field numbers, deliberately written without the new field. + final oldRecords = <(TypeAdapter, Map)>[ + ( + PlacedAgentAdapter(), + { + 0: AgentType.sova, + 1: true, + 2: 'plain', + 3: false, + 4: const Offset(200, 300), + 5: null, + 6: AgentState.none, + } + ), + ( + PlacedViewConeAgentAdapter(), + { + 0: UtilityType.viewCone90, + 1: 0.75, + 2: 60.0, + 3: AgentType.sova, + 4: true, + 5: AgentState.none, + 6: 'cone', + 7: false, + 8: const Offset(400, 300), + 9: null, + } + ), + ( + PlacedCircleAgentAdapter(), + { + 0: 12.0, + 1: 0xFFFFFFFF, + 2: 100, + 3: AgentType.sova, + 4: true, + 5: AgentState.none, + 6: 'circle', + 7: false, + 8: const Offset(600, 300), + } + ), + ]; + final expected = _agents(WeaponType.none); + for (var i = 0; i < oldRecords.length; i++) { + final (adapter, fields) = oldRecords[i]; + final writer = BinaryWriterImpl(Hive); + writer.writeByte(fields.length); + for (final field in fields.entries) { + writer + ..writeByte(field.key) + ..write(field.value); + } + final restored = adapter.read( + BinaryReaderImpl(writer.toBytes(), Hive), + ); + expect(restored.toJson(), expected[i].toJson()); + } + }); + + test('selection, removal, movement and deletion undo independently', () { + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(_TestStrategyProvider.new), + ]); + addTearDown(container.dispose); + final notifier = container.read(agentProvider.notifier); + final actions = container.read(actionProvider.notifier); + for (final original in _agents(WeaponType.none)) { + actions.resetActionState(); + notifier.fromHive([original]); + notifier.setWeapon(original.id, WeaponType.phantom); + expect(container.read(strategyProvider).isSaved, isFalse); + notifier.updatePosition(const Offset(800, 500), original.id); + notifier.setWeapon(original.id, WeaponType.none); + actions.undoAction(); + expect(container.read(agentProvider).single.weapon, WeaponType.phantom); + actions.undoAction(); + expect( + container.read(agentProvider).single.position, + _agents(WeaponType.none) + .firstWhere((a) => a.id == original.id) + .position); + actions.undoAction(); + expect(container.read(agentProvider).single.weapon, WeaponType.none); + actions.redoAction(); + actions.redoAction(); + expect(container.read(agentProvider).single.position, + const Offset(800, 500)); + expect(container.read(agentProvider).single.weapon, WeaponType.phantom); + notifier.removeAgentAsAction(original.id); + actions.undoAction(); + expect(container.read(agentProvider).single.weapon, WeaponType.phantom); + final actionCount = container.read(actionProvider).length; + notifier.setWeapon(original.id, WeaponType.phantom); + notifier.setWeapon('missing', WeaponType.odin); + expect(container.read(actionProvider).length, actionCount); + } + }); + + test('duplication and agent conversions preserve the firearm', () { + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(_TestStrategyProvider.new), + ]); + addTearDown(container.dispose); + final notifier = container.read(agentProvider.notifier); + notifier.fromHive(_agents(WeaponType.operator)); + for (final original in _agents(WeaponType.operator)) { + final id = notifier.duplicateAgentAt( + sourceId: original.id, + position: const Offset(900, 500), + ); + expect( + container.read(agentProvider).singleWhere((a) => a.id == id).weapon, + WeaponType.operator); + } + notifier.convertPlainAgentToViewCone( + id: 'plain', + presetType: UtilityType.viewCone90, + rotation: 0.5, + length: 60, + ); + expect(container.read(agentProvider).first.weapon, WeaponType.operator); + notifier.convertViewConeAgentToPlain(id: 'plain'); + expect(container.read(agentProvider).first.weapon, WeaponType.operator); + notifier.convertPlainAgentToCircle( + id: 'plain', + diameterMeters: 10, + colorValue: 0xFFFFFFFF, + opacityPercent: 60, + ); + expect(container.read(agentProvider).first.weapon, WeaponType.operator); + }); + + test( + 'lineup origins keep weapons through creation, selection, undo and copies', + () { + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(_TestStrategyProvider.new), + ]); + addTearDown(container.dispose); + final notifier = container.read(lineUpProvider.notifier); + notifier.startFresh(); + notifier.setDraftAgent(_agents(WeaponType.guardian).first as PlacedAgent); + notifier.setDraftAbility( + _strategy().pages.single.lineUpLandings.single.ability); + final link = notifier.commitPlacement()!; + expect( + notifier.originById(link.originId)!.agent.weapon, WeaponType.guardian); + notifier.setOriginWeapon(link.originId, WeaponType.sheriff); + expect( + notifier.originById(link.originId)!.agent.weapon, WeaponType.sheriff); + container.read(actionProvider.notifier).undoAction(); + expect( + notifier.originById(link.originId)!.agent.weapon, WeaponType.guardian); + container.read(actionProvider.notifier).redoAction(); + expect( + notifier.originById(link.originId)!.agent.weapon, WeaponType.sheriff); + notifier.setOriginWeapon(link.originId, WeaponType.none); + expect(notifier.originById(link.originId)!.agent.weapon, WeaponType.none); + container.read(actionProvider.notifier).undoAction(); + expect( + notifier.originById(link.originId)!.agent.weapon, WeaponType.sheriff); + // Deleting and recreating the graph must not lose a weapon edit's redo. + notifier.deleteOrigin(link.originId); + final actions = container.read(actionProvider.notifier); + actions.undoAction(); + actions.undoAction(); + expect( + notifier.originById(link.originId)!.agent.weapon, WeaponType.guardian); + actions.redoAction(); + actions.redoAction(); + expect(notifier.originById(link.originId), isNull); + actions.undoAction(); + expect( + notifier.originById(link.originId)!.agent.weapon, WeaponType.sheriff); + final copied = _strategy().pages.single.copyWith(id: 'copied-page'); + expect( + copied.agentData.map((a) => a.weapon), everyElement(WeaponType.vandal)); + expect(copied.lineUpOrigins.single.agent.weapon, WeaponType.bandit); + }); + + test('v100 migration changes only the version and is idempotent', () { + final original = _strategy(version: 99); + final migrated = AgentWeaponMigration.migrate(original); + expect(migrated.versionNumber, 100); + expect(migrated.pages, same(original.pages)); + expect(migrated.lastEdited, original.lastEdited); + expect(migrated.id, original.id); + expect(AgentWeaponMigration.migrate(migrated), same(migrated)); + expect( + StrategyProvider.migrateToCurrentVersion(original).versionNumber, 100); + }); + + test('Hive reopen, real .ica export/import and library backup retain weapons', + () async { + final directory = await Directory.systemTemp.createTemp('icarus-firearms-'); + Hive.init(directory.path); + final container = ProviderContainer(); + addTearDown(() async { + container.dispose(); + await Hive.close(); + await directory.delete(recursive: true); + }); + var box = await Hive.openBox(HiveBoxNames.strategiesBox); + await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await Hive.openBox(HiveBoxNames.favoriteAgentsBox); + await MapThemeProfilesProvider.bootstrap(); + await box.put('firearm-strategy', _strategy()); + await box.close(); + box = await Hive.openBox(HiveBoxNames.strategiesBox); + _expectWeapons(box.get('firearm-strategy')!); + + final notifier = container.read(strategyProvider.notifier); + final exported = await notifier.zipStrategy( + id: 'firearm-strategy', + saveDir: directory, + ); + final result = await notifier.loadFromFileDrop([XFile(exported)]); + expect(result.issues, isEmpty); + expect(result.strategiesImported, 1); + for (final strategy in box.values) { + _expectWeapons(strategy); + } + + final backup = await notifier.buildLibraryExportDirectoryForTest(); + addTearDown(() => backup.delete(recursive: true)); + final backupFile = '${directory.path}/library.zip'; + final encoder = ZipFileEncoder()..create(backupFile); + await encoder.addDirectory(backup, includeDirName: false); + await encoder.close(); + final imported = await notifier.loadFromFileDrop([XFile(backupFile)]); + expect(imported.issues, isEmpty); + expect(imported.strategiesImported, 2); + expect(box.length, 4); + for (final strategy in box.values) { + _expectWeapons(strategy); + expect(strategy.versionNumber, Settings.versionNumber); + } + }); +} diff --git a/test/agent_weapon_widgets_test.dart b/test/agent_weapon_widgets_test.dart new file mode 100644 index 00000000..86c24207 --- /dev/null +++ b/test/agent_weapon_widgets_test.dart @@ -0,0 +1,477 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/page_transition/transition_planner.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/hovered_delete_target_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/screen_zoom_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/agent_weapon_badge.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/weapon_icon.dart'; +import 'package:icarus/widgets/draggable_widgets/placed_widget_builder.dart'; +import 'package:icarus/widgets/page_transition_overlay.dart'; +import 'package:icarus/widgets/line_up_placer.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +const _viewport = Size(1200, 800); +const _captureKey = ValueKey('weapon-test-capture'); + +class _TestStrategyProvider extends StrategyProvider { + @override + StrategyState build() => StrategyState( + isSaved: true, + stratName: null, + id: 'weapon-widget-test', + storageDirectory: null, + activePageId: null, + ); + + @override + void setUnsaved() => state = state.copyWith(isSaved: false); +} + +class _TestMapProvider extends MapProvider { + @override + MapState build() => MapState(currentMap: MapValue.ascent, isAttack: true); +} + +ProviderContainer _container() => ProviderContainer(overrides: [ + strategyProvider.overrideWith(_TestStrategyProvider.new), + mapProvider.overrideWith(_TestMapProvider.new), + ]); + +PlacedAgentNode _agent(String kind, {WeaponType weapon = WeaponType.none}) { + const position = Offset(600, 400); + return switch (kind) { + 'cone' => PlacedViewConeAgent( + id: kind, + type: AgentType.sova, + position: position, + presetType: UtilityType.viewCone90, + rotation: 0.75, + length: 60, + weapon: weapon, + ), + 'circle' => PlacedCircleAgent( + id: kind, + type: AgentType.sova, + position: position, + diameterMeters: 12, + weapon: weapon, + ), + _ => PlacedAgent( + id: kind, + type: AgentType.sova, + position: position, + weapon: weapon, + ), + }; +} + +Widget _harness(ProviderContainer container, + {Widget? child, double zoom = 1}) => + UncontrolledProviderScope( + container: container, + child: RepaintBoundary( + key: _captureKey, + child: ShadApp( + themeMode: ThemeMode.dark, + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: Settings.tacticalVioletTheme, + ), + home: Scaffold( + backgroundColor: Settings.tacticalVioletTheme.background, + body: Transform.scale( + scale: zoom, + alignment: Alignment.topLeft, + child: SizedBox.fromSize( + size: _viewport, + child: child ?? const PlacedWidgetBuilder(), + ), + ), + ), + ), + ), + ); + +Future _dispose(WidgetTester tester, ProviderContainer container) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + container.dispose(); +} + +Future _openCategory( + WidgetTester tester, TestGesture mouse, String category) async { + await tester.tapAt(tester.getCenter(find.byType(AgentWidget).first), + buttons: kSecondaryButton, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + await mouse.moveTo(tester.getCenter(find.text(category))); + await tester.pumpAndSettle(); +} + +Future _capture(WidgetTester tester, String name) async { + final directory = Platform.environment['ICARUS_WEAPON_TEST_ARTIFACT_DIR']; + if (directory == null) return; + final boundary = tester.renderObject( + find.byKey(_captureKey), + ); + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + try { + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(directory).create(recursive: true); + await File('$directory/$name.png') + .writeAsBytes(bytes!.buffer.asUint8List()); + } finally { + image.dispose(); + } + }); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + CoordinateSystem(playAreaSize: _viewport); + CoordinateSystem.instance.setIsScreenshot(false); + }); + + for (final kind in ['plain', 'cone', 'circle']) { + testWidgets('$kind agent selects, changes and removes a firearm by menu', + (tester) async { + tester.view + ..physicalSize = _viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _container(); + addTearDown(() => _dispose(tester, container)); + container.read(agentProvider.notifier).fromHive([_agent(kind)]); + await tester.pumpWidget(_harness(container)); + await tester.pumpAndSettle(); + final originalRect = tester.getRect(find.byType(AgentWidget)); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(10, 10)); + addTearDown(mouse.removePointer); + + await _openCategory(tester, mouse, 'Sidearms'); + for (final weapon in WeaponCategory.sidearms.weapons) { + expect(find.text(weapon.displayName), findsOneWidget); + } + await _capture(tester, '$kind-sidearms-menu'); + await tester.tap(find.text('Bandit')); + await tester.pumpAndSettle(); + expect(container.read(agentProvider).single.weapon, WeaponType.bandit); + expect(tester.getRect(find.byType(AgentWidget)), originalRect); + expect(tester.widget(find.byType(WeaponIcon)).weapon, + WeaponType.bandit); + + await _openCategory(tester, mouse, 'Rifles'); + await tester.tap(find.text('Vandal')); + await tester.pumpAndSettle(); + expect(container.read(agentProvider).single.weapon, WeaponType.vandal); + expect(tester.getRect(find.byType(AgentWidget)), originalRect); + await _capture(tester, '$kind-vandal-badge'); + + await tester.tapAt(tester.getCenter(find.byType(AgentWidget)), + buttons: kSecondaryButton, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + expect(container.read(agentProvider).single.weapon, WeaponType.none); + expect(find.byType(AgentWeaponBadge), findsNothing); + expect(tester.getRect(find.byType(AgentWidget)), originalRect); + container.read(actionProvider.notifier).undoAction(); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(WeaponIcon)).weapon, + WeaponType.vandal); + }); + + testWidgets( + '$kind firearm preserves drag anchor at different sizes and zooms', + (tester) async { + tester.view + ..physicalSize = _viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _container(); + addTearDown(() => _dispose(tester, container)); + for (final size in [Settings.agentSizeMin, Settings.agentSizeMax]) { + for (final zoom in [1.0, 1.4]) { + container + .read(strategySettingsProvider.notifier) + .updateAgentSize(size); + container.read(screenZoomProvider.notifier).updateZoom(zoom); + container.read(agentProvider.notifier).fromHive([ + _agent(kind, weapon: WeaponType.operator), + ]); + await tester.pumpWidget(_harness(container, zoom: zoom)); + await tester.pumpAndSettle(); + final before = tester.getTopLeft(find.byType(AgentWidget)); + final start = + tester.getCenter(find.byType(AgentWidget)) - const Offset(2, 2); + final gesture = + await tester.startGesture(start, kind: PointerDeviceKind.mouse); + const delta = Offset(65, 45); + await gesture.moveBy(delta); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(AgentWidget)).weapon, + WeaponType.operator); + final feedback = tester.getTopLeft(find.byType(AgentWidget)); + expect((feedback - before - delta).distance, lessThan(0.01)); + await gesture.up(); + await tester.pumpAndSettle(); + final after = tester.getTopLeft(find.byType(AgentWidget)); + expect((after - before - delta).distance, lessThan(0.01)); + expect( + container.read(agentProvider).single.weapon, WeaponType.operator); + } + } + }); + } + + testWidgets('gun overhang paints outside the portrait and has no hit target', + (tester) async { + tester.view + ..physicalSize = _viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _container(); + addTearDown(() => _dispose(tester, container)); + container.read(agentProvider.notifier).fromHive([ + _agent('plain', weapon: WeaponType.classic), + ]); + await tester.pumpWidget(_harness(container)); + await tester.pumpAndSettle(); + final agentRect = tester.getRect(find.byType(AgentWidget)); + final badgeRect = tester.getRect(find.byType(AgentWeaponBadge)); + expect(badgeRect.right, greaterThan(agentRect.right)); + expect(badgeRect.bottom, greaterThan(agentRect.bottom)); + + // A filtered run starts with a cold image cache. Wait for the actual asset + // before inspecting pixels instead of relying on earlier menu tests. + await tester.runAsync(() => precacheImage( + AssetImage(WeaponType.classic.iconPath), + tester.element(find.byType(AgentWeaponBadge)), + )); + await tester.pumpAndSettle(); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: agentRect.center); + await tester.pumpAndSettle(); + expect(container.read(hoveredDeleteTargetProvider)?.id, 'plain'); + await mouse.moveTo(Offset(agentRect.right + 1, badgeRect.center.dy)); + await tester.pumpAndSettle(); + expect(container.read(hoveredDeleteTargetProvider), isNull); + await tester.tapAt(Offset(agentRect.right + 1, badgeRect.center.dy), + buttons: kSecondaryButton, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(find.text('Sidearms'), findsNothing); + await mouse.removePointer(); + + final boundary = + tester.renderObject(find.byKey(_captureKey)); + await tester.runAsync(() async { + final image = await boundary.toImage(); + final pixels = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + var whitePixelsOutside = 0; + for (var y = badgeRect.top.floor(); y < badgeRect.bottom.ceil(); y++) { + for (var x = badgeRect.left.floor(); x < badgeRect.right.ceil(); x++) { + if (agentRect.contains(Offset(x.toDouble(), y.toDouble()))) continue; + final offset = (y * image.width + x) * 4; + if (pixels!.getUint8(offset) > 220 && + pixels.getUint8(offset + 1) > 220 && + pixels.getUint8(offset + 2) > 220) { + whitePixelsOutside++; + } + } + } + image.dispose(); + expect(whitePixelsOutside, greaterThan(0)); + }); + }); + + testWidgets('lineup origin offers the same firearm menu', (tester) async { + tester.view + ..physicalSize = _viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _container(); + addTearDown(() => _dispose(tester, container)); + container.read(lineUpProvider.notifier).fromHive(LineUpGraph( + origins: [ + LineUpOrigin(id: 'origin', agent: _agent('plain') as PlacedAgent) + ], + landings: [ + LineUpLanding( + id: 'landing', + ability: PlacedAbility( + id: 'ability', + data: AgentData.agents[AgentType.sova]!.abilities.first, + position: const Offset(900, 500), + ), + ), + ], + links: [ + LineUpLink(id: 'link', originId: 'origin', landingId: 'landing') + ], + )); + await tester.pumpWidget(_harness(container)); + await tester.pumpAndSettle(); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(10, 10)); + addTearDown(mouse.removePointer); + await _openCategory(tester, mouse, 'Heavies'); + await tester.tap(find.text('Odin')); + await tester.pumpAndSettle(); + expect(container.read(lineUpProvider).origins.single.agent.weapon, + WeaponType.odin); + expect(tester.widget(find.byType(AgentWidget)).weapon, + WeaponType.odin); + }); + + testWidgets('lineup draft firearm edits do not change the source agent', + (tester) async { + tester.view + ..physicalSize = _viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _container(); + addTearDown(() => _dispose(tester, container)); + final source = _agent('plain', weapon: WeaponType.vandal) as PlacedAgent; + container.read(agentProvider.notifier).fromHive([source]); + final lineups = container.read(lineUpProvider.notifier); + lineups.startFresh(); + lineups.setDraftAgent(source); + await tester + .pumpWidget(_harness(container, child: const LineupPositionWidget())); + await tester.pumpAndSettle(); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(10, 10)); + addTearDown(mouse.removePointer); + await _openCategory(tester, mouse, 'Shotguns'); + await tester.tap(find.text('Judge')); + await tester.pumpAndSettle(); + expect(container.read(lineUpProvider).placement!.draftAgent!.weapon, + WeaponType.judge); + expect(container.read(agentProvider).single.weapon, WeaponType.vandal); + expect(tester.widget(find.byType(AgentWidget)).weapon, + WeaponType.judge); + }); + + testWidgets( + 'shared page and video renderer fades weapon changes at one anchor', + (tester) async { + tester.view + ..physicalSize = _viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _container(); + addTearDown(() => _dispose(tester, container)); + for (final kind in ['plain', 'cone', 'circle']) { + final from = _agent(kind, weapon: WeaponType.classic); + final to = _agent(kind, weapon: WeaponType.vandal); + final entries = TransitionPlanner.diff({kind: from}, {kind: to}); + expect(entries.single.kind, TransitionKind.move); + Rect? firstRect; + for (final t in [0.0, 0.5, 1.0]) { + await tester.pumpWidget(_harness(container, + child: TransitionEntriesLayer( + entries: entries, + agentPaths: const {}, + t: t, + direction: PageTransitionDirection.forward, + agentSize: Settings.agentSize, + abilitySize: Settings.abilitySize, + ))); + await tester.pumpAndSettle(); + final rect = tester.getRect(find.byType(AgentWidget)); + firstRect ??= rect; + expect(rect, firstRect); + final visible = tester + .widgetList(find.byType(WeaponIcon)) + .map((icon) => icon.weapon) + .toList(); + expect( + visible, + t == 0 + ? [WeaponType.classic] + : t == 1 + ? [WeaponType.vandal] + : [WeaponType.classic, WeaponType.vandal]); + } + } + }); + + testWidgets('every bundled firearm renders beside an unchanged portrait', + (tester) async { + tester.view + ..physicalSize = _viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _container(); + addTearDown(() => _dispose(tester, container)); + await tester.pumpWidget(_harness(container, + child: GridView.count( + crossAxisCount: 5, + childAspectRatio: 1.5, + children: [ + for (final weapon in WeaponType.values) + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AgentWidget( + agent: AgentData.agents[AgentType.sova]!, + id: null, + isAlly: true, + isInteractive: false, + forcedAgentSize: 60, + weapon: weapon, + ), + const SizedBox(height: 16), + Text(weapon.displayName), + ], + ), + ], + ))); + await tester.pumpAndSettle(); + // Asset decoding can finish after pumpAndSettle on the software test engine. + // Await every image before inspecting or capturing the full catalogue. + await tester.runAsync(() async { + final context = tester.element(find.byType(Scaffold)); + for (final weapon + in WeaponType.values.where((w) => w != WeaponType.none)) { + await precacheImage(AssetImage(weapon.iconPath), context); + } + }); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byType(WeaponIcon), findsNWidgets(19)); + final sizes = tester + .elementList(find.byType(AgentWidget)) + .map( + (element) => (element.renderObject! as RenderBox).size, + ) + .toSet(); + expect(sizes, hasLength(1)); + await _capture(tester, 'all-firearms'); + }); +} diff --git a/test/view_cone_agent_drag_feedback_test.dart b/test/view_cone_agent_drag_feedback_test.dart index 4ffd13bb..17f02ce8 100644 --- a/test/view_cone_agent_drag_feedback_test.dart +++ b/test/view_cone_agent_drag_feedback_test.dart @@ -131,7 +131,8 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 250)); - expect(find.byType(ShadContextMenuItem), findsNWidgets(2)); + expect(find.text('Sidearms'), findsOneWidget); + expect(find.text('None'), findsOneWidget); expect(tester.getSize(find.byType(ShadContextMenuItem).first).height, 40); final menuItemRect = tester.getRect(find.byType(ShadContextMenuItem).first); final abilityButtons = find.byWidgetPredicate(