diff --git a/.github/workflows/godot-ci.yml b/.github/workflows/godot-ci.yml new file mode 100644 index 0000000..cb6a697 --- /dev/null +++ b/.github/workflows/godot-ci.yml @@ -0,0 +1,31 @@ +name: Godot HTML5 Build + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: chickensoft-games/setup-godot@v1 + with: + version: 4.3.0 + use-dotnet: false + - run: godot --version + - run: | + cd godot + godot --headless --import + godot --headless --export-release "Web" ../dist/index.html + - uses: actions/upload-artifact@v4 + with: + name: human-vs-bots-html5 + path: dist/ + - uses: peaceiris/actions-gh-pages@v4 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist \ No newline at end of file diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..a93d6a0 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1 @@ +sonar.exclusions=dist/**/*,.godot/**/*,demo/**/*,**/*.min.js,**/*.wasm \ No newline at end of file diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..8b38f0d --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,14 @@ +[preset.0] +name="Web" +platform="Web" +runnable=true +export_filter="all_resources" +export_path="dist/index.html" + +[preset.0.options] +variant/thread_support=true +vram_texture_compression/for_desktop=true +html/export_icon=true +html/head_include="" +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true \ No newline at end of file diff --git a/godot/project.godot b/godot/project.godot index 7a31998..e517e5d 100644 --- a/godot/project.godot +++ b/godot/project.godot @@ -1,29 +1,31 @@ -; Engine configuration file. -; Edit this file through the Godot editor when possible. -; -; Format: -; [section] -; param=value - -config_version=5 - [application] +config/name="Human vs Bots" +config/description="Hexagonal strategy game with Web3/ZK integration" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.3", "Mobile") -config/name="HumanVsBots" -run/main_scene="res://scenes/Main.tscn" +[autoload] +GameState="*res://src/autoload/GameState.gd" +TurnManager="*res://src/autoload/TurnManager.gd" +EconomyManager="*res://src/autoload/EconomyManager.gd" +EventBus="*res://src/autoload/EventBus.gd" +Web3Bridge="*res://src/autoload/Web3Bridge.gd" +AudioManager="*res://src/autoload/AudioManager.gd" [autoload] WebBridge="*res://autoloads/WebBridge.gd" [display] - window/size/viewport_width=1280 window/size/viewport_height=720 window/stretch/mode="canvas_items" - -[rendering] - -renderer/rendering_method="gl_compatibility" -renderer/rendering_method.mobile="gl_compatibility" -renderer/rendering_method.web="gl_compatibility" +window/stretch/aspect="expand" + +[input] +zoom_in={"deadzone":0.5,"events":[Object(InputEventMouseButton,"button_index":4)]} +zoom_out={"deadzone":0.5,"events":[Object(InputEventMouseButton,"button_index":5)]} +pan_map={"deadzone":0.5,"events":[Object(InputEventMouseButton,"button_index":3)]} +select_unit={"deadzone":0.5,"events":[Object(InputEventMouseButton,"button_index":1)]} +toggle_conquer={"deadzone":0.5,"events":[Object(InputEventKey,"keycode":67)]} +end_turn={"deadzone":0.5,"events":[Object(InputEventKey,"keycode":32)]} \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..5f3a45b --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,9 @@ +# SonarCloud exclusions for Godot HTML5 project +sonar.exclusions=\ + dist/**/*,\ + .godot/**/*,\ + demo/**/*,\ + scripts/**/*,\ + **/*.min.js,\ + **/*.wasm,\ + **/*.html \ No newline at end of file diff --git a/src/Main.gd b/src/Main.gd new file mode 100644 index 0000000..5289a71 --- /dev/null +++ b/src/Main.gd @@ -0,0 +1,25 @@ +extends Node + +func _ready() -> void: + NoiseGenerator.set_seed(randi()) + await _load_assets() + + var map_gen = MapGenerator.new() + if not map_gen.generate_with_retries(): + push_error("Failed to generate map"); return + + var hex_map = $Game/HexMap + if hex_map: hex_map.render_map() + + EconomyManager.reset() + AudioManager.play_music("ambient_strategy") + EventBus.emit_signal("hud_message", "Ready: produce units, conquer tiles, defeat bot tech-core.", "ok") + +func _load_assets() -> void: + for terrain_file in ["Grassland.png", "Forest.png", "Hill.png", "Coast.png", "Desert.png"]: + var path = "res://assets/terrain/" + terrain_file + if ResourceLoader.exists(path): ResourceLoader.load(path) + for unit_file in ["Warrior.png", "Tank.png", "Infantry.png"]: + var path = "res://assets/units/" + unit_file + if ResourceLoader.exists(path): ResourceLoader.load(path) + await get_tree().create_timer(0.1).timeout \ No newline at end of file diff --git a/src/autoload/AudioManager.gd b/src/autoload/AudioManager.gd new file mode 100644 index 0000000..c64fbbe --- /dev/null +++ b/src/autoload/AudioManager.gd @@ -0,0 +1,108 @@ +extends Node + +const BUS_MASTER: String = "Master" +const BUS_MUSIC: String = "Music" +const BUS_SFX: String = "SFX" + +var music_player: AudioStreamPlayer +var sfx_players: Array = [] +var max_sfx_players: int = 8 +var music_tracks: Dictionary = {} +var sfx_clips: Dictionary = {} +var current_music: String = "" +var music_volume: float = 0.7; var sfx_volume: float = 0.8; var master_volume: float = 1.0 + +func _ready() -> void: + _setup_audio_buses(); _setup_players(); _connect_events() + +func _setup_audio_buses() -> void: + if AudioServer.get_bus_index(BUS_MUSIC) == -1: + AudioServer.add_bus(AudioServer.bus_count) + AudioServer.set_bus_name(AudioServer.bus_count - 1, BUS_MUSIC) + if AudioServer.get_bus_index(BUS_SFX) == -1: + AudioServer.add_bus(AudioServer.bus_count) + AudioServer.set_bus_name(AudioServer.bus_count - 1, BUS_SFX) + AudioServer.set_bus_send(AudioServer.get_bus_index(BUS_MUSIC), BUS_MASTER) + AudioServer.set_bus_send(AudioServer.get_bus_index(BUS_SFX), BUS_MASTER) + +func _setup_players() -> void: + music_player = AudioStreamPlayer.new() + music_player.bus = BUS_MUSIC; music_player.name = "MusicPlayer" + add_child(music_player) + for i in range(max_sfx_players): + var player = AudioStreamPlayer.new() + player.bus = BUS_SFX; player.name = "SFXPlayer_%d" % i + add_child(player); sfx_players.append(player) + +func _connect_events() -> void: + EventBus.connect("play_sfx", _on_play_sfx) + EventBus.connect("play_music", _on_play_music) + EventBus.connect("stop_music", _on_stop_music) + +func _on_play_sfx(sfx_name: String) -> void: play_sfx(sfx_name) +func _on_play_music(track_name: String) -> void: play_music(track_name) +func _on_stop_music() -> void: stop_music() + +func play_music(track_name: String, fade_duration: float = 1.0) -> void: + if track_name == current_music and music_player.playing: return + var track = _load_music_track(track_name) + if not track: push_warning("AudioManager: Track not found: " + track_name); return + if music_player.playing and fade_duration > 0: + var tween = create_tween(); tween.tween_property(music_player, "volume_db", -80.0, fade_duration / 2.0) + await tween.finished; music_player.stop() + music_player.stream = track; music_player.volume_db = -80.0; music_player.play(); current_music = track_name + if fade_duration > 0: + var target_db = linear_to_db(music_volume * master_volume) + var tween = create_tween(); tween.tween_property(music_player, "volume_db", target_db, fade_duration / 2.0) + +func stop_music(fade_duration: float = 0.5) -> void: + if not music_player.playing: return + if fade_duration > 0: + var tween = create_tween(); tween.tween_property(music_player, "volume_db", -80.0, fade_duration) + await tween.finished + music_player.stop(); current_music = "" + +func play_sfx(sfx_name: String, pitch_variation: float = 0.0) -> void: + var clip = _load_sfx_clip(sfx_name) + if not clip: push_warning("AudioManager: SFX not found: " + sfx_name); return + for player in sfx_players: + if not player.playing: + player.stream = clip; player.pitch_scale = 1.0 + randf_range(-pitch_variation, pitch_variation) + player.volume_db = linear_to_db(sfx_volume * master_volume); player.play(); return + sfx_players[0].stop(); sfx_players[0].stream = clip; sfx_players[0].play() + +func set_master_volume(volume: float) -> void: + master_volume = clampf(volume, 0.0, 1.0) + AudioServer.set_bus_volume_db(AudioServer.get_bus_index(BUS_MASTER), linear_to_db(master_volume)) + +func set_music_volume(volume: float) -> void: + music_volume = clampf(volume, 0.0, 1.0) + var target_db = linear_to_db(music_volume * master_volume) + if music_player.playing: + var tween = create_tween(); tween.tween_property(music_player, "volume_db", target_db, 0.3) + else: music_player.volume_db = target_db + +func _load_music_track(track_name: String) -> AudioStream: + if music_tracks.has(track_name): return music_tracks[track_name] + var path = "res://assets/audio/music/%s.ogg" % track_name + if ResourceLoader.exists(path): + var track = load(path); music_tracks[track_name] = track; return track + return null + +func _load_sfx_clip(sfx_name: String) -> AudioStream: + if sfx_clips.has(sfx_name): return sfx_clips[sfx_name] + for ext in [".wav", ".ogg", ".mp3"]: + var path = "res://assets/audio/sfx/%s%s" % [sfx_name, ext] + if ResourceLoader.exists(path): + var clip = load(path); sfx_clips[sfx_name] = clip; return clip + return null + +# Shortcuts +func play_ui_click() -> void: play_sfx("ui_click", 0.1) +func play_unit_move() -> void: play_sfx("unit_move", 0.15) +func play_unit_attack() -> void: play_sfx("unit_attack", 0.2) +func play_unit_die() -> void: play_sfx("unit_die", 0.1) +func play_conquer_tile() -> void: play_sfx("conquer_tile", 0.1) +func play_turn_end() -> void: play_sfx("turn_end") +func play_victory() -> void: play_sfx("victory") +func play_defeat() -> void: play_sfx("defeat") \ No newline at end of file diff --git a/src/autoload/EconomyManager.gd b/src/autoload/EconomyManager.gd new file mode 100644 index 0000000..79391a2 --- /dev/null +++ b/src/autoload/EconomyManager.gd @@ -0,0 +1,82 @@ +extends Node + +var food: int = 0 +var production: int = 0 +var science: int = 0 +var gold: int = 0 + +var food_income: int = 0 +var production_income: int = 0 +var science_income: int = 0 +var gold_income: int = 0 + +const TERRAIN_YIELDS: Dictionary = { + GameState.Terrain.PLAINS: {"food": 2, "production": 1, "science": 1, "gold": 1}, + GameState.Terrain.FOREST: {"food": 1, "production": 2, "science": 1, "gold": 0}, + GameState.Terrain.HILL: {"food": 0, "production": 3, "science": 0, "gold": 1}, + GameState.Terrain.DESERT: {"food": 0, "production": 0, "science": 2, "gold": 2}, + GameState.Terrain.WATER: {"food": 1, "production": 0, "science": 0, "gold": 2} +} + +const STRUCTURE_BONUSES: Dictionary = { + GameState.StructureType.HQ: {"food": 5, "production": 3, "science": 3, "gold": 3}, + GameState.StructureType.BARRACKS: {"food": 0, "production": 2, "science": 0, "gold": 0}, + GameState.StructureType.FACTORY: {"food": 0, "production": 5, "science": 2, "gold": 1}, + GameState.StructureType.TECH_CORE: {"food": 0, "production": 3, "science": 5, "gold": 2} +} + +func _ready() -> void: + EventBus.connect("turn_started", _on_turn_started) + EventBus.connect("territory_changed", _on_territory_changed) + EventBus.connect("structure_built", _on_structure_built) + +func reset() -> void: + food = 0; production = 0; science = 0; gold = 0 + _recalculate_income() + _emit_resources_changed() + +func _on_turn_started(_turn: int, _phase: int) -> void: + food += food_income; production += production_income + science += science_income; gold += gold_income + _emit_resources_changed() + +func _recalculate_income() -> void: + food_income = 0; production_income = 0; science_income = 0; gold_income = 0 + + for cell in GameState.map_cells.values(): + if cell.owner == GameState.Team.HUMAN: + var yields = TERRAIN_YIELDS.get(cell.terrain, TERRAIN_YIELDS[GameState.Terrain.PLAINS]) + food_income += yields.food; production_income += yields.production + science_income += yields.science; gold_income += yields.gold + + for structure in GameState.structures.values(): + if structure.team == GameState.Team.HUMAN: + var bonus = STRUCTURE_BONUSES.get(structure.structure_type, {}) + food_income += bonus.get("food", 0) + production_income += bonus.get("production", 0) + science_income += bonus.get("science", 0) + gold_income += bonus.get("gold", 0) + + EventBus.emit_signal("income_calculated", food_income, production_income, + science_income, gold_income) + +func _on_territory_changed(_coords: Vector2i, _new_owner: int) -> void: _recalculate_income() +func _on_structure_built(_id: String, _type: int, _team: int) -> void: _recalculate_income() + +func _emit_resources_changed() -> void: + EventBus.emit_signal("resources_changed", food, production, science, gold) + +func can_afford(costs: Dictionary) -> bool: + return (food >= costs.get("food", 0) and production >= costs.get("production", 0) and + science >= costs.get("science", 0) and gold >= costs.get("gold", 0)) + +func spend(costs: Dictionary) -> bool: + if not can_afford(costs): return false + food -= costs.get("food", 0); production -= costs.get("production", 0) + science -= costs.get("science", 0); gold -= costs.get("gold", 0) + _emit_resources_changed(); return true + +func add_resources(amounts: Dictionary) -> void: + food += amounts.get("food", 0); production += amounts.get("production", 0) + science += amounts.get("science", 0); gold += amounts.get("gold", 0) + _emit_resources_changed() \ No newline at end of file diff --git a/src/autoload/EventBus.gd b/src/autoload/EventBus.gd new file mode 100644 index 0000000..daa1983 --- /dev/null +++ b/src/autoload/EventBus.gd @@ -0,0 +1,42 @@ +extends Node + +signal cell_clicked(cell_coords: Vector2i) +signal cell_hovered(cell_coords: Vector2i) +signal territory_changed(coords: Vector2i, new_owner: int) +signal unit_moved(unit_id: int, from_coords: Vector2i, to_coords: Vector2i) +signal unit_attacked(attacker_id: int, defender_id: int, damage: int) +signal unit_died(unit_id: int) +signal unit_spawned(unit_id: int, unit_type: int, team: int) +signal unit_selected(unit_id: int) +signal unit_deselected +signal turn_started(turn_number: int, phase: int) +signal turn_ended(turn_number: int) +signal phase_changed(new_phase: int) +signal structure_built(structure_id: String, structure_type: int, team: int) +signal structure_acted(structure_id: String) +signal unit_produced(structure_id: String, unit_id: int) +signal resources_changed(food: int, production: int, science: int, gold: int) +signal income_calculated(food_income: int, production_income: int, + science_income: int, gold_income: int) +signal wallet_connected(address: String) +signal wallet_disconnected +signal game_started_on_chain(tx_hash: String) +signal proof_submitted(proof_id: String) +signal game_ended_on_chain(tx_hash: String) +signal zk_proof_generated(proof_data: Dictionary) +signal zk_proof_verified(result: bool) +signal hud_message(message: String, message_type: String) +signal match_result_displayed(result: String) +signal zoom_changed(zoom_level: float) +signal camera_panned(position: Vector2) +signal play_sfx(sfx_name: String) +signal play_music(track_name: String) +signal stop_music +signal combat_started(attacker_id: int, defender_id: int) +signal combat_ended(attacker_id: int, defender_id: int, attacker_won: bool) +signal tile_conquered(coords: Vector2i, conqueror_team: int) +signal game_reset +signal game_paused +signal game_resumed +signal save_requested +signal load_requested(save_data: Dictionary) \ No newline at end of file diff --git a/src/autoload/GameState.gd b/src/autoload/GameState.gd new file mode 100644 index 0000000..bf559b4 --- /dev/null +++ b/src/autoload/GameState.gd @@ -0,0 +1,193 @@ +extends Node + +enum Team { HUMAN, BOT, NEUTRAL } +enum Phase { SETUP, PLAYER, BOT, SIMULATION, ENDED } +enum Terrain { PLAINS, FOREST, HILL, WATER, DESERT } +enum UnitType { WARRIOR, CAR, ROBOT } +enum StructureType { HQ, BARRACKS, FACTORY, TECH_CORE } +enum MatchMode { HUMAN_VS_LLM, LLM_VS_LLM } +enum Difficulty { EASY, NORMAL, HARD } + +const HEX_SIZE: float = 34.0 +const MAP_COLS: int = 18 +const MAP_ROWS: int = 13 + +var match_mode: MatchMode = MatchMode.HUMAN_VS_LLM +var selected_ai: String = "claude-3-5-sonnet" +var selected_human_model: String = "claude-3-5-sonnet" +var selected_difficulty: Difficulty = Difficulty.NORMAL + +var current_phase: Phase = Phase.SETUP +var turn_number: int = 0 +var in_match: bool = false +var wallet_connected: bool = false +var wallet_address: String = "" + +var map_cells: Dictionary = {} +var units: Dictionary = {} +var structures: Dictionary = {} +var proofs: Array = [] +var next_unit_id: int = 1 + +var selected_unit_id: int = -1 +var capture_mode: bool = false + +const LLM_PROFILES: Dictionary = { + "claude-3-5-sonnet": {"name": "Claude 3.5 Sonnet", "difficulty": "Hard", "atk_mul": 1.08, "hp_mul": 1.05, "style": "balanced"}, + "claude-3-opus": {"name": "Claude 3 Opus", "difficulty": "Very Hard", "atk_mul": 1.14, "hp_mul": 1.10, "style": "aggressive"}, + "clawbot-v2": {"name": "Clawbot v2", "difficulty": "Medium", "atk_mul": 1.0, "hp_mul": 1.0, "style": "swarm"}, + "gpt-4o": {"name": "OpenAI GPT-4o", "difficulty": "Hard", "atk_mul": 1.10, "hp_mul": 1.04, "style": "balanced"}, + "gpt-4.1-mini": {"name": "OpenAI GPT-4.1 mini", "difficulty": "Medium", "atk_mul": 0.98, "hp_mul": 1.03, "style": "defensive"}, + "o1-mini": {"name": "OpenAI o1-mini", "difficulty": "Very Hard", "atk_mul": 1.12, "hp_mul": 1.08, "style": "aggressive"} +} + +const UNIT_STATS: Dictionary = { + UnitType.WARRIOR: {"hp": 95, "atk": 16, "sprite": "Warrior.png", "label": "W"}, + UnitType.CAR: {"hp": 130, "atk": 23, "sprite": "Tank.png", "label": "C"}, + UnitType.ROBOT: {"hp": 120, "atk": 20, "sprite": "Infantry.png", "label": "R"} +} + +const STRUCTURE_PRODUCTION: Dictionary = { + StructureType.BARRACKS: [UnitType.WARRIOR], + StructureType.FACTORY: [UnitType.CAR], + StructureType.TECH_CORE: [UnitType.ROBOT] +} + +signal state_changed +signal unit_selected(unit_id: int) +signal match_started +signal match_ended(result: String) +signal wallet_status_changed(connected: bool) +signal turn_changed(turn: int) +signal phase_changed(phase: Phase) + +func _ready() -> void: + EventBus.connect("wallet_connected", _on_wallet_connected) + EventBus.connect("wallet_disconnected", _on_wallet_disconnected) + +func reset_game() -> void: + current_phase = Phase.SETUP + turn_number = 0 + in_match = false + proofs.clear() + selected_unit_id = -1 + capture_mode = false + next_unit_id = 1 + + for unit in units.values(): + if is_instance_valid(unit): unit.queue_free() + units.clear() + + for structure in structures.values(): + if is_instance_valid(structure): structure.queue_free() + structures.clear() + + map_cells.clear() + state_changed.emit() + +func start_match() -> void: + in_match = true + turn_number = 1 + current_phase = Phase.PLAYER if match_mode == MatchMode.HUMAN_VS_LLM else Phase.SIMULATION + proofs.clear() + match_started.emit() + turn_changed.emit(turn_number) + phase_changed.emit(current_phase) + state_changed.emit() + +func end_match(result: String) -> void: + in_match = false + current_phase = Phase.ENDED + match_ended.emit(result) + phase_changed.emit(current_phase) + state_changed.emit() + +func get_selected_unit() -> Unit: + if selected_unit_id == -1: return null + return units.get(selected_unit_id) + +func get_units_by_team(team: Team) -> Array: + var result = [] + for unit in units.values(): + if unit.team == team and unit.is_alive(): + result.append(unit) + return result + +func get_llm_profile(model_id: String) -> Dictionary: + return LLM_PROFILES.get(model_id, LLM_PROFILES["claude-3-5-sonnet"]) + +func get_opponent_profile() -> Dictionary: + return get_llm_profile(selected_ai) + +func get_human_side_profile() -> Dictionary: + if match_mode != MatchMode.LLM_VS_LLM: return {} + return get_llm_profile(selected_human_model) + +func get_unit_stats(unit_type: UnitType) -> Dictionary: + return UNIT_STATS[unit_type] + +func get_difficulty_multiplier(for_team: Team) -> float: + match selected_difficulty: + Difficulty.HARD: return 1.12 if for_team == Team.BOT else 0.95 + Difficulty.EASY: return 0.90 if for_team == Team.BOT else 1.06 + _: return 1.0 + +func count_territory(owner: Team) -> int: + var count = 0 + for cell in map_cells.values(): + if cell.owner == owner: count += 1 + return count + +func count_living_units(team: Team) -> int: + var count = 0 + for unit in units.values(): + if unit.team == team and unit.is_alive(): count += 1 + return count + +func build_proof_snapshot(tag: String = "turn") -> Dictionary: + var payload = { + "tag": tag, + "turn": turn_number, + "ai": selected_ai, + "difficulty": _difficulty_to_string(), + "humans_alive": count_living_units(Team.HUMAN), + "bots_alive": count_living_units(Team.BOT), + "human_territory": count_territory(Team.HUMAN), + "bot_territory": count_territory(Team.BOT), + "timestamp": Time.get_datetime_string_from_system(true), + "proof_input_hash": _hash_proof_input() + } + proofs.append(payload) + EventBus.emit_signal("proof_generated", payload) + return payload + +func _hash_proof_input() -> String: + var input = "%d|%d|%d|%s" % [turn_number, count_territory(Team.HUMAN), + count_territory(Team.BOT), selected_ai] + return Marshalls.utf8_to_base64(input) + +func export_proofs() -> String: + var data = { + "game": "human-vs-bots", + "mode": "turn-based-buildings", + "ai": selected_ai, + "difficulty": _difficulty_to_string(), + "proofs": proofs + } + return JSON.stringify(data, "\t") + +func _difficulty_to_string() -> String: + match selected_difficulty: + Difficulty.EASY: return "easy" + Difficulty.HARD: return "hard" + _: return "normal" + +func _on_wallet_connected(address: String) -> void: + wallet_connected = true + wallet_address = address + wallet_status_changed.emit(true) + +func _on_wallet_disconnected() -> void: + wallet_connected = false + wallet_address = "" + wallet_status_changed.emit(false) \ No newline at end of file diff --git a/src/autoload/TurnManager.gd b/src/autoload/TurnManager.gd new file mode 100644 index 0000000..85658da --- /dev/null +++ b/src/autoload/TurnManager.gd @@ -0,0 +1,164 @@ +extends Node + +enum TurnPhase { COMMIT, REVEAL, RESOLVE, END } + +var current_phase: TurnPhase = TurnPhase.COMMIT +var is_processing: bool = false +var commit_actions: Dictionary = {} +var reveal_queue: Array = [] +var turn_timer: Timer + +func _ready() -> void: + turn_timer = Timer.new() + turn_timer.one_shot = true + add_child(turn_timer) + EventBus.connect("turn_ended", _on_turn_ended) + +func start_turn(turn_number: int) -> void: + current_phase = TurnPhase.COMMIT + commit_actions.clear() + reveal_queue.clear() + is_processing = false + + for unit in GameState.units.values(): + unit.acted = false + unit.selected = false + + for structure in GameState.structures.values(): + structure.acted = false + + GameState.selected_unit_id = -1 + GameState.capture_mode = false + + EventBus.emit_signal("turn_started", turn_number, GameState.current_phase) + EventBus.emit_signal("hud_message", "Turn %d started" % turn_number, "info") + +func commit_action(unit_id: int, action_type: String, target: Variant) -> bool: + if current_phase != TurnPhase.COMMIT: + return false + + var unit = GameState.units.get(unit_id) + if not unit or unit.acted: return false + + commit_actions[unit_id] = { + "type": action_type, + "target": target, + "timestamp": Time.get_ticks_msec() + } + unit.acted = true + + _check_all_committed() + return true + +func _check_all_committed() -> void: + var living_units = GameState.get_units_by_team(GameState.Team.HUMAN) + var all_committed = true + for unit in living_units: + if not unit.acted: all_committed = false; break + + if all_committed and living_units.size() > 0: + _advance_to_reveal() + +func _advance_to_reveal() -> void: + current_phase = TurnPhase.REVEAL + EventBus.emit_signal("phase_changed", GameState.Phase.BOT) + + _process_bot_turn() + turn_timer.start(0.5) + await turn_timer.timeout + _process_reveal() + +func _process_bot_turn() -> void: + var bot_units = GameState.get_units_by_team(GameState.Team.BOT) + for bot in bot_units: + if not bot.is_alive(): continue + var action = bot.decide_ai_action() + if action: commit_actions[bot.id] = action + +func _process_reveal() -> void: + current_phase = TurnPhase.RESOLVE + reveal_queue = commit_actions.keys() + reveal_queue.sort_custom(func(a, b): + return commit_actions[a].timestamp < commit_actions[b].timestamp) + + for unit_id in reveal_queue: + var action = commit_actions[unit_id] + await _resolve_action(unit_id, action) + await get_tree().create_timer(0.15).timeout + + _end_turn() + +func _resolve_action(unit_id: int, action: Dictionary) -> void: + var unit = GameState.units.get(unit_id) + if not unit or not unit.is_alive(): return + + match action.type: + "move": + var target_coords = action.target + if HexMath.is_passable(target_coords) and not HexMath.is_occupied(target_coords): + var old_coords = Vector2i(unit.q, unit.r) + unit.move_to(target_coords.x, target_coords.y) + EventBus.emit_signal("unit_moved", unit_id, old_coords, target_coords) + "attack": + var target_id = action.target + var target_unit = GameState.units.get(target_id) + if target_unit and target_unit.is_alive(): + CombatSystem.resolve_combat(unit, target_unit) + "conquer": + var cell_coords = action.target + var cell = GameState.map_cells.get(cell_coords) + if cell and cell.terrain != GameState.Terrain.WATER: + cell.owner = unit.team + EventBus.emit_signal("tile_conquered", cell_coords, unit.team) + "produce": + var structure_id = action.target + var structure = GameState.structures.get(structure_id) + if structure: structure.produce_unit() + +func _end_turn() -> void: + current_phase = TurnPhase.END + if _check_victory(): return + + GameState.build_proof_snapshot("turn") + EventBus.emit_signal("turn_ended", GameState.turn_number) + GameState.turn_number += 1 + + if GameState.match_mode == GameState.MatchMode.HUMAN_VS_LLM: + GameState.current_phase = GameState.Phase.PLAYER + else: + GameState.current_phase = GameState.Phase.SIMULATION + + EventBus.emit_signal("phase_changed", GameState.current_phase) + start_turn(GameState.turn_number) + +func _check_victory() -> bool: + var humans_alive = GameState.count_living_units(GameState.Team.HUMAN) + var bots_alive = GameState.count_living_units(GameState.Team.BOT) + var total_land = 0 + var human_land = GameState.count_territory(GameState.Team.HUMAN) + var bot_land = GameState.count_territory(GameState.Team.BOT) + + for cell in GameState.map_cells.values(): + if cell.terrain != GameState.Terrain.WATER: total_land += 1 + + var human_win = "LLM A Wins" if GameState.match_mode == GameState.MatchMode.LLM_VS_LLM else "Humans Win" + var bot_win = "Opponent LLM Wins" if GameState.match_mode == GameState.MatchMode.LLM_VS_LLM else "Bots Win" + + if bots_alive == 0: + GameState.end_match(human_win); return true + if humans_alive == 0: + GameState.end_match(bot_win); return true + + if total_land > 0: + if human_land >= total_land * 0.65: + GameState.end_match("%s by Territory" % human_win); return true + if bot_land >= total_land * 0.65: + GameState.end_match("%s by Territory" % bot_win); return true + + return false + +func force_end_turn() -> void: + if current_phase == TurnPhase.COMMIT: + _advance_to_reveal() + +func _on_turn_ended(_turn_number: int) -> void: pass \ No newline at end of file diff --git a/src/autoload/Web3Bridge.gd b/src/autoload/Web3Bridge.gd new file mode 100644 index 0000000..cd4a22b --- /dev/null +++ b/src/autoload/Web3Bridge.gd @@ -0,0 +1,135 @@ +extends Node + +var js_window = null +var js_stellar_service = null +var is_web_build: bool = false + +signal wallet_connected(address: String) +signal wallet_connection_failed(error: String) +signal game_started(tx_hash: String) +signal game_start_failed(error: String) +signal proof_submitted(proof_id: String) +signal proof_submission_failed(error: String) +signal game_ended(tx_hash: String) +signal game_end_failed(error: String) + +func _ready() -> void: + is_web_build = OS.has_feature("web") + if is_web_build: _initialize_js_bridge() + else: _setup_mock_service() + +func _initialize_js_bridge() -> void: + js_window = JavaScriptBridge.get_interface("window") + if js_window: + var has_service = JavaScriptBridge.eval("typeof window.StellarGameService !== 'undefined'") + if has_service: + js_stellar_service = JavaScriptBridge.get_interface("StellarGameService") + else: + _inject_mock_js_service() + +func _inject_mock_js_service() -> void: + var mock_js = """ + window.StellarGameService = { + connectWallet: async function() { + await new Promise(r => setTimeout(r, 350)); + return { address: 'GHUMANVSBOTSDEMO12345XYZ' }; + }, + start_game: async function(payload) { + await new Promise(r => setTimeout(r, 260)); + return { txHash: 'tx_start_' + Date.now(), payload: payload }; + }, + submit_zk_proof: async function(payload) { + await new Promise(r => setTimeout(r, 420)); + return { proofId: 'proof_' + Math.floor(Math.random() * 99999), payload: payload }; + }, + end_game: async function(result) { + await new Promise(r => setTimeout(r, 260)); + return { txHash: 'tx_end_' + Date.now(), result: result }; + } + }; + """ + JavaScriptBridge.eval(mock_js) + js_stellar_service = JavaScriptBridge.get_interface("StellarGameService") + +func _setup_mock_service() -> void: pass + +func connect_wallet() -> void: + if is_web_build and js_stellar_service: + var promise = js_stellar_service.connectWallet() + promise.then(_on_wallet_connected).catch(_on_wallet_error) + else: + await get_tree().create_timer(0.35).timeout + _on_wallet_connected({"address": "GHUMANVSBOTSDEMO12345XYZ"}) + +func _on_wallet_connected(result) -> void: + var address = result.address if result.has("address") else str(result) + GameState.wallet_connected = true; GameState.wallet_address = address + EventBus.emit_signal("wallet_connected", address) + wallet_connected.emit(address) + +func _on_wallet_error(error) -> void: + wallet_connection_failed.emit(str(error)) + +func start_game_on_chain(mode: String, llm_a: String, ai: String, + difficulty: String, contract: String) -> void: + var payload = {"mode": mode, "llmA": llm_a, "ai": ai, + "difficulty": difficulty, "contract": contract} + if is_web_build and js_stellar_service: + var promise = js_stellar_service.start_game(payload) + promise.then(_on_game_started).catch(_on_game_start_error) + else: + await get_tree().create_timer(0.26).timeout + _on_game_started({"txHash": "tx_start_" + str(Time.get_ticks_msec())}) + +func _on_game_started(result) -> void: + var tx_hash = result.txHash if result.has("txHash") else str(result) + EventBus.emit_signal("game_started_on_chain", tx_hash) + game_started.emit(tx_hash) + +func _on_game_start_error(error) -> void: game_start_failed.emit(str(error)) + +func submit_zk_proof(proof_data: Dictionary) -> void: + if is_web_build and js_stellar_service: + var promise = js_stellar_service.submit_zk_proof(proof_data) + promise.then(_on_proof_submitted).catch(_on_proof_error) + else: + await get_tree().create_timer(0.42).timeout + _on_proof_submitted({"proofId": "proof_" + str(randi() % 99999)}) + +func _on_proof_submitted(result) -> void: + var proof_id = result.proofId if result.has("proofId") else str(result) + EventBus.emit_signal("proof_submitted", proof_id) + proof_submitted.emit(proof_id) + +func _on_proof_error(error) -> void: proof_submission_failed.emit(str(error)) + +func end_game_on_chain(winner: String, turn: int) -> void: + var result = {"winner": winner, "turn": turn} + if is_web_build and js_stellar_service: + var promise = js_stellar_service.end_game(result) + promise.then(_on_game_ended).catch(_on_game_end_error) + else: + await get_tree().create_timer(0.26).timeout + _on_game_ended({"txHash": "tx_end_" + str(Time.get_ticks_msec())}) + +func _on_game_ended(result) -> void: + var tx_hash = result.txHash if result.has("txHash") else str(result) + EventBus.emit_signal("game_ended_on_chain", tx_hash) + game_ended.emit(tx_hash) + +func _on_game_end_error(error) -> void: game_end_failed.emit(str(error)) + +func export_proofs_to_js(proofs_json: String) -> void: + if is_web_build: + JavaScriptBridge.eval(""" + var blob = new Blob([JSON.stringify(""" + proofs_json + """)], {type: 'application/json'}); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'human-vs-bots-proofs-' + Date.now() + '.json'; + a.click(); + URL.revokeObjectURL(url); + """) + else: + print("Web3Bridge: Would export proofs (native mode)") + print(proofs_json) \ No newline at end of file diff --git a/src/camera/GameCamera.gd b/src/camera/GameCamera.gd new file mode 100644 index 0000000..476928e --- /dev/null +++ b/src/camera/GameCamera.gd @@ -0,0 +1,69 @@ +extends Camera2D +class_name GameCamera + +@export var min_zoom: float = 0.75 +@export var max_zoom: float = 1.8 +@export var zoom_speed: float = 0.08 +@export var pan_speed: float = 1.0 +@export var edge_scroll_margin: int = 30 +@export var edge_scroll_speed: float = 400.0 + +var is_panning: bool = false +var last_mouse_pos: Vector2 = Vector2.ZERO +var target_zoom: float = 1.0 + +func _ready() -> void: + target_zoom = zoom.x; make_current() + +func _process(delta: float) -> void: + _handle_edge_scroll(delta); _smooth_zoom(delta) + +func _input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: _zoom_in() + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: _zoom_out() + elif event.button_index == MOUSE_BUTTON_MIDDLE: + is_panning = event.pressed + if event.pressed: last_mouse_pos = event.position + if event is InputEventMouseMotion and is_panning: + position -= (event.position - last_mouse_pos) / zoom.x + last_mouse_pos = event.position + +func _handle_edge_scroll(delta: float) -> void: + var viewport_size = get_viewport().size + var mouse_pos = get_viewport().get_mouse_position() + var move_dir = Vector2.ZERO + if mouse_pos.x < edge_scroll_margin: move_dir.x = -1 + elif mouse_pos.x > viewport_size.x - edge_scroll_margin: move_dir.x = 1 + if mouse_pos.y < edge_scroll_margin: move_dir.y = -1 + elif mouse_pos.y > viewport_size.y - edge_scroll_margin: move_dir.y = 1 + if move_dir != Vector2.ZERO: + position += move_dir * edge_scroll_speed * delta / zoom.x + +func _zoom_in() -> void: target_zoom = min(target_zoom + zoom_speed, max_zoom) +func _zoom_out() -> void: target_zoom = max(target_zoom - zoom_speed, min_zoom) + +func _smooth_zoom(delta: float) -> void: + var current_zoom = zoom.x + if abs(current_zoom - target_zoom) > 0.001: + var new_zoom = lerp(current_zoom, target_zoom, 10.0 * delta) + zoom = Vector2(new_zoom, new_zoom) + EventBus.emit_signal("zoom_changed", new_zoom) + +func set_zoom_level(level: float) -> void: target_zoom = clampf(level, min_zoom, max_zoom) +func reset_zoom() -> void: target_zoom = 1.0 + +func focus_on_position(world_pos: Vector2) -> void: + var tween = create_tween() + tween.set_ease(Tween.EASE_OUT); tween.set_trans(Tween.TRANS_QUAD) + tween.tween_property(self, "position", world_pos, 0.5) + +func focus_on_unit(unit: Unit) -> void: + if unit: focus_on_position(unit.position) + +func shake(intensity: float = 5.0, duration: float = 0.3) -> void: + var original_pos = position; var tween = create_tween() + for i in range(10): + var offset = Vector2(randf_range(-intensity, intensity), randf_range(-intensity, intensity)) + tween.tween_property(self, "position", original_pos + offset, duration / 20) + tween.tween_property(self, "position", original_pos, duration / 20) \ No newline at end of file diff --git a/src/map/HexCell.gd b/src/map/HexCell.gd new file mode 100644 index 0000000..31896e3 --- /dev/null +++ b/src/map/HexCell.gd @@ -0,0 +1,31 @@ +extends RefCounted +class_name HexCell + +var coords: Vector2i +var q: int: get: return coords.x +var r: int: get: return coords.y +var terrain: int = GameState.Terrain.PLAINS +var owner: int = GameState.Team.NEUTRAL +var elevation: float = 0.0 +var moisture: float = 0.0 +var movement_cost: int = 1 +var defense_bonus: float = 1.0 + +func _init(_coords: Vector2i, _terrain: int = GameState.Terrain.PLAINS) -> void: + coords = _coords; terrain = _terrain + _update_derived_properties() + +func _update_derived_properties() -> void: + match terrain: + GameState.Terrain.PLAINS: movement_cost = 1; defense_bonus = 1.0 + GameState.Terrain.FOREST: movement_cost = 2; defense_bonus = 1.14 + GameState.Terrain.HILL: movement_cost = 2; defense_bonus = 1.22 + GameState.Terrain.DESERT: movement_cost = 1; defense_bonus = 1.0 + GameState.Terrain.WATER: movement_cost = 999; defense_bonus = 1.0 + +func set_owner(new_owner: int) -> void: + if owner != new_owner: + owner = new_owner + EventBus.emit_signal("territory_changed", coords, new_owner) + +func is_passable() -> bool: return terrain != GameState.Terrain.WATER \ No newline at end of file diff --git a/src/map/MapGenerator.gd b/src/map/MapGenerator.gd new file mode 100644 index 0000000..537ef7c --- /dev/null +++ b/src/map/MapGenerator.gd @@ -0,0 +1,93 @@ +extends Node +class_name MapGenerator + +var map_width: int = GameState.MAP_COLS +var map_height: int = GameState.MAP_ROWS + +func generate_map() -> Dictionary: + GameState.map_cells.clear() + var center_q = (map_width - 1) / 2.0 + var center_r = (map_height - 1) / 2.0 + var q_radius = 7.1; var r_radius = 5.4 + + for r in range(map_height): + for q in range(map_width): + var world_pos = HexMath.hex_to_pixel(q, r) + if world_pos.x < 30 or world_pos.y < 30: continue + if world_pos.x > 1250 or world_pos.y > 690: continue + + var norm_q = (q - center_q) / q_radius + var norm_r = (r - center_r) / r_radius + var radial = sqrt(norm_q * norm_q + norm_r * norm_r) + var shoreline_noise = (NoiseGenerator.noise_2d(q * 0.83 + 10, r * 0.91 + 15) - 0.5) * 0.18 + var island_shape = radial + shoreline_noise + var force_water = q <= 1 or q >= map_width - 2 or r <= 0 or r >= map_height - 1 + + var terrain: int + if force_water or island_shape > 0.92: + terrain = GameState.Terrain.WATER + else: + var n = NoiseGenerator.noise_2d(q + 31, r + 17) + var elevation = 1.0 - island_shape + if elevation > 0.44 and n > 0.82: terrain = GameState.Terrain.HILL + elif n < 0.5: terrain = GameState.Terrain.PLAINS + elif n < 0.82: terrain = GameState.Terrain.FOREST + else: terrain = GameState.Terrain.DESERT + + var owner = GameState.Team.NEUTRAL + if terrain != GameState.Terrain.WATER: + if q <= center_q - 3: owner = GameState.Team.HUMAN + elif q >= center_q + 3: owner = GameState.Team.BOT + + var cell = HexCell.new(Vector2i(q, r), terrain) + cell.owner = owner + GameState.map_cells[Vector2i(q, r)] = cell + + _place_structures() + return GameState.map_cells + +func _place_structures() -> bool: + for s in GameState.structures.values(): + if is_instance_valid(s): s.queue_free() + GameState.structures.clear() + + var hq = _nearest_passable(Vector2i(2, 6), 0, 5) + var barracks = _nearest_passable(Vector2i(4, 4), 1, 7) + var factory = _nearest_passable(Vector2i(4, 8), 1, 8) + var tech_core = _nearest_passable(Vector2i(14, 6), 11, 17) + + if not hq or not barracks or not factory or not tech_core: + push_error("MapGenerator: Failed to place structures"); return false + + GameState.structures["hq"] = Structure.new("hq", GameState.StructureType.HQ, GameState.Team.HUMAN, hq.coords) + GameState.structures["barracks"] = Structure.new("barracks", GameState.StructureType.BARRACKS, GameState.Team.HUMAN, barracks.coords) + GameState.structures["factory"] = Structure.new("factory", GameState.StructureType.FACTORY, GameState.Team.HUMAN, factory.coords) + GameState.structures["tech-core"] = Structure.new("tech-core", GameState.StructureType.TECH_CORE, GameState.Team.BOT, tech_core.coords) + + hq.set_owner(GameState.Team.HUMAN) + barracks.set_owner(GameState.Team.HUMAN) + factory.set_owner(GameState.Team.HUMAN) + tech_core.set_owner(GameState.Team.BOT) + + for id in GameState.structures.keys(): + var s = GameState.structures[id] + EventBus.emit_signal("structure_built", id, s.structure_type, s.team) + return true + +func _nearest_passable(target: Vector2i, min_q: int, max_q: int) -> HexCell: + var candidates = [] + for cell in GameState.map_cells.values(): + if cell.q >= min_q and cell.q <= max_q and cell.is_passable(): + candidates.append({"cell": cell, "dist": HexMath.hex_distance(target, cell.coords)}) + candidates.sort_custom(func(a, b): return a.dist < b.dist) + if candidates.size() > 0: return candidates[0].cell + for cell in GameState.map_cells.values(): + if cell.is_passable(): return cell + return null + +func generate_with_retries(max_attempts: int = 8) -> bool: + for attempt in range(max_attempts): + NoiseGenerator.set_seed(randi()) + generate_map() + if GameState.structures.size() >= 4: return true + return false \ No newline at end of file diff --git a/src/utils/CombatSystem.gd b/src/utils/CombatSystem.gd new file mode 100644 index 0000000..26f21c6 --- /dev/null +++ b/src/utils/CombatSystem.gd @@ -0,0 +1,65 @@ +extends Node +class_name CombatSystem + +static func resolve_combat(attacker: Unit, defender: Unit) -> Dictionary: + var result = {"attacker_id": attacker.id, "defender_id": defender.id, + "damage_dealt": 0, "defender_died": false, "attacker_died": false} + + if not attacker.is_alive() or not defender.is_alive(): return result + if attacker.acted: + EventBus.emit_signal("hud_message", "Unit already acted", "warn") + return result + + var cell = GameState.map_cells.get(defender.coords) + var terrain_def = cell.defense_bonus if cell else 1.0 + var damage = max(6, int(attacker.atk / terrain_def)) + result.damage_dealt = damage + + defender.take_damage(damage); attacker.acted = true + + EventBus.emit_signal("combat_started", attacker.id, defender.id) + EventBus.emit_signal("play_sfx", "unit_attack") + + if not defender.is_alive(): + result.defender_died = true + EventBus.emit_signal("combat_ended", attacker.id, defender.id, true) + var cell_defender = GameState.map_cells.get(defender.coords) + if cell_defender: cell_defender.set_owner(attacker.team) + else: + if HexMath.hex_distance(attacker.coords, defender.coords) == 1: + var counter_damage = max(3, int(defender.atk * 0.5)) + attacker.take_damage(counter_damage) + if not attacker.is_alive(): + result.attacker_died = true + EventBus.emit_signal("combat_ended", attacker.id, defender.id, false) + else: + EventBus.emit_signal("combat_ended", attacker.id, defender.id, true) + else: + EventBus.emit_signal("combat_ended", attacker.id, defender.id, true) + + EventBus.emit_signal("unit_attacked", attacker.id, defender.id, damage) + return result + +static func can_attack(attacker: Unit, defender: Unit) -> bool: + if not attacker.is_alive() or not defender.is_alive(): return false + if attacker.team == defender.team: return false + if attacker.acted: return false + return HexMath.hex_distance(attacker.coords, defender.coords) <= attacker.attack_range + +static func get_combat_preview(attacker: Unit, defender: Unit) -> Dictionary: + var cell = GameState.map_cells.get(defender.coords) + var terrain_def = cell.defense_bonus if cell else 1.0 + var damage = max(6, int(attacker.atk / terrain_def)) + var defender_hp_after = max(0, defender.hp - damage) + var defender_survives = defender_hp_after > 0 + var counter_damage = 0 + if defender_survives and HexMath.hex_distance(attacker.coords, defender.coords) == 1: + counter_damage = max(3, int(defender.atk * 0.5)) + return { + "estimated_damage": damage, + "defender_hp_after": defender_hp_after, + "defender_will_die": not defender_survives, + "counter_damage": counter_damage, + "attacker_hp_after": max(0, attacker.hp - counter_damage), + "attacker_will_die": max(0, attacker.hp - counter_damage) <= 0 + } \ No newline at end of file diff --git a/src/utils/HexMath.gd b/src/utils/HexMath.gd new file mode 100644 index 0000000..32c3c95 --- /dev/null +++ b/src/utils/HexMath.gd @@ -0,0 +1,59 @@ +extends Node + +const DIRECTIONS: Array[Vector2i] = [ + Vector2i(1, 0), Vector2i(1, -1), Vector2i(0, -1), + Vector2i(-1, 0), Vector2i(-1, 1), Vector2i(0, 1) +] + +func hex_to_pixel(q: int, r: int, hex_size: float = GameState.HEX_SIZE) -> Vector2: + var x = hex_size * sqrt(3.0) * (q + r / 2.0) + var y = hex_size * 1.5 * r + return Vector2(x, y) + +func hex_to_pixelv(coords: Vector2i, hex_size: float = GameState.HEX_SIZE) -> Vector2: + return hex_to_pixel(coords.x, coords.y, hex_size) + +func pixel_to_hex(x: float, y: float, hex_size: float = GameState.HEX_SIZE) -> Vector2i: + var q = (sqrt(3.0) / 3.0 * x - 1.0 / 3.0 * y) / hex_size + var r = (2.0 / 3.0 * y) / hex_size + return hex_round(q, r) + +func hex_round(q: float, r: float) -> Vector2i: + var s = -q - r + var rq = round(q); var rr = round(r); var rs = round(s) + var dq = abs(rq - q); var dr = abs(rr - r); var ds = abs(rs - s) + if dq > dr and dq > ds: rq = -rr - rs + elif dr > ds: rr = -rq - rs + return Vector2i(int(rq), int(rr)) + +func hex_distance(a: Vector2i, b: Vector2i) -> int: + return (abs(a.x - b.x) + abs(a.y - b.y) + abs((-a.x - a.y) - (-b.x - b.y))) / 2 + +func get_neighbors(coords: Vector2i) -> Array[Vector2i]: + var neighbors: Array[Vector2i] = [] + for dir in DIRECTIONS: neighbors.append(coords + dir) + return neighbors + +func get_neighbors_in_range(coords: Vector2i, range_dist: int) -> Array[Vector2i]: + var results: Array[Vector2i] = [] + for q in range(-range_dist, range_dist + 1): + for r in range(max(-range_dist, -q - range_dist), min(range_dist, -q + range_dist) + 1): + results.append(coords + Vector2i(q, r)) + return results + +func is_passable(coords: Vector2i) -> bool: + var cell = GameState.map_cells.get(coords) + if not cell: return false + return cell.terrain != GameState.Terrain.WATER + +func is_occupied(coords: Vector2i) -> bool: + for unit in GameState.units.values(): + if unit.is_alive() and unit.q == coords.x and unit.r == coords.y: + return true + return false + +func get_unit_at(coords: Vector2i) -> Unit: + for unit in GameState.units.values(): + if unit.is_alive() and unit.q == coords.x and unit.r == coords.y: + return unit + return null \ No newline at end of file diff --git a/src/utils/Pathfinder.gd b/src/utils/Pathfinder.gd new file mode 100644 index 0000000..b802990 --- /dev/null +++ b/src/utils/Pathfinder.gd @@ -0,0 +1,86 @@ +extends Node +class_name Pathfinder + +class PathNode: + extends RefCounted + var coords: Vector2i + var g_cost: int = 0; var h_cost: int = 0; var parent: PathNode = null + var f_cost: int: get: return g_cost + h_cost + func _init(_coords: Vector2i) -> void: coords = _coords + +static func find_path(start: Vector2i, goal: Vector2i, max_range: int = 999) -> Array[Vector2i]: + var open_list: Array[PathNode] = [] + var closed_set: Dictionary = {} + var node_map: Dictionary = {} + + var start_node = PathNode.new(start) + start_node.g_cost = 0; start_node.h_cost = HexMath.hex_distance(start, goal) + open_list.append(start_node); node_map[start] = start_node + + while open_list.size() > 0: + var current = open_list[0]; var current_idx = 0 + for i in range(open_list.size()): + if open_list[i].f_cost < current.f_cost or (open_list[i].f_cost == current.f_cost and open_list[i].h_cost < current.h_cost): + current = open_list[i]; current_idx = i + + open_list.remove_at(current_idx); closed_set[current.coords] = true + if current.coords == goal: return _reconstruct_path(current) + if current.g_cost >= max_range: continue + + for neighbor in HexMath.get_neighbors(current.coords): + if closed_set.has(neighbor): continue + if not HexMath.is_passable(neighbor): continue + + var cell = GameState.map_cells.get(neighbor) + var move_cost = cell.movement_cost if cell else 1 + var new_g = current.g_cost + move_cost + + var neighbor_node = node_map.get(neighbor) + if not neighbor_node: + neighbor_node = PathNode.new(neighbor) + neighbor_node.h_cost = HexMath.hex_distance(neighbor, goal) + node_map[neighbor] = neighbor_node; open_list.append(neighbor_node) + elif new_g >= neighbor_node.g_cost: continue + + neighbor_node.parent = current; neighbor_node.g_cost = new_g + + return [] + +static func _reconstruct_path(end_node: PathNode) -> Array[Vector2i]: + var path: Array[Vector2i] = [] + var current = end_node + while current != null: + path.append(current.coords); current = current.parent + path.reverse(); return path + +static func get_reachable_cells(unit: Unit, range: int) -> Array[Vector2i]: + var reachable: Array[Vector2i] = [] + var visited: Dictionary = {unit.coords: 0} + var queue = [unit.coords] + + while queue.size() > 0: + var current = queue.pop_front() + var current_cost = visited[current] + if current_cost >= range: continue + + for neighbor in HexMath.get_neighbors(current): + if visited.has(neighbor): continue + if not HexMath.is_passable(neighbor): continue + if HexMath.is_occupied(neighbor) and neighbor != unit.coords: continue + + var cell = GameState.map_cells.get(neighbor) + var move_cost = cell.movement_cost if cell else 1 + var new_cost = current_cost + move_cost + + if new_cost <= range: + visited[neighbor] = new_cost; reachable.append(neighbor); queue.append(neighbor) + return reachable + +static func get_attackable_cells(unit: Unit, range: int) -> Array[Vector2i]: + var attackable: Array[Vector2i] = [] + for cell in HexMath.get_neighbors_in_range(unit.coords, range): + if cell == unit.coords: continue + var target = HexMath.get_unit_at(cell) + if target and target.team != unit.team and target.is_alive(): + attackable.append(cell) + return attackable \ No newline at end of file diff --git a/src/utils/Unit.gd b/src/utils/Unit.gd new file mode 100644 index 0000000..a684f60 --- /dev/null +++ b/src/utils/Unit.gd @@ -0,0 +1,148 @@ +extends CharacterBody2D +class_name Unit + +var id: int = -1 +var team: int = GameState.Team.HUMAN +var unit_type: int = GameState.UnitType.WARRIOR +var q: int = 0; var r: int = 0 +var coords: Vector2i: + get: return Vector2i(q, r) + set(v): q = v.x; r = v.y + +var hp: int = 100; var hp_max: int = 100 +var atk: int = 10 +var movement_range: int = 1; var attack_range: int = 1; var vision_range: int = 2 +var acted: bool = false; var selected: bool = false; var alive: bool = true + +var sprite: Sprite2D +var selection_indicator: Sprite2D + +signal unit_died(unit: Unit) +signal unit_moved(unit: Unit, from_pos: Vector2i, to_pos: Vector2i) + +func _ready() -> void: + _setup_visuals(); _update_position() + +func _setup_visuals() -> void: + sprite = Sprite2D.new() + sprite.texture = _load_unit_texture() + sprite.scale = Vector2(0.5, 0.5) + add_child(sprite) + + selection_indicator = Sprite2D.new() + selection_indicator.texture = load("res://assets/ui/Crosshair.png") + selection_indicator.scale = Vector2(0.8, 0.8) + selection_indicator.visible = false + selection_indicator.modulate = Color(1, 1, 1, 0.5) + add_child(selection_indicator) + + # HP bar + var hp_bg = ColorRect.new() + hp_bg.color = Color(0, 0, 0, 0.58) + hp_bg.size = Vector2(32, 5); hp_bg.position = Vector2(-16, -22) + add_child(hp_bg) + + var hp_fg = ColorRect.new() + hp_fg.name = "HPForeground"; hp_fg.size = Vector2(32, 5) + hp_fg.position = Vector2(-16, -22); hp_fg.color = Color(0.133, 0.765, 0.22) + add_child(hp_fg) + +func _load_unit_texture() -> Texture2D: + var stats = GameState.get_unit_stats(unit_type) + var path = "res://assets/units/" + stats.sprite + return load(path) if ResourceLoader.exists(path) else load("res://assets/units/Warrior.png") + +func _update_position() -> void: + position = HexMath.hex_to_pixel(q, r) + +func _update_hp_bar() -> void: + var hp_fg = get_node_or_null("HPForeground") + if hp_fg: + var hp_pct = float(max(0, hp)) / hp_max + hp_fg.size.x = 32 * hp_pct + hp_fg.color = Color(0.133, 0.765, 0.22) if hp_pct > 0.55 else Color(0.961, 0.62, 0.043) if hp_pct > 0.3 else Color(0.937, 0.267, 0.267) + +func set_selected(value: bool) -> void: + selected = value; selection_indicator.visible = value + if value: EventBus.emit_signal("unit_selected", id) + else: EventBus.emit_signal("unit_deselected") + +func move_to(new_q: int, new_r: int) -> void: + var old_coords = Vector2i(q, r) + var target_pos = HexMath.hex_to_pixel(new_q, new_r) + var tween = create_tween() + tween.tween_property(self, "position", target_pos, 0.3) + tween.set_ease(Tween.EASE_OUT); tween.set_trans(Tween.TRANS_QUAD) + q = new_q; r = new_r; acted = true + await tween.finished + EventBus.emit_signal("play_sfx", "unit_move") + unit_moved.emit(self, old_coords, Vector2i(new_q, new_r)) + +func take_damage(damage: int) -> void: + hp -= damage; _update_hp_bar() + var tween = create_tween() + tween.tween_property(sprite, "modulate", Color.RED, 0.1) + tween.tween_property(sprite, "modulate", Color.WHITE, 0.1) + if hp <= 0: die() + +func die() -> void: + alive = false + var tween = create_tween() + tween.tween_property(self, "modulate", Color(1, 1, 1, 0), 0.5) + tween.tween_property(self, "scale", Vector2.ZERO, 0.5) + EventBus.emit_signal("play_sfx", "unit_die") + EventBus.emit_signal("unit_died", id) + unit_died.emit(self) + await tween.finished; queue_free() + +func heal(amount: int) -> void: + hp = min(hp + amount, hp_max); _update_hp_bar() + +func is_alive() -> bool: return alive and hp > 0 + +func decide_ai_action() -> Dictionary: + var profile = GameState.get_opponent_profile() if team == GameState.Team.BOT else GameState.get_human_side_profile() + var style = profile.get("style", "balanced") + var enemies = GameState.get_units_by_team(GameState.Team.HUMAN if team == GameState.Team.BOT else GameState.Team.BOT) + if enemies.is_empty(): return {} + + var neighbors = HexMath.get_neighbors(coords) + for n in neighbors: + var unit = HexMath.get_unit_at(n) + if unit and unit.team != team and unit.is_alive(): + return {"type": "attack", "target": unit.id} + + var conquer_options = [] + for n in neighbors: + var cell = GameState.map_cells.get(n) + if cell and cell.is_passable() and cell.owner != team and not HexMath.is_occupied(n): + conquer_options.append(n) + + var should_conquer = false + match style: + "defensive": should_conquer = randf() < 0.7 + "swarm": should_conquer = randf() < 0.55 + _: should_conquer = randf() < 0.4 + + if conquer_options.size() > 0 and should_conquer: + return {"type": "conquer", "target": conquer_options[0]} + + var nearest = _find_nearest_enemy(enemies) + if nearest: + var move_options = HexMath.get_neighbors(coords) + move_options = move_options.filter(func(n): return HexMath.is_passable(n) and not HexMath.is_occupied(n)) + if move_options.size() > 0: + move_options.sort_custom(func(a, b): + var da = HexMath.hex_distance(a, nearest.coords) + var db = HexMath.hex_distance(b, nearest.coords) + return da > db if style == "defensive" else da < db) + return {"type": "move", "target": move_options[0]} + return {} + +func _find_nearest_enemy(enemies: Array) -> Unit: + var best = null; var best_dist = 999 + for enemy in enemies: + if not enemy.is_alive(): continue + var dist = HexMath.hex_distance(coords, enemy.coords) + if dist < best_dist: best = enemy; best_dist = dist + return best \ No newline at end of file diff --git a/web3_bridge.js b/web3_bridge.js new file mode 100644 index 0000000..e978b42 --- /dev/null +++ b/web3_bridge.js @@ -0,0 +1,47 @@ +(function() { + 'use strict'; + + if (typeof window.StellarGameService === 'undefined') { + window.StellarGameService = { + connectWallet: async function() { + await new Promise(r => setTimeout(r, 350)); + return { address: 'GHUMANVSBOTSDEMO12345XYZ' }; + }, + start_game: async function(payload) { + await new Promise(r => setTimeout(r, 260)); + return { txHash: 'tx_start_' + Date.now(), payload }; + }, + submit_zk_proof: async function(payload) { + await new Promise(r => setTimeout(r, 420)); + return { proofId: 'proof_' + Math.floor(Math.random() * 99999), payload }; + }, + end_game: async function(result) { + await new Promise(r => setTimeout(r, 260)); + return { txHash: 'tx_end_' + Date.now(), result }; + } + }; + } + + window.downloadProofs = function(data) { + var blob = new Blob([data], {type: 'application/json'}); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'human-vs-bots-proofs-' + Date.now() + '.json'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + window.godotBridge = { + emitSignal: function(signalName, ...args) { + if (window.godotCallbacks && window.godotCallbacks[signalName]) { + window.godotCallbacks[signalName].forEach(cb => cb(...args)); + } + }, + registerCallback: function(signalName, callback) { + if (!window.godotCallbacks) window.godotCallbacks = {}; + if (!window.godotCallbacks[signalName]) window.godotCallbacks[signalName] = []; + window.godotCallbacks[signalName].push(callback); + } + }; +})(); \ No newline at end of file