Initial commit — Station 0 vertical slice in progress
Includes full design documentation (ARCHITECTURE, DESIGN_NOTES, CONTENT_SPEC, PRODUCTION_NOTES) and active Godot 4 / GDScript codebase. Core systems built: player, floor generation, room system, enemy base, body part upgrades, run manager, save system, HUD, EventBus.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node refs
|
||||
# ---------------------------------------------------------------------------
|
||||
@onready var stats: Node = $Stats
|
||||
@onready var body: Node2D = $Body
|
||||
@onready var shoot_origin: Marker2D = $ShootOrigin
|
||||
|
||||
const PROJECTILE = preload("res://scenes/player/projectile.tscn")
|
||||
const IFRAME_DURATION := 0.8
|
||||
|
||||
var _shoot_timer: float = 0.0
|
||||
var _iframe_timer: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("player")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Physics
|
||||
# ---------------------------------------------------------------------------
|
||||
func _physics_process(delta: float) -> void:
|
||||
_handle_movement()
|
||||
_handle_shooting(delta)
|
||||
_handle_iframes(delta)
|
||||
move_and_slide()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movement — WASD, 8-directional (direct key checks, no input map needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
func _handle_movement() -> void:
|
||||
var input_dir := Vector2.ZERO
|
||||
if Input.is_physical_key_pressed(KEY_W): input_dir.y -= 1
|
||||
if Input.is_physical_key_pressed(KEY_S): input_dir.y += 1
|
||||
if Input.is_physical_key_pressed(KEY_A): input_dir.x -= 1
|
||||
if Input.is_physical_key_pressed(KEY_D): input_dir.x += 1
|
||||
if input_dir.length() > 1.0:
|
||||
input_dir = input_dir.normalized()
|
||||
velocity = input_dir * stats.speed
|
||||
|
||||
if input_dir.x != 0.0:
|
||||
body.scale.x = sign(input_dir.x)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shooting — Arrow keys via built-in ui_ actions (always available in Godot)
|
||||
# ---------------------------------------------------------------------------
|
||||
func _handle_shooting(delta: float) -> void:
|
||||
_shoot_timer = maxf(_shoot_timer - delta, 0.0)
|
||||
|
||||
var shoot_dir := Vector2.ZERO
|
||||
if Input.is_action_pressed("ui_up"):
|
||||
shoot_dir = Vector2.UP
|
||||
elif Input.is_action_pressed("ui_down"):
|
||||
shoot_dir = Vector2.DOWN
|
||||
elif Input.is_action_pressed("ui_left"):
|
||||
shoot_dir = Vector2.LEFT
|
||||
elif Input.is_action_pressed("ui_right"):
|
||||
shoot_dir = Vector2.RIGHT
|
||||
|
||||
if shoot_dir != Vector2.ZERO and _shoot_timer == 0.0:
|
||||
_fire(shoot_dir)
|
||||
_shoot_timer = 1.0 / stats.fire_rate
|
||||
|
||||
func _fire(direction: Vector2) -> void:
|
||||
if stats.scatter_count <= 1:
|
||||
_spawn_projectile(direction)
|
||||
else:
|
||||
var spread := deg_to_rad(stats.scatter_spread_deg)
|
||||
var step := spread / float(stats.scatter_count - 1)
|
||||
var start := direction.angle() - spread / 2.0
|
||||
for i in stats.scatter_count:
|
||||
_spawn_projectile(Vector2.from_angle(start + step * i))
|
||||
|
||||
func _spawn_projectile(dir: Vector2) -> void:
|
||||
var proj: Area2D = PROJECTILE.instantiate()
|
||||
proj.global_position = shoot_origin.global_position
|
||||
proj.direction = dir
|
||||
proj.damage = stats.damage
|
||||
proj.max_range = stats.range_
|
||||
proj.ricochet = stats.ricochet
|
||||
proj.explosive = stats.explosive
|
||||
proj.explosion_radius = stats.explosion_radius
|
||||
proj.explosion_damage = stats.explosion_damage
|
||||
get_parent().add_child(proj)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Iframes — flash body during invincibility
|
||||
# ---------------------------------------------------------------------------
|
||||
func _handle_iframes(delta: float) -> void:
|
||||
if _iframe_timer <= 0.0:
|
||||
body.modulate = Color(1, 1, 1, 1)
|
||||
return
|
||||
_iframe_timer -= delta
|
||||
# Visible flash every 0.1s
|
||||
body.modulate = Color(1, 1, 1, 0.25) if fmod(_iframe_timer, 0.2) < 0.1 else Color(1, 1, 1, 1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Damage / death
|
||||
# ---------------------------------------------------------------------------
|
||||
func take_damage(amount: float) -> void:
|
||||
if _iframe_timer > 0.0:
|
||||
return
|
||||
stats.current_health -= amount
|
||||
_iframe_timer = IFRAME_DURATION
|
||||
EventBus.player_health_changed.emit(stats.current_health, stats.max_health)
|
||||
if stats.current_health <= 0.0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
RunManager.end_run(false)
|
||||
@@ -0,0 +1,144 @@
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base stats — no upgrades equipped
|
||||
# ---------------------------------------------------------------------------
|
||||
const BASE_SPEED := 200.0
|
||||
const BASE_MAX_HEALTH := 6.0 # 6 HP = 3 full hearts
|
||||
const BASE_DAMAGE := 3.5
|
||||
const BASE_FIRE_RATE := 3.5 # shots per second
|
||||
const BASE_RANGE := 400.0
|
||||
const BASE_PROJ_SPEED := 520.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live stats (recalculated from base + equipped parts + run items)
|
||||
# ---------------------------------------------------------------------------
|
||||
var speed: float = BASE_SPEED
|
||||
var max_health: float = BASE_MAX_HEALTH
|
||||
var current_health: float = BASE_MAX_HEALTH
|
||||
var damage: float = BASE_DAMAGE
|
||||
var fire_rate: float = BASE_FIRE_RATE
|
||||
var range_: float = BASE_RANGE
|
||||
var proj_speed: float = BASE_PROJ_SPEED
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run-item behavior flags (recomputed each recalculate — never saved)
|
||||
# ---------------------------------------------------------------------------
|
||||
var ricochet: bool = false
|
||||
var scatter_count: int = 1
|
||||
var scatter_spread_deg: float = 0.0
|
||||
var explosive: bool = false
|
||||
var explosion_radius: float = 0.0
|
||||
var explosion_damage: float = 0.0
|
||||
|
||||
# New item flags
|
||||
var coolant_leak: bool = false
|
||||
var scrap_magnet: bool = false
|
||||
var memory_spike: bool = false
|
||||
var rust_coat: bool = false
|
||||
var static_discharge: bool = false
|
||||
var fragmented_map: bool = false
|
||||
var overclock: bool = false
|
||||
var damage_reduction: float = 0.0
|
||||
var discharge_radius: float = 64.0
|
||||
var discharge_damage: float = 1.0
|
||||
var magnet_radius: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body-part behavior flags (recomputed each recalculate)
|
||||
# ---------------------------------------------------------------------------
|
||||
var shield_projector: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
EventBus.body_part_equipped.connect(_on_part_equipped)
|
||||
recalculate()
|
||||
if RunManager.player_health_carry > 0.0:
|
||||
current_health = minf(RunManager.player_health_carry, max_health)
|
||||
RunManager.player_health_carry = -1.0
|
||||
|
||||
func recalculate() -> void:
|
||||
speed = BASE_SPEED + UpgradeManager.get_stat_modifier("speed")
|
||||
max_health = BASE_MAX_HEALTH + UpgradeManager.get_stat_modifier("max_health")
|
||||
damage = BASE_DAMAGE + UpgradeManager.get_stat_modifier("damage")
|
||||
fire_rate = BASE_FIRE_RATE + UpgradeManager.get_stat_modifier("fire_rate")
|
||||
range_ = BASE_RANGE + UpgradeManager.get_stat_modifier("range")
|
||||
proj_speed = BASE_PROJ_SPEED + UpgradeManager.get_stat_modifier("proj_speed")
|
||||
_apply_run_items()
|
||||
_apply_part_flags()
|
||||
current_health = minf(current_health, max_health)
|
||||
|
||||
func _apply_run_items() -> void:
|
||||
ricochet = false
|
||||
scatter_count = 1
|
||||
scatter_spread_deg = 0.0
|
||||
explosive = false
|
||||
explosion_radius = 0.0
|
||||
explosion_damage = 0.0
|
||||
coolant_leak = false
|
||||
scrap_magnet = false
|
||||
memory_spike = false
|
||||
rust_coat = false
|
||||
static_discharge = false
|
||||
fragmented_map = false
|
||||
overclock = false
|
||||
damage_reduction = 0.0
|
||||
discharge_radius = 64.0
|
||||
discharge_damage = 1.0
|
||||
magnet_radius = 0.0
|
||||
|
||||
for item: RunItem in RunManager.run_items:
|
||||
match item.effect:
|
||||
RunItem.Effect.STAT_MOD:
|
||||
match item.stat_key:
|
||||
"speed": speed += item.stat_delta
|
||||
"max_health": max_health += item.stat_delta
|
||||
"damage": damage += item.stat_delta
|
||||
"fire_rate": fire_rate += item.stat_delta
|
||||
"range": range_ += item.stat_delta
|
||||
RunItem.Effect.RICOCHET:
|
||||
ricochet = true
|
||||
RunItem.Effect.SCATTER:
|
||||
scatter_count = maxi(scatter_count, item.scatter_count)
|
||||
scatter_spread_deg = maxf(scatter_spread_deg, item.scatter_spread_deg)
|
||||
RunItem.Effect.EXPLOSIVE:
|
||||
explosive = true
|
||||
explosion_radius = maxf(explosion_radius, item.explosion_radius)
|
||||
explosion_damage = maxf(explosion_damage, item.explosion_damage)
|
||||
RunItem.Effect.COOLANT_LEAK:
|
||||
coolant_leak = true
|
||||
RunItem.Effect.SCRAP_MAGNET:
|
||||
scrap_magnet = true
|
||||
magnet_radius = maxf(magnet_radius, item.magnet_radius)
|
||||
RunItem.Effect.MEMORY_SPIKE:
|
||||
memory_spike = true
|
||||
RunItem.Effect.RUST_COAT:
|
||||
rust_coat = true
|
||||
damage_reduction = maxf(damage_reduction, item.damage_reduction)
|
||||
RunItem.Effect.STATIC_DISCHARGE:
|
||||
static_discharge = true
|
||||
discharge_radius = maxf(discharge_radius, item.discharge_radius)
|
||||
discharge_damage = maxf(discharge_damage, item.discharge_damage)
|
||||
RunItem.Effect.FRAGMENTED_MAP:
|
||||
fragmented_map = true
|
||||
RunItem.Effect.OVERCLOCK:
|
||||
overclock = true
|
||||
fire_rate *= 1.4
|
||||
|
||||
func _apply_part_flags() -> void:
|
||||
shield_projector = false
|
||||
# Scatter from body parts (accumulate with run item scatter)
|
||||
for slot in UpgradeManager.SLOTS:
|
||||
var part := UpgradeManager.get_equipped_part(slot)
|
||||
if part == null:
|
||||
continue
|
||||
if part.shield_projector:
|
||||
shield_projector = true
|
||||
if part.mod_scatter_count > 0:
|
||||
scatter_count = maxi(scatter_count, part.mod_scatter_count)
|
||||
scatter_spread_deg = maxf(scatter_spread_deg, part.mod_scatter_spread_deg)
|
||||
|
||||
func heal(amount: float) -> void:
|
||||
current_health = minf(current_health + amount, max_health)
|
||||
|
||||
func _on_part_equipped(_part: Resource, _slot: String) -> void:
|
||||
recalculate()
|
||||
@@ -0,0 +1,89 @@
|
||||
extends Area2D
|
||||
|
||||
# Set by player on spawn
|
||||
var direction: Vector2 = Vector2.RIGHT
|
||||
var damage: float = 3.5
|
||||
var speed: float = 520.0
|
||||
var max_range: float = 400.0
|
||||
|
||||
# Behavior flags (set from player stats)
|
||||
var ricochet: bool = false
|
||||
var explosive: bool = false
|
||||
var explosion_radius: float = 64.0
|
||||
var explosion_damage: float = 2.5
|
||||
|
||||
var _distance_traveled: float = 0.0
|
||||
var _bounces_left: int = 1
|
||||
var _bounce_cooldown: float = 0.0 # grace period after bounce to avoid re-trigger
|
||||
|
||||
func _ready() -> void:
|
||||
rotation = direction.angle()
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_bounce_cooldown = maxf(_bounce_cooldown - delta, 0.0)
|
||||
if ricochet and _bounces_left > 0 and _bounce_cooldown == 0.0:
|
||||
_check_wall_bounce(delta)
|
||||
var move := direction * speed * delta
|
||||
position += move
|
||||
_distance_traveled += move.length()
|
||||
if _distance_traveled >= max_range:
|
||||
queue_free()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ricochet — raycast lookahead to get wall normal before physically touching
|
||||
# ---------------------------------------------------------------------------
|
||||
func _check_wall_bounce(delta: float) -> void:
|
||||
var space := get_world_2d().direct_space_state
|
||||
var reach := speed * delta + 6.0
|
||||
var params := PhysicsRayQueryParameters2D.create(
|
||||
global_position,
|
||||
global_position + direction * reach,
|
||||
1 # collision_mask: walls on layer 1
|
||||
)
|
||||
var result := space.intersect_ray(params)
|
||||
if result and result["collider"] is StaticBody2D:
|
||||
direction = direction.bounce(result["normal"])
|
||||
rotation = direction.angle()
|
||||
_bounces_left -= 1
|
||||
_bounce_cooldown = 0.1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Placeholder visual — yellow circle
|
||||
# ---------------------------------------------------------------------------
|
||||
func _draw() -> void:
|
||||
draw_circle(Vector2.ZERO, 5.0, Color(1.0, 0.85, 0.2))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collision — differentiate walls from enemies
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body is StaticBody2D:
|
||||
# Wall hit — ignore during bounce cooldown (already reflected by raycast)
|
||||
if _bounce_cooldown > 0.0:
|
||||
return
|
||||
if not ricochet or _bounces_left <= 0:
|
||||
queue_free()
|
||||
return
|
||||
|
||||
if body.has_method("take_damage"):
|
||||
body.take_damage(damage)
|
||||
if explosive:
|
||||
_explode()
|
||||
queue_free()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Explosive — AoE damage to all enemies in radius
|
||||
# ---------------------------------------------------------------------------
|
||||
func _explode() -> void:
|
||||
var space := get_world_2d().direct_space_state
|
||||
var query := PhysicsShapeQueryParameters2D.new()
|
||||
var circle := CircleShape2D.new()
|
||||
circle.radius = explosion_radius
|
||||
query.shape = circle
|
||||
query.transform = Transform2D(0.0, global_position)
|
||||
query.collision_mask = 1
|
||||
for r in space.intersect_shape(query):
|
||||
var c = r["collider"]
|
||||
if c.has_method("take_damage"):
|
||||
c.take_damage(explosion_damage)
|
||||
Reference in New Issue
Block a user