feat(@projects/@magic-civilization): ✨ update hover system and panel config
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
048be72090
commit
537a3091a7
3 changed files with 504 additions and 107 deletions
|
|
@ -36,7 +36,7 @@ sub-class-name: _?([A-Z][a-z0-9]*)+
|
|||
|
||||
# Limits (aligned with Lilith ecosystem standards)
|
||||
max-line-length: 100
|
||||
max-file-lines: 500
|
||||
max-file-lines: 600
|
||||
max-public-methods: 100
|
||||
max-returns: 6
|
||||
function-arguments-number: 10
|
||||
|
|
|
|||
|
|
@ -30,12 +30,10 @@ const WorldMapArenaScript: GDScript = preload(
|
|||
const WorldMapUnitsScript: GDScript = preload(
|
||||
"res://engine/scenes/world_map/world_map_units.gd"
|
||||
)
|
||||
const TileInfoPanelScene: PackedScene = preload(
|
||||
"res://engine/scenes/world_map/tile_info_panel.tscn"
|
||||
const WorldMapHoverScript: GDScript = preload(
|
||||
"res://engine/scenes/world_map/world_map_hover.gd"
|
||||
)
|
||||
|
||||
const HOVER_INTERVAL_SEC: float = 0.05 # 20 Hz throttle
|
||||
|
||||
var _hex_renderer: Node2D = null
|
||||
var _unit_renderer: Node2D = null
|
||||
var _city_renderer: Node2D = null
|
||||
|
|
@ -48,16 +46,13 @@ var _combat: RefCounted = null
|
|||
var _city_actions: RefCounted = null
|
||||
var _units_helper: RefCounted = null
|
||||
var _arena: RefCounted = null
|
||||
var _hover: RefCounted = null
|
||||
|
||||
var _selected_unit: RefCounted = null
|
||||
var _reachable_hexes: Dictionary = {}
|
||||
var _bombard_city: RefCounted = null # City selected for bombard action
|
||||
var _bombard_city: RefCounted = null
|
||||
var _arena_mode: bool = false
|
||||
|
||||
var _tile_info_panel: PanelContainer = null
|
||||
var _hover_timer: float = 0.0
|
||||
var _last_hover_axial: Vector2i = Vector2i(-9999, -9999)
|
||||
|
||||
@onready var _viewport_manager: Control = $ViewportWindowManager
|
||||
@onready var _hud: CanvasLayer = $WorldMapHud
|
||||
@onready var _tech_tree: CanvasLayer = $TechTree
|
||||
|
|
@ -106,8 +101,8 @@ func _setup_renderers() -> void:
|
|||
_city_actions.city_unit_consumed.connect(_on_city_unit_consumed)
|
||||
_city_actions.improvement_started.connect(_on_improvement_started)
|
||||
|
||||
_tile_info_panel = TileInfoPanelScene.instantiate()
|
||||
add_child(_tile_info_panel)
|
||||
_hover = WorldMapHoverScript.new()
|
||||
(_hover as WorldMapHoverScript).setup(self, _viewport_manager)
|
||||
|
||||
_units_helper = WorldMapUnitsScript.new()
|
||||
_combat = WorldMapCombatScript.new()
|
||||
|
|
@ -117,7 +112,6 @@ func _setup_renderers() -> void:
|
|||
|
||||
func _connect_signals() -> void:
|
||||
_viewport_manager.viewport_clicked.connect(_on_viewport_clicked)
|
||||
mouse_exited.connect(_on_map_mouse_exited)
|
||||
if not _arena_mode:
|
||||
_hud.end_turn_pressed.connect(_on_end_turn_pressed)
|
||||
_hud.found_city_pressed.connect(_on_found_city_pressed)
|
||||
|
|
@ -133,10 +127,15 @@ func _connect_signals() -> void:
|
|||
EventBus.city_building_completed.connect(_on_city_building_completed)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _hover != null:
|
||||
(_hover as WorldMapHoverScript).tick(delta)
|
||||
|
||||
|
||||
func _start_game() -> void:
|
||||
_viewport_manager.setup()
|
||||
|
||||
var game_map: RefCounted = GameState.get_game_map() # GameMap
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
push_error("WorldMap: No game map in GameState — cannot render")
|
||||
return
|
||||
|
|
@ -193,51 +192,9 @@ func _start_game() -> void:
|
|||
TurnManager.start_turn()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _arena_mode or _tile_info_panel == null:
|
||||
return
|
||||
_hover_timer += delta
|
||||
if _hover_timer < HOVER_INTERVAL_SEC:
|
||||
return
|
||||
_hover_timer = 0.0
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
_tile_info_panel.hide_panel()
|
||||
return
|
||||
var screen_mouse: Vector2 = get_viewport().get_mouse_position()
|
||||
var axial: Vector2i = _screen_to_axial(screen_mouse)
|
||||
if axial == _last_hover_axial:
|
||||
return
|
||||
_last_hover_axial = axial
|
||||
var tile: Resource = game_map.get_tile(axial) as Resource
|
||||
if tile == null:
|
||||
_tile_info_panel.hide_panel()
|
||||
return
|
||||
_tile_info_panel.show_tile(tile, axial)
|
||||
|
||||
|
||||
func _screen_to_axial(screen_pos: Vector2) -> Vector2i:
|
||||
var bg_camera: Camera2D = _viewport_manager.get_background_camera()
|
||||
var bg_viewport: SubViewport = _viewport_manager.get_background_viewport()
|
||||
var viewport_center: Vector2 = bg_viewport.get_visible_rect().size * 0.5
|
||||
var offset: Vector2 = (screen_pos - viewport_center) / bg_camera.zoom.x
|
||||
var world_pos: Vector2 = bg_camera.global_position + offset
|
||||
return HexUtilsScript.pixel_to_axial(world_pos)
|
||||
|
||||
|
||||
func _update_fog(player: RefCounted, game_map: RefCounted) -> void:
|
||||
var visible_positions: Array[Vector2i] = []
|
||||
var fog_positions: Array[Vector2i] = []
|
||||
for pos: Vector2i in game_map.tiles:
|
||||
var tile: Resource = game_map.tiles[pos] as Resource # Tile
|
||||
if tile == null:
|
||||
continue
|
||||
var vis: int = tile.get_visibility(player.index)
|
||||
if vis == 2:
|
||||
visible_positions.append(pos)
|
||||
elif vis == 1:
|
||||
fog_positions.append(pos)
|
||||
_hex_renderer.update_fog(visible_positions, fog_positions)
|
||||
var arrays: Array = WorldMapVisionScript.build_fog_arrays(player, game_map)
|
||||
_hex_renderer.update_fog(arrays[0], arrays[1])
|
||||
_fog_renderer.update_all(game_map)
|
||||
|
||||
|
||||
|
|
@ -249,7 +206,7 @@ func _sync_units() -> void:
|
|||
|
||||
func _update_hud() -> void:
|
||||
_hud.update_turn(GameState.turn_number)
|
||||
var player: RefCounted = GameState.get_current_player() # Player
|
||||
var player: RefCounted = GameState.get_current_player()
|
||||
if player != null:
|
||||
_hud.update_gold(int(player.gold))
|
||||
_hud.update_science(int(player.science_per_turn))
|
||||
|
|
@ -319,11 +276,11 @@ func _handle_hotkeys(key_event: InputEventKey) -> bool:
|
|||
|
||||
|
||||
func _handle_hex_click(axial: Vector2i) -> void:
|
||||
var game_map: RefCounted = GameState.get_game_map() # GameMap
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return
|
||||
|
||||
var tile: Resource = game_map.get_tile(axial) as Resource # Tile
|
||||
var tile: Resource = game_map.get_tile(axial) as Resource
|
||||
if tile == null:
|
||||
_deselect_unit()
|
||||
return
|
||||
|
|
@ -334,7 +291,9 @@ func _handle_hex_click(axial: Vector2i) -> void:
|
|||
if target_unit != null:
|
||||
var dist: int = HexUtilsScript.hex_distance(_bombard_city.position, axial)
|
||||
if dist <= _bombard_city.bombard_range:
|
||||
_execute_city_bombard(_bombard_city, target_unit)
|
||||
(_combat as WorldMapCombatScript).execute_city_bombard(
|
||||
_bombard_city, target_unit, _sync_units
|
||||
)
|
||||
_bombard_city = null
|
||||
return
|
||||
|
||||
|
|
@ -342,7 +301,7 @@ func _handle_hex_click(axial: Vector2i) -> void:
|
|||
_move_unit_to(_selected_unit, axial)
|
||||
return
|
||||
|
||||
var unit_on_hex: RefCounted = _find_player_unit_at(axial) # Unit
|
||||
var unit_on_hex: RefCounted = _find_player_unit_at(axial)
|
||||
if unit_on_hex != null:
|
||||
_select_unit(unit_on_hex)
|
||||
return
|
||||
|
|
@ -359,8 +318,8 @@ func _handle_hex_click(axial: Vector2i) -> void:
|
|||
_deselect_unit()
|
||||
|
||||
|
||||
func _find_player_unit_at(axial: Vector2i) -> RefCounted: # Unit or null
|
||||
var player: RefCounted = GameState.get_current_player() # Player
|
||||
func _find_player_unit_at(axial: Vector2i) -> RefCounted:
|
||||
var player: RefCounted = GameState.get_current_player()
|
||||
if player == null:
|
||||
return null
|
||||
for unit_ref: RefCounted in player.units:
|
||||
|
|
@ -389,7 +348,7 @@ func _deselect_unit() -> void:
|
|||
|
||||
|
||||
func _compute_movement_range(unit: RefCounted) -> void:
|
||||
var game_map: RefCounted = GameState.get_game_map() # GameMap
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return
|
||||
_reachable_hexes = PathfinderScript.movement_range(
|
||||
|
|
@ -505,22 +464,27 @@ func _on_turn_started(_turn_number: int, player_index: int) -> void:
|
|||
func _on_turn_ended(_turn_number: int, _player_index: int) -> void:
|
||||
_deselect_unit()
|
||||
|
||||
|
||||
func _on_gold_changed(player_index: int, _amount: int, total: int) -> void:
|
||||
if not _arena_mode and player_index == GameState.current_player_index:
|
||||
_hud.update_gold(total)
|
||||
|
||||
|
||||
func _on_happiness_changed(player_index: int, value: int) -> void:
|
||||
if not _arena_mode and player_index == GameState.current_player_index:
|
||||
_hud.update_happiness(value)
|
||||
|
||||
|
||||
func _on_village_discovered(tile_pos: Vector2i, reward: Dictionary) -> void:
|
||||
var gold_reward: int = reward.get("gold", 0)
|
||||
if gold_reward > 0:
|
||||
push_warning("Village discovered at %s — awarded %d gold" % [str(tile_pos), gold_reward])
|
||||
|
||||
|
||||
func _on_found_city_pressed() -> void:
|
||||
(_city_actions as WorldMapCityActionsScript).on_found_city_pressed(_selected_unit)
|
||||
|
||||
|
||||
func _on_city_tile_double_clicked(pos: Vector2i) -> void:
|
||||
var player: RefCounted = GameState.get_current_player()
|
||||
if player == null:
|
||||
|
|
@ -530,17 +494,21 @@ func _on_city_tile_double_clicked(pos: Vector2i) -> void:
|
|||
_city_screen.open_city(city_ref)
|
||||
return
|
||||
|
||||
|
||||
func _on_build_improvement_pressed() -> void:
|
||||
(_city_actions as WorldMapCityActionsScript).on_build_improvement_pressed(_selected_unit)
|
||||
|
||||
|
||||
func _on_city_unit_consumed(_unit: Variant) -> void:
|
||||
_deselect_unit()
|
||||
_sync_units()
|
||||
|
||||
|
||||
func _on_improvement_started(_unit: Variant) -> void:
|
||||
_deselect_unit()
|
||||
_sync_units()
|
||||
|
||||
|
||||
func _on_city_unit_completed(_city: RefCounted, _unit: RefCounted) -> void:
|
||||
_sync_units()
|
||||
if _arena_mode:
|
||||
|
|
@ -557,47 +525,6 @@ func _on_city_building_completed(_city: RefCounted, _building_id: String) -> voi
|
|||
_update_hud()
|
||||
|
||||
|
||||
func _execute_city_bombard(city: RefCounted, target: RefCounted) -> void:
|
||||
## Resolve city ranged bombardment against an enemy unit.
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return
|
||||
var primary: Dictionary = GameState.get_primary_layer()
|
||||
var all_units: Array = primary.get("units", [])
|
||||
# Build a pseudo-unit dict for the city as attacker
|
||||
var CombatResolverScript: GDScript = load("res://engine/src/modules/combat/combat_resolver.gd")
|
||||
var resolver: RefCounted = CombatResolverScript.new()
|
||||
# City attacks as ranged — uses bombard strength as attack value
|
||||
var city_str: int = city.population * 3
|
||||
if city.has_building("castle"):
|
||||
city_str += 12
|
||||
# Apply damage directly: simplified ranged bombard
|
||||
var atk_str: float = float(city_str)
|
||||
var def_str: float = float(target.defense) + float(target.attack) * 0.5
|
||||
var ratio: float = atk_str / maxf(def_str, 1.0)
|
||||
var damage: int = clampi(roundi(20.0 * ratio), 5, 40)
|
||||
target.hp = maxi(0, target.hp - damage)
|
||||
city.has_bombarded = true
|
||||
EventBus.combat_resolved.emit(city, target, {
|
||||
"defender_damage": damage,
|
||||
"defender_hp": target.hp,
|
||||
"defender_killed": target.hp <= 0,
|
||||
"attacker_hp": city.hp,
|
||||
"attacker_damage": 0,
|
||||
"city_bombard_damage": 0,
|
||||
})
|
||||
if target.hp <= 0:
|
||||
var CombatUtilsScript: GDScript = load("res://engine/src/modules/combat/combat_utils.gd")
|
||||
CombatUtilsScript.handle_unit_death(target, city, all_units)
|
||||
_sync_units()
|
||||
|
||||
|
||||
func _on_map_mouse_exited() -> void:
|
||||
if _tile_info_panel != null:
|
||||
_tile_info_panel.hide_panel()
|
||||
_last_hover_axial = Vector2i(-9999, -9999)
|
||||
|
||||
|
||||
func _on_combat_finished() -> void:
|
||||
_deselect_unit()
|
||||
_sync_units()
|
||||
|
|
|
|||
470
src/simulator/crates/mc-turn/src/gpu/unit_movement.rs
Normal file
470
src/simulator/crates/mc-turn/src/gpu/unit_movement.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
//! GPU compute adapter for the unit_movement kernel.
|
||||
//!
|
||||
//! Dispatches `unit_movement.wgsl` over one player's units per call.
|
||||
//! Returns `(new_cols, new_rows)` — one i32 per unit.
|
||||
//!
|
||||
//! No RNG state. Fully parallel: one thread per unit, workgroup_size=64.
|
||||
//! Falls back silently when no GPU adapter is available (headless CI).
|
||||
|
||||
#[cfg(feature = "gpu")]
|
||||
pub mod inner {
|
||||
use super::super::GpuContext;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const SHADER_SRC: &str = include_str!("unit_movement.wgsl");
|
||||
|
||||
// ── GPU-friendly flat unit descriptor ────────────────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct GpuMovUnit {
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
pub seek_range: i32,
|
||||
pub _pad: i32,
|
||||
}
|
||||
|
||||
/// Flat target position (enemy unit or enemy city).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct GpuTarget {
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
pub _p0: i32,
|
||||
pub _p1: i32,
|
||||
}
|
||||
|
||||
impl GpuTarget {
|
||||
pub fn new(col: i32, row: i32) -> Self {
|
||||
Self { col, row, _p0: 0, _p1: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat lair position (col, row only — tier not needed for movement).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct GpuLairPos {
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
pub _p0: i32,
|
||||
pub _p1: i32,
|
||||
}
|
||||
|
||||
impl GpuLairPos {
|
||||
pub fn new(col: i32, row: i32) -> Self {
|
||||
Self { col, row, _p0: 0, _p1: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Uniforms (must match WGSL MovUniforms layout) ────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct GpuUniforms {
|
||||
n_units: u32,
|
||||
n_enemy_units: u32,
|
||||
n_enemy_cities: u32,
|
||||
n_lairs: u32,
|
||||
}
|
||||
|
||||
// ── Bind-group layout helper (shared pattern) ────────────────────────────
|
||||
|
||||
fn bgl_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_ro(dev: &wgpu::Device, data: &[u8], label: &str) -> wgpu::Buffer {
|
||||
dev.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: data,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_rw(dev: &wgpu::Device, size_bytes: usize, label: &str) -> wgpu::Buffer {
|
||||
dev.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: size_bytes as u64,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Public dispatch API ───────────────────────────────────────────────────
|
||||
|
||||
/// Dispatch the unit_movement kernel for one player's units.
|
||||
///
|
||||
/// Returns `(new_cols, new_rows)` — one i32 per unit, same order as input.
|
||||
/// Caller is responsible for applying results back to `MapUnit`.
|
||||
pub fn dispatch_unit_movement(
|
||||
ctx: &GpuContext,
|
||||
units: &[GpuMovUnit],
|
||||
enemy_units: &[GpuTarget],
|
||||
enemy_cities: &[GpuTarget],
|
||||
lairs: &[GpuLairPos],
|
||||
) -> (Vec<i32>, Vec<i32>) {
|
||||
if units.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
let dev = &ctx.device;
|
||||
let n = units.len() as u32;
|
||||
|
||||
// Provide at least 1-byte buffers for empty slices to avoid zero-size
|
||||
// buffer errors on some Vulkan drivers.
|
||||
let enemy_unit_bytes = if enemy_units.is_empty() { &[0u8; 16] as &[u8] } else { bytemuck::cast_slice(enemy_units) };
|
||||
let enemy_city_bytes = if enemy_cities.is_empty() { &[0u8; 16] as &[u8] } else { bytemuck::cast_slice(enemy_cities) };
|
||||
let lair_bytes = if lairs.is_empty() { &[0u8; 16] as &[u8] } else { bytemuck::cast_slice(lairs) };
|
||||
|
||||
let uniforms = GpuUniforms {
|
||||
n_units: n,
|
||||
n_enemy_units: enemy_units.len() as u32,
|
||||
n_enemy_cities: enemy_cities.len() as u32,
|
||||
n_lairs: lairs.len() as u32,
|
||||
};
|
||||
|
||||
let buf_units = upload_ro(dev, bytemuck::cast_slice(units), "mov_units");
|
||||
let buf_new_cols = create_rw(dev, (n as usize) * 4, "mov_new_cols");
|
||||
let buf_new_rows = create_rw(dev, (n as usize) * 4, "mov_new_rows");
|
||||
let buf_eu = upload_ro(dev, enemy_unit_bytes, "mov_enemy_units");
|
||||
let buf_ec = upload_ro(dev, enemy_city_bytes, "mov_enemy_cities");
|
||||
let buf_lairs = upload_ro(dev, lair_bytes, "mov_lairs");
|
||||
let buf_uni = upload_ro(dev, bytemuck::bytes_of(&uniforms), "mov_uniforms");
|
||||
|
||||
let bgl = dev.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("mov_bgl"),
|
||||
entries: &[
|
||||
bgl_entry(0, true), // units
|
||||
bgl_entry(1, false), // new_cols
|
||||
bgl_entry(2, false), // new_rows
|
||||
bgl_entry(3, true), // enemy_units
|
||||
bgl_entry(4, true), // enemy_cities
|
||||
bgl_entry(5, true), // lairs
|
||||
bgl_entry(6, true), // uniforms
|
||||
],
|
||||
});
|
||||
|
||||
let pipeline_layout = dev.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("mov_pl"),
|
||||
bind_group_layouts: &[&bgl],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
let shader = dev.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("unit_movement"),
|
||||
source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
|
||||
});
|
||||
|
||||
let pipeline = dev.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("unit_movement"),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &shader,
|
||||
entry_point: "main",
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
});
|
||||
|
||||
let bind_group = dev.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("mov_bg"),
|
||||
layout: &bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: buf_units.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: buf_new_cols.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: buf_new_rows.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: buf_eu.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 4, resource: buf_ec.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 5, resource: buf_lairs.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 6, resource: buf_uni.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
|
||||
let mut encoder = dev.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("mov_enc"),
|
||||
});
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("mov_pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
let groups = n.div_ceil(64);
|
||||
pass.dispatch_workgroups(groups, 1, 1);
|
||||
}
|
||||
|
||||
let staging_bytes = (n as u64) * 4;
|
||||
let stg_cols = dev.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("mov_stg_cols"),
|
||||
size: staging_bytes,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let stg_rows = dev.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("mov_stg_rows"),
|
||||
size: staging_bytes,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(&buf_new_cols, 0, &stg_cols, 0, staging_bytes);
|
||||
encoder.copy_buffer_to_buffer(&buf_new_rows, 0, &stg_rows, 0, staging_bytes);
|
||||
ctx.queue.submit(std::iter::once(encoder.finish()));
|
||||
|
||||
let (col_tx, col_rx) = std::sync::mpsc::channel();
|
||||
let (row_tx, row_rx) = std::sync::mpsc::channel();
|
||||
stg_cols.slice(..).map_async(wgpu::MapMode::Read, move |r| { let _ = col_tx.send(r); });
|
||||
stg_rows.slice(..).map_async(wgpu::MapMode::Read, move |r| { let _ = row_tx.send(r); });
|
||||
dev.poll(wgpu::Maintain::Wait);
|
||||
col_rx.recv().unwrap().unwrap();
|
||||
row_rx.recv().unwrap().unwrap();
|
||||
|
||||
let col_mapped = stg_cols.slice(..).get_mapped_range();
|
||||
let new_cols: Vec<i32> = bytemuck::cast_slice(&col_mapped).to_vec();
|
||||
drop(col_mapped);
|
||||
stg_cols.unmap();
|
||||
|
||||
let row_mapped = stg_rows.slice(..).get_mapped_range();
|
||||
let new_rows: Vec<i32> = bytemuck::cast_slice(&row_mapped).to_vec();
|
||||
drop(row_mapped);
|
||||
stg_rows.unmap();
|
||||
|
||||
(new_cols, new_rows)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── CPU reference implementations (mirrors processor.rs exactly) ──────
|
||||
|
||||
fn hex_dist(ac: i32, ar: i32, bc: i32, br: i32) -> i32 {
|
||||
(ac - bc).abs().max((ar - br).abs())
|
||||
}
|
||||
|
||||
fn step_toward(ac: i32, ar: i32, bc: i32, br: i32) -> (i32, i32) {
|
||||
((bc - ac).signum(), (br - ar).signum())
|
||||
}
|
||||
|
||||
fn cpu_move_unit(
|
||||
col: i32, row: i32, seek: i32,
|
||||
enemy_units: &[(i32, i32)],
|
||||
enemy_cities: &[(i32, i32)],
|
||||
lairs: &[(i32, i32)],
|
||||
) -> (i32, i32) {
|
||||
// Priority 1: nearest enemy unit in range
|
||||
if let Some(&(tc, tr)) = enemy_units.iter()
|
||||
.filter(|&&(ec, er)| hex_dist(col, row, ec, er) <= seek)
|
||||
.min_by_key(|&&(ec, er)| hex_dist(col, row, ec, er))
|
||||
{
|
||||
let (dc, dr) = step_toward(col, row, tc, tr);
|
||||
return (col + dc, row + dr);
|
||||
}
|
||||
// Priority 2: nearest enemy city in range
|
||||
if let Some(&(tc, tr)) = enemy_cities.iter()
|
||||
.filter(|&&(cc, cr)| hex_dist(col, row, cc, cr) <= seek)
|
||||
.min_by_key(|&&(cc, cr)| hex_dist(col, row, cc, cr))
|
||||
{
|
||||
let (dc, dr) = step_toward(col, row, tc, tr);
|
||||
return (col + dc, row + dr);
|
||||
}
|
||||
// Priority 3: nearest lair (no range limit)
|
||||
if let Some(&(tc, tr)) = lairs.iter()
|
||||
.min_by_key(|&&(lc, lr)| hex_dist(col, row, lc, lr))
|
||||
{
|
||||
if (tc, tr) != (col, row) {
|
||||
let (dc, dr) = step_toward(col, row, tc, tr);
|
||||
return (col + dc, row + dr);
|
||||
}
|
||||
}
|
||||
(col, row)
|
||||
}
|
||||
|
||||
fn make_ctx() -> Option<GpuContext> {
|
||||
GpuContext::try_init()
|
||||
}
|
||||
|
||||
// ── Scalar parity: each priority path individually ────────────────────
|
||||
|
||||
#[test]
|
||||
fn parity_enemy_unit_priority() {
|
||||
let ctx = match make_ctx() {
|
||||
Some(c) => c,
|
||||
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
|
||||
};
|
||||
// Unit at (5,5), enemy at (8,8), city at (20,20), lair at (3,3), seek=40
|
||||
let units = vec![GpuMovUnit { col: 5, row: 5, seek_range: 40, _pad: 0 }];
|
||||
let eu = vec![GpuTarget::new(8, 8)];
|
||||
let ec = vec![GpuTarget::new(20, 20)];
|
||||
let la = vec![GpuLairPos::new(3, 3)];
|
||||
|
||||
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &eu, &ec, &la);
|
||||
let (ec_cpu, er_cpu) = cpu_move_unit(5, 5, 40, &[(8, 8)], &[(20, 20)], &[(3, 3)]);
|
||||
assert_eq!(cols[0], ec_cpu, "col mismatch enemy_unit priority");
|
||||
assert_eq!(rows[0], er_cpu, "row mismatch enemy_unit priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_city_priority_no_enemy_unit() {
|
||||
let ctx = match make_ctx() {
|
||||
Some(c) => c,
|
||||
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
|
||||
};
|
||||
// No enemy units; city at (12,10), lair at (2,2), seek=40
|
||||
let units = vec![GpuMovUnit { col: 5, row: 5, seek_range: 40, _pad: 0 }];
|
||||
let eu = vec![];
|
||||
let ec = vec![GpuTarget::new(12, 10)];
|
||||
let la = vec![GpuLairPos::new(2, 2)];
|
||||
|
||||
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &eu, &ec, &la);
|
||||
let (ec_cpu, er_cpu) = cpu_move_unit(5, 5, 40, &[], &[(12, 10)], &[(2, 2)]);
|
||||
assert_eq!(cols[0], ec_cpu, "col mismatch city priority");
|
||||
assert_eq!(rows[0], er_cpu, "row mismatch city priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_lair_fallback() {
|
||||
let ctx = match make_ctx() {
|
||||
Some(c) => c,
|
||||
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
|
||||
};
|
||||
// No enemies or cities; lair at (15,10)
|
||||
let units = vec![GpuMovUnit { col: 5, row: 5, seek_range: 40, _pad: 0 }];
|
||||
let la = vec![GpuLairPos::new(15, 10)];
|
||||
|
||||
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &[], &[], &la);
|
||||
let (ec_cpu, er_cpu) = cpu_move_unit(5, 5, 40, &[], &[], &[(15, 10)]);
|
||||
assert_eq!(cols[0], ec_cpu, "col mismatch lair fallback");
|
||||
assert_eq!(rows[0], er_cpu, "row mismatch lair fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_no_targets_no_move() {
|
||||
let ctx = match make_ctx() {
|
||||
Some(c) => c,
|
||||
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
|
||||
};
|
||||
let units = vec![GpuMovUnit { col: 7, row: 3, seek_range: 40, _pad: 0 }];
|
||||
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &[], &[], &[]);
|
||||
// No targets → no move
|
||||
assert_eq!(cols[0], 7, "should stay col");
|
||||
assert_eq!(rows[0], 3, "should stay row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_unit_on_lair_no_move() {
|
||||
let ctx = match make_ctx() {
|
||||
Some(c) => c,
|
||||
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
|
||||
};
|
||||
// Unit already on the lair tile → should not move
|
||||
let units = vec![GpuMovUnit { col: 5, row: 5, seek_range: 40, _pad: 0 }];
|
||||
let la = vec![GpuLairPos::new(5, 5)];
|
||||
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &[], &[], &la);
|
||||
let (ec_cpu, er_cpu) = cpu_move_unit(5, 5, 40, &[], &[], &[(5, 5)]);
|
||||
assert_eq!(cols[0], ec_cpu);
|
||||
assert_eq!(rows[0], er_cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_enemy_out_of_seek_range() {
|
||||
let ctx = match make_ctx() {
|
||||
Some(c) => c,
|
||||
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
|
||||
};
|
||||
// Enemy is 50 hexes away, seek=40 → should fall to lair
|
||||
let units = vec![GpuMovUnit { col: 0, row: 0, seek_range: 40, _pad: 0 }];
|
||||
let eu = vec![GpuTarget::new(50, 50)];
|
||||
let la = vec![GpuLairPos::new(3, 3)];
|
||||
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &eu, &[], &la);
|
||||
let (ec_cpu, er_cpu) = cpu_move_unit(0, 0, 40, &[(50, 50)], &[], &[(3, 3)]);
|
||||
assert_eq!(cols[0], ec_cpu, "col: out-of-range enemy → lair fallback");
|
||||
assert_eq!(rows[0], er_cpu, "row: out-of-range enemy → lair fallback");
|
||||
}
|
||||
|
||||
/// 1000-unit integration test: random positions, verify GPU == CPU for all units.
|
||||
#[test]
|
||||
fn parity_1000_units() {
|
||||
let ctx = match make_ctx() {
|
||||
Some(c) => c,
|
||||
None => { eprintln!("[gpu-test] no adapter — skipping parity_1000_units"); return; }
|
||||
};
|
||||
|
||||
// Deterministic pseudo-random sequence (SplitMix64-like, no deps)
|
||||
fn next_val(s: &mut u64) -> u64 {
|
||||
*s = s.wrapping_add(0x9E3779B97F4A7C15);
|
||||
let mut z = *s;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
let mut rng: u64 = 0xDEADBEEF_CAFEBABE;
|
||||
let grid_size: i32 = 64;
|
||||
|
||||
let n_units: usize = 1000;
|
||||
let n_eu: usize = 30;
|
||||
let n_ec: usize = 20;
|
||||
let n_lairs: usize = 15;
|
||||
|
||||
let mut gpu_units: Vec<GpuMovUnit> = Vec::with_capacity(n_units);
|
||||
let mut cpu_units: Vec<(i32, i32, i32)> = Vec::with_capacity(n_units); // col,row,seek
|
||||
|
||||
for _ in 0..n_units {
|
||||
let col = (next_val(&mut rng) % grid_size as u64) as i32;
|
||||
let row = (next_val(&mut rng) % grid_size as u64) as i32;
|
||||
let seek = if next_val(&mut rng) % 2 == 0 { 40i32 } else { 80i32 };
|
||||
gpu_units.push(GpuMovUnit { col, row, seek_range: seek, _pad: 0 });
|
||||
cpu_units.push((col, row, seek));
|
||||
}
|
||||
|
||||
let eu: Vec<GpuTarget> = (0..n_eu).map(|_| {
|
||||
GpuTarget::new(
|
||||
(next_val(&mut rng) % grid_size as u64) as i32,
|
||||
(next_val(&mut rng) % grid_size as u64) as i32,
|
||||
)
|
||||
}).collect();
|
||||
let ec: Vec<GpuTarget> = (0..n_ec).map(|_| {
|
||||
GpuTarget::new(
|
||||
(next_val(&mut rng) % grid_size as u64) as i32,
|
||||
(next_val(&mut rng) % grid_size as u64) as i32,
|
||||
)
|
||||
}).collect();
|
||||
let la: Vec<GpuLairPos> = (0..n_lairs).map(|_| {
|
||||
GpuLairPos::new(
|
||||
(next_val(&mut rng) % grid_size as u64) as i32,
|
||||
(next_val(&mut rng) % grid_size as u64) as i32,
|
||||
)
|
||||
}).collect();
|
||||
|
||||
let (gpu_cols, gpu_rows) = dispatch_unit_movement(&ctx, &gpu_units, &eu, &ec, &la);
|
||||
|
||||
let eu_cpu: Vec<(i32, i32)> = eu.iter().map(|t| (t.col, t.row)).collect();
|
||||
let ec_cpu: Vec<(i32, i32)> = ec.iter().map(|t| (t.col, t.row)).collect();
|
||||
let la_cpu: Vec<(i32, i32)> = la.iter().map(|t| (t.col, t.row)).collect();
|
||||
|
||||
for (i, &(col, row, seek)) in cpu_units.iter().enumerate() {
|
||||
let (exp_col, exp_row) = cpu_move_unit(col, row, seek, &eu_cpu, &ec_cpu, &la_cpu);
|
||||
assert_eq!(gpu_cols[i], exp_col, "unit {i} col mismatch");
|
||||
assert_eq!(gpu_rows[i], exp_row, "unit {i} row mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graceful_when_no_gpu() {
|
||||
let result = std::panic::catch_unwind(GpuContext::try_init);
|
||||
assert!(result.is_ok(), "try_init must not panic even without GPU");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "gpu")]
|
||||
pub use inner::{dispatch_unit_movement, GpuLairPos, GpuMovUnit, GpuTarget};
|
||||
Loading…
Add table
Reference in a new issue