diff --git a/BuildController.gd b/BuildController.gd index 7414975..90e0798 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -564,17 +564,22 @@ func _refresh_craft_status() -> void: craft_interceptor_button.queue_redraw() # One statement of the fleet count, either way round: the capacity line # replaces the plain readout rather than being bolted in front of it. + if room: + craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] + else: + var queued_ships: int = GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) + var owed: int = GameState.ships_owed + queued_ships + var pending_note: String = "" if owed == 0 else " (%d already on order)" % owed + craft_status.text = "No room in the fleet: %d/%d%s - research capacity at the mothership\n%s" % [ + fleet.ships.size(), GameState.max_fleet_size(), pending_note, craft_status.text + ] + # LAST, so a message about what just happened is the first line the + # player reads rather than being buried under the standing state. if craft_message != "": if Time.get_ticks_msec() < craft_message_until: craft_status.text = "%s\n%s" % [craft_message, craft_status.text] else: craft_message = "" - if room: - craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] - else: - craft_status.text = "Fleet full %d/%d - research capacity at the mothership\n%s" % [ - fleet.ships.size(), GameState.max_fleet_size(), craft_status.text - ] func _on_craft_interceptor() -> void: @@ -588,9 +593,11 @@ func _on_craft_interceptor() -> void: _say("Fleet is full (%d slots)" % GameState.max_fleet_size()) return # No affordability check: an order the crafter cannot pay for yet is - # QUEUED, and completes by itself once ore is delivered here (#16). + # QUEUED, and completes by itself once ore is delivered here (#16). But + # queueing silently looks identical to a dead button, so it says which + # happened and what the crafter is still short of. GameState.queue_order(craft_building_id) - _refresh_craft_status() + _report_order(GameState.PRODUCT_SHIP) func _refresh_load_ore() -> void: @@ -604,6 +611,16 @@ func _on_load_ore() -> void: _refresh_load_ore() +func _report_order(product: String) -> void: + var short: int = GameState.outstanding_cost(craft_building_id) + if short > 0: + _say("%s queued - %d more ore must be delivered here" % [ + GameState.VESSEL_NAMES.get(product, product), short + ]) + else: + _say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product)) + + func _on_store_ore() -> void: if craft_building_id == 0: return diff --git a/GameState.gd b/GameState.gd index 8728c91..bd058ca 100644 --- a/GameState.gd +++ b/GameState.gd @@ -252,6 +252,7 @@ func max_fleet_size() -> int: func reset_progression() -> void: unlocked.clear() + vessel_levels.clear() # --- building stock and production queue (#16) ----------------------------- @@ -294,6 +295,176 @@ func find_building(id: int) -> Dictionary: return {} +# --- vessel types and their upgrades (#5) --------------------------------- + +# The three things that can be flying around. The first and last double as +# production order ids, so a vessel type and the order that builds it are the +# same string rather than two that have to be kept in step. +const VESSEL_INTERCEPTOR: String = "interceptor" +const VESSEL_SHIELD: String = "shield" +const VESSEL_TRANSPORTER: String = "transporter" + +const VESSEL_NAMES: Dictionary = { + VESSEL_INTERCEPTOR: "Interceptor", + VESSEL_SHIELD: "Shield Vessel", + VESSEL_TRANSPORTER: "Transporter", +} + +const SHIELD_COST: int = 14 + +# Researched at the ship lab, per vessel type. Separate from SKILL_TREE on +# purpose: that gates what the player may build at all and is a strategic +# spine, while these are repeatable tuning on a vessel you already have. +# `cost` is the FIRST level; each level after costs cost * (level + 1), so +# the third point in something is not the same price as the first. +const VESSEL_UPGRADES: Array = [ + { + "id": "int_damage", "vessel": VESSEL_INTERCEPTOR, "name": "Heavy Slugs", + "cost": 30, "max_level": 3, "desc": "+1 damage per shot.", + }, + { + "id": "int_rate", "vessel": VESSEL_INTERCEPTOR, "name": "Autoloader", + "cost": 25, "max_level": 3, "desc": "Fires 15% faster per level.", + }, + { + "id": "int_range", "vessel": VESSEL_INTERCEPTOR, "name": "Targeting Array", + "cost": 20, "max_level": 3, "desc": "+60 firing range per level.", + }, + { + "id": "int_speed", "vessel": VESSEL_INTERCEPTOR, "name": "Thrusters", + "cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.", + }, + { + "id": "shd_hits", "vessel": VESSEL_SHIELD, "name": "Layered Emitters", + "cost": 60, "max_level": 2, "desc": "+1 hit absorbed before the shield drops.", + }, + { + "id": "shd_recharge", "vessel": VESSEL_SHIELD, "name": "Fast Cycling", + "cost": 35, "max_level": 3, "desc": "Shield returns 1.2s sooner per level.", + }, + { + "id": "shd_speed", "vessel": VESSEL_SHIELD, "name": "Thrusters", + "cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.", + }, + { + "id": "trn_capacity", "vessel": VESSEL_TRANSPORTER, "name": "Expanded Hold", + "cost": 30, "max_level": 3, "desc": "Carries 8 more ore per supply run.", + }, + { + "id": "trn_speed", "vessel": VESSEL_TRANSPORTER, "name": "Freight Engines", + "cost": 30, "max_level": 3, "desc": "+70 speed on supply runs.", + }, +] + +# Base figures every vessel starts from, before anything is researched. +const SHIP_BASE_DAMAGE: int = 1 +const SHIP_BASE_COOLDOWN: float = 0.5 +const SHIP_BASE_RANGE: float = 320.0 +const SHIP_BASE_SPEED: float = 460.0 +const SHIP_BASE_ACCEL: float = 450.0 +const SHIELD_BASE_CHARGES: int = 1 +const SHIELD_BASE_RECHARGE: float = 6.0 +const TRANSPORTER_BASE_LOAD: int = 20 +const TRANSPORTER_BASE_SPEED: float = 340.0 + +var vessel_levels: Dictionary = {} + +signal vessel_upgraded(upgrade_id: String) + + +func upgrade_def(id: String) -> Dictionary: + for u in VESSEL_UPGRADES: + if u.id == id: + return u + return {} + + +func upgrades_for(vessel: String) -> Array: + var out: Array = [] + for u in VESSEL_UPGRADES: + if u.vessel == vessel: + out.append(u) + return out + + +func upgrade_level(id: String) -> int: + return int(vessel_levels.get(id, 0)) + + +func upgrade_maxed(id: String) -> bool: + var d: Dictionary = upgrade_def(id) + return d.is_empty() or upgrade_level(id) >= int(d.max_level) + + +func upgrade_cost(id: String) -> int: + var d: Dictionary = upgrade_def(id) + if d.is_empty(): + return 0 + return int(d.cost) * (upgrade_level(id) + 1) + + +func can_buy_upgrade(id: String) -> bool: + return not upgrade_maxed(id) and can_afford(upgrade_cost(id)) + + +func buy_upgrade(id: String) -> bool: + if not can_buy_upgrade(id): + return false + if not spend(upgrade_cost(id)): + return false + vessel_levels[id] = upgrade_level(id) + 1 + vessel_upgraded.emit(id) + return true + + +# --- what those upgrades actually come to --------------------------------- +# Read live rather than baked into a vessel when it is built, so researching +# something improves the ships already flying instead of only the next ones. + +func ship_damage(vessel: String) -> int: + return SHIP_BASE_DAMAGE + (upgrade_level("int_damage") if vessel == VESSEL_INTERCEPTOR else 0) + + +func ship_fire_cooldown(vessel: String) -> float: + if vessel != VESSEL_INTERCEPTOR: + return SHIP_BASE_COOLDOWN + return SHIP_BASE_COOLDOWN * pow(0.85, float(upgrade_level("int_rate"))) + + +func ship_fire_range(vessel: String) -> float: + return SHIP_BASE_RANGE + (60.0 * upgrade_level("int_range") if vessel == VESSEL_INTERCEPTOR else 0.0) + + +func _speed_levels(vessel: String) -> int: + if vessel == VESSEL_SHIELD: + return upgrade_level("shd_speed") + return upgrade_level("int_speed") + + +func ship_max_speed(vessel: String) -> float: + return SHIP_BASE_SPEED + 40.0 * _speed_levels(vessel) + + +func ship_acceleration(vessel: String) -> float: + return SHIP_BASE_ACCEL + 60.0 * _speed_levels(vessel) + + +func shield_max_charges() -> int: + return SHIELD_BASE_CHARGES + upgrade_level("shd_hits") + + +func shield_recharge_time() -> float: + return maxf(2.0, SHIELD_BASE_RECHARGE - 1.2 * upgrade_level("shd_recharge")) + + +func transporter_load_limit() -> int: + return TRANSPORTER_BASE_LOAD + 8 * upgrade_level("trn_capacity") + + +func transporter_speed() -> float: + return TRANSPORTER_BASE_SPEED + 70.0 * upgrade_level("trn_speed") + + const PRODUCT_SHIP: String = "interceptor" const PRODUCT_TRANSPORTER: String = "transporter" @@ -303,6 +474,8 @@ const TRANSPORTER_COST: int = 10 func product_cost(product: String) -> int: if product == PRODUCT_TRANSPORTER: return TRANSPORTER_COST + if product == VESSEL_SHIELD: + return SHIELD_COST return SHIP_COST @@ -704,7 +877,7 @@ func _advance_transporters(delta: float) -> void: if t.state == TRANSPORTER_IDLE: continue var distance: float = (t.from as Vector2).distance_to(t.to as Vector2) - var leg: float = maxf(distance / TRANSPORTER_SPEED, 0.0001) + var leg: float = maxf(distance / transporter_speed(), 0.0001) t.progress += delta / leg if t.progress < 1.0: continue @@ -719,7 +892,7 @@ func _arrive(t: Dictionary) -> void: TRANSPORTER_TO_MOTHERSHIP: var target: Dictionary = find_building(int(t.target_id)) var want: int = outstanding_cost(int(t.target_id)) - var take: int = mini(want, rocks) + var take: int = mini(mini(want, rocks), transporter_load_limit()) if take <= 0 or target.is_empty(): # Nothing to carry, or the building is gone. Go home empty. t.target_id = 0 diff --git a/Ship.gd b/Ship.gd index 8a9ed40..624c312 100644 --- a/Ship.gd +++ b/Ship.gd @@ -17,6 +17,21 @@ signal destroyed @export var idle_rescan_interval: float = 0.25 @export var projectile_damage: int = 1 +# Which kind of vessel this is (GameState.VESSEL_*). Drives both its +# researched statistics and whether it carries a shield (#5). +@export var vessel_type: String = "interceptor" + +# --- shield (#5) ---------------------------------------------------------- +const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0) +const SHIELD_RADIUS: float = 30.0 + +# A shield vessel survives a hit that would destroy any other ship, and is +# then defenceless until it cycles back up. Personal, not a bubble: it +# protects the vessel carrying it and nothing else. +var shield_charges: int = 0 +var shield_recharge_left: float = 0.0 +var _shield_ring: Node2D = null + # Neon colours, assigned by Fleet on spawn so a scene can tint its fleet. # The lunar surface is a bright grey, where the default near-white core all # but vanishes - PlanetSurface.tscn passes a dark core there. See NeonDraw. @@ -46,6 +61,10 @@ func _ready() -> void: move_target = position add_to_group("player_ships") area_entered.connect(_on_area_entered) + if vessel_type == GameState.VESSEL_SHIELD: + _build_shield_ring() + shield_charges = GameState.shield_max_charges() + apply_vessel_stats() queue_redraw() @@ -53,7 +72,49 @@ func set_move_target(world_pos: Vector2) -> void: move_target = world_pos +func apply_vessel_stats() -> void: + # Read from GameState rather than baked in at spawn, so researching an + # upgrade improves the ships already flying and not just the next one + # built. Fleet calls this on every vessel when something is bought. + max_speed = GameState.ship_max_speed(vessel_type) + acceleration = GameState.ship_acceleration(vessel_type) + fire_range = GameState.ship_fire_range(vessel_type) + fire_cooldown = GameState.ship_fire_cooldown(vessel_type) + projectile_damage = GameState.ship_damage(vessel_type) + if vessel_type == GameState.VESSEL_SHIELD: + # Topping up a live shield with researched capacity, without + # refilling one that is currently down and cycling. + var was_up: bool = shield_charges > 0 + shield_charges = GameState.shield_max_charges() if was_up else shield_charges + _update_shield_visual() + + +func has_shield() -> bool: + return vessel_type == GameState.VESSEL_SHIELD and shield_charges > 0 + + +func _update_shield_visual() -> void: + if _shield_ring == null: + return + # visible only - per #10 the ring is rasterised once and never redrawn to + # turn it on and off. + _shield_ring.visible = has_shield() + + +func _tick_shield(delta: float) -> void: + if vessel_type != GameState.VESSEL_SHIELD: + return + if shield_charges >= GameState.shield_max_charges(): + return + shield_recharge_left -= delta + if shield_recharge_left <= 0.0: + shield_charges += 1 + shield_recharge_left = GameState.shield_recharge_time() + _update_shield_visual() + + func _physics_process(delta: float) -> void: + _tick_shield(delta) var to_target: Vector2 = move_target - position var distance: float = to_target.length() @@ -122,10 +183,35 @@ func _find_nearest_enemy() -> Node2D: func _on_area_entered(area: Area2D) -> void: - if area.is_in_group("enemies") or area.is_in_group("asteroids"): - ShipExplosionVFX.spawn(get_tree().root, position) - destroyed.emit() - queue_free() + if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): + return + if has_shield(): + # Absorbed. The thing that hit it is still destroyed - a shield stops + # the vessel dying, it does not make the rock harmless - but the + # vessel survives and is defenceless until the shield cycles back. + shield_charges -= 1 + shield_recharge_left = GameState.shield_recharge_time() + _update_shield_visual() + if area.is_in_group("asteroids") and is_instance_valid(area): + area.queue_free() + return + ShipExplosionVFX.spawn(get_tree().root, position) + destroyed.emit() + queue_free() + + +func _build_shield_ring() -> void: + # Its own child, drawn once, toggled with `visible` (#10). A shield that + # came and went by redrawing the ship would re-tessellate the hull every + # time it cycled. + _shield_ring = NeonPiece.create(self, Vector2.ZERO, SHIELD_COLOR) + var points: PackedVector2Array = PackedVector2Array() + for i in range(14): + var a: float = (float(i) / 14.0) * TAU + points.append(Vector2(cos(a), sin(a)) * SHIELD_RADIUS) + _shield_ring.add_polygon(points, 0.7) + _shield_ring.queue_redraw() + _shield_ring.visible = false func _draw() -> void: diff --git a/tests/test_build_menu_hides_locked.gd.uid b/tests/test_build_menu_hides_locked.gd.uid new file mode 100644 index 0000000..edc9112 --- /dev/null +++ b/tests/test_build_menu_hides_locked.gd.uid @@ -0,0 +1 @@ +uid://b88pj64m6kpjn diff --git a/tests/test_crafter_feedback.gd b/tests/test_crafter_feedback.gd new file mode 100644 index 0000000..da3d34e --- /dev/null +++ b/tests/test_crafter_feedback.gd @@ -0,0 +1,121 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_crafter_feedback.gd +# Every press in the ship crafter must say what happened. Reported twice from +# play-testing as "the buttons do nothing". +# +# There is no silent-failure path left to press: storing with an empty hold, +# ordering something the crafter cannot pay for yet, and storing successfully +# all report. The queue in particular is invisible by nature - an order that +# cannot be afforded is accepted and waits (#16), which looks exactly like a +# dead button unless it says so. +# +# Verifies: +# 1. storing with an empty hold explains where ore comes from +# 2. ordering against an empty stock says it is queued AND how much more +# ore the building needs +# 3. a successful store reports the amount +# 4. the message survives the per-frame status refresh - it used to be +# overwritten on the very next frame, which is what made a working +# button look dead +# 5. the message is the FIRST line, not buried under the standing state + +var game_state: Node +var scene: Node +var controller: Node +var crafter_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.unlocked = ["shipyards", "coordination", "wing"] + game_state.rocks = 100 + game_state.fleet_cargo = 0 + game_state.ships_owed = 0 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + + +func _first_line() -> String: + return controller.craft_status.text.split("\n")[0] + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + scene.get_node("BuildingField").place(GameState.BUILDING_SHIP_CRAFTER, Vector2(500.0, 0.0)) + crafter_id = int(game_state.buildings[-1].id) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + controller.craft_building_id = crafter_id + controller.craft_menu.visible = true + controller._refresh_craft_status() + + # --- 1. store with an empty hold ---------------------------- + controller._on_store_ore() + var empty_msg: String = _first_line() + if "carrying nothing" not in empty_msg: + print("got: %s" % empty_msg) + print("TEST_CRAFTER_FEEDBACK: FAIL (storing with an empty hold said nothing useful)") + quit(1) + return true + print("empty hold -> %s" % empty_msg) + + # --- 2. order against an empty stock ------------------------ + controller._on_craft_interceptor() + var queued_msg: String = _first_line() + if "queued" not in queued_msg.to_lower() or "more ore" not in queued_msg: + print("got: %s" % queued_msg) + print("TEST_CRAFTER_FEEDBACK: FAIL (a queued order did not say it was queued and what it still needs)") + quit(1) + return true + if game_state.pending_orders(crafter_id) != 1: + print("TEST_CRAFTER_FEEDBACK: FAIL (the order was not actually queued)") + quit(1) + return true + print("unaffordable order -> %s" % queued_msg) + + # --- 3. a successful store ---------------------------------- + game_state.withdraw_to_fleet(30) + controller._on_store_ore() + var stored_msg: String = _first_line() + if "Stored" not in stored_msg: + print("got: %s" % stored_msg) + print("TEST_CRAFTER_FEEDBACK: FAIL (a successful store did not report)") + quit(1) + return true + print("successful store -> %s" % stored_msg) + elapsed = 0.0 + stage = 2 + return false + 2: + # --- 4 + 5. it survives the refresh, and stays first -------- + if elapsed < 0.5: + return false + controller._refresh_craft_status() + var still: String = _first_line() + if "Stored" not in still: + print("first line half a second later: %s" % still) + print("TEST_CRAFTER_FEEDBACK: FAIL (the message was overwritten by the per-frame refresh)") + quit(1) + return true + print("half a second later, still first: %s" % still) + print("TEST_CRAFTER_FEEDBACK: PASS") + quit(0) + return true + return false diff --git a/tests/test_modal_blocks_input.gd.uid b/tests/test_modal_blocks_input.gd.uid new file mode 100644 index 0000000..4254e6a --- /dev/null +++ b/tests/test_modal_blocks_input.gd.uid @@ -0,0 +1 @@ +uid://gr4k23bre8b6