Add 3 showcase game projects - Neon Runner: 2D synthwave endless runner - Void Explorer: 3D first-person space station exploration - Circuit Logic: Minimalist puzzle game about connecting circuits

This commit is contained in:
MrPiglr 2026-03-06 19:01:43 -07:00
parent 39c99577f5
commit 782e1d35e0
33 changed files with 3767 additions and 0 deletions

View file

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" fill="#1e2228"/>
<!-- Circuit grid -->
<line x1="20" y1="64" x2="50" y2="64" stroke="#3a3f4a" stroke-width="4"/>
<line x1="78" y1="64" x2="108" y2="64" stroke="#4dcc80" stroke-width="4"/>
<line x1="64" y1="30" x2="64" y2="50" stroke="#4dcc80" stroke-width="4"/>
<line x1="64" y1="78" x2="64" y2="98" stroke="#3a3f4a" stroke-width="4"/>
<!-- Center cross -->
<rect x="50" y="50" width="28" height="28" rx="4" fill="#2a2f38"/>
<line x1="50" y1="64" x2="78" y2="64" stroke="#4dcc80" stroke-width="4"/>
<line x1="64" y1="50" x2="64" y2="78" stroke="#4dcc80" stroke-width="4"/>
<circle cx="64" cy="64" r="6" fill="#4dcc80"/>
<!-- Source -->
<rect x="10" y="54" width="20" height="20" rx="3" fill="#2a2f38"/>
<circle cx="20" cy="64" r="6" fill="#ffd54f"/>
<!-- Output -->
<rect x="98" y="54" width="20" height="20" rx="3" fill="#2a2f38"/>
<circle cx="108" cy="64" r="6" fill="#4dcc80"/>
<!-- Corner pieces -->
<rect x="54" y="10" width="20" height="20" rx="3" fill="#2a2f38"/>
<rect x="54" y="98" width="20" height="20" rx="3" fill="#2a2f38"/>
<text x="64" y="122" font-family="Arial" font-size="8" fill="#4dcc80" text-anchor="middle">CIRCUIT</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,62 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Circuit Logic"
config/description="A minimalist puzzle game about completing circuits. Rotate tiles to connect power sources to outputs. Simple to learn, challenging to master."
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
[autoload]
PuzzleManager="*res://scripts/autoload/puzzle_manager.gd"
ProgressManager="*res://scripts/autoload/progress_manager.gd"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
window/stretch/mode="canvas_items"
window/stretch/aspect="keep"
[input]
click={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null)
]
}
right_click={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":2,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":true,"double_click":false,"script":null)
]
}
reset_level={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
]
}
undo={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":90,"key_label":0,"unicode":122,"location":0,"echo":false,"script":null)
]
}
pause={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
renderer/rendering_method="forward_plus"
environment/defaults/default_clear_color=Color(0.12, 0.14, 0.18, 1)

View file

@ -0,0 +1,149 @@
[gd_scene load_steps=2 format=3 uid="uid://circuit_game"]
[ext_resource type="Script" path="res://scripts/game/game_controller.gd" id="1_game"]
[node name="Game" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_game")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.12, 0.14, 0.18, 1)
[node name="GridContainer" type="Control" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -250.0
offset_top = -250.0
offset_right = 250.0
offset_bottom = 250.0
[node name="UI" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="TopBar" type="HBoxContainer" parent="UI"]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_top = 10.0
offset_bottom = 60.0
theme_override_constants/separation = 20
[node name="BackButton" type="Button" parent="UI/TopBar"]
custom_minimum_size = Vector2(100, 40)
layout_mode = 2
text = "< BACK"
[node name="LevelName" type="Label" parent="UI/TopBar"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.8, 0.9, 1, 1)
theme_override_font_sizes/font_size = 24
text = "Level 1: First Steps"
horizontal_alignment = 1
vertical_alignment = 1
[node name="MovesLabel" type="Label" parent="UI/TopBar"]
custom_minimum_size = Vector2(120, 0)
layout_mode = 2
theme_override_colors/font_color = Color(0.6, 0.7, 0.8, 1)
theme_override_font_sizes/font_size = 20
text = "Moves: 0"
horizontal_alignment = 2
vertical_alignment = 1
[node name="BottomBar" type="HBoxContainer" parent="UI"]
layout_mode = 1
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -60.0
offset_bottom = -10.0
theme_override_constants/separation = 20
[node name="ResetButton" type="Button" parent="UI/BottomBar"]
custom_minimum_size = Vector2(120, 40)
layout_mode = 2
text = "RESET [R]"
[node name="UndoButton" type="Button" parent="UI/BottomBar"]
custom_minimum_size = Vector2(120, 40)
layout_mode = 2
text = "UNDO [Z]"
[node name="Spacer" type="Control" parent="UI/BottomBar"]
layout_mode = 2
size_flags_horizontal = 3
[node name="ParLabel" type="Label" parent="UI/BottomBar"]
custom_minimum_size = Vector2(150, 0)
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.85, 0.3, 0.8)
theme_override_font_sizes/font_size = 18
text = "Par: 5 moves"
horizontal_alignment = 2
vertical_alignment = 1
[node name="WinPanel" type="PanelContainer" parent="."]
visible = false
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -180.0
offset_top = -140.0
offset_right = 180.0
offset_bottom = 140.0
[node name="VBox" type="VBoxContainer" parent="WinPanel"]
layout_mode = 2
theme_override_constants/separation = 15
[node name="Title" type="Label" parent="WinPanel/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.3, 0.8, 0.5, 1)
theme_override_font_sizes/font_size = 36
text = "SOLVED!"
horizontal_alignment = 1
[node name="StarsLabel" type="Label" parent="WinPanel/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.85, 0.3, 1)
theme_override_font_sizes/font_size = 32
text = "★ ★ ★"
horizontal_alignment = 1
[node name="MovesResult" type="Label" parent="WinPanel/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.7, 0.8, 0.9, 1)
theme_override_font_sizes/font_size = 18
text = "Completed in 5 moves"
horizontal_alignment = 1
[node name="NextButton" type="Button" parent="WinPanel/VBox"]
custom_minimum_size = Vector2(200, 45)
layout_mode = 2
size_flags_horizontal = 4
text = "NEXT LEVEL"
[node name="MenuButton" type="Button" parent="WinPanel/VBox"]
custom_minimum_size = Vector2(160, 35)
layout_mode = 2
size_flags_horizontal = 4
text = "MENU"

View file

@ -0,0 +1,79 @@
[gd_scene load_steps=2 format=3 uid="uid://circuit_level_select"]
[ext_resource type="Script" path="res://scripts/ui/level_select.gd" id="1_select"]
[node name="LevelSelect" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_select")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.12, 0.14, 0.18, 1)
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 40.0
offset_top = 20.0
offset_right = -40.0
offset_bottom = -20.0
theme_override_constants/separation = 20
[node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
[node name="BackButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 40)
layout_mode = 2
text = "< BACK"
[node name="Title" type="Label" parent="VBox/Header"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.8, 0.9, 1, 1)
theme_override_font_sizes/font_size = 32
text = "SELECT LEVEL"
horizontal_alignment = 1
vertical_alignment = 1
[node name="TotalStars" type="Label" parent="VBox/Header"]
custom_minimum_size = Vector2(150, 0)
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.85, 0.3, 1)
theme_override_font_sizes/font_size = 20
text = "★ 0 / 30"
horizontal_alignment = 2
vertical_alignment = 1
[node name="ChapterTabs" type="HBoxContainer" parent="VBox"]
layout_mode = 2
alignment = 1
[node name="Chapter1" type="Button" parent="VBox/ChapterTabs"]
custom_minimum_size = Vector2(150, 40)
layout_mode = 2
text = "CHAPTER 1"
[node name="Chapter2" type="Button" parent="VBox/ChapterTabs"]
custom_minimum_size = Vector2(150, 40)
layout_mode = 2
text = "CHAPTER 2"
[node name="ScrollContainer" type="ScrollContainer" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="LevelGrid" type="GridContainer" parent="VBox/ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 20
theme_override_constants/v_separation = 20
columns = 5

View file

@ -0,0 +1,97 @@
[gd_scene load_steps=2 format=3 uid="uid://circuit_main_menu"]
[ext_resource type="Script" path="res://scripts/ui/main_menu.gd" id="1_menu"]
[node name="MainMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_menu")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.12, 0.14, 0.18, 1)
[node name="CircuitPattern" type="Control" parent="Background"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -180.0
offset_top = -160.0
offset_right = 180.0
offset_bottom = 160.0
theme_override_constants/separation = 20
[node name="Title" type="Label" parent="VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.3, 0.8, 0.5, 1)
theme_override_font_sizes/font_size = 56
text = "CIRCUIT LOGIC"
horizontal_alignment = 1
[node name="Subtitle" type="Label" parent="VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.6, 0.7, 0.8, 0.8)
theme_override_font_sizes/font_size = 16
text = "Complete the circuit. Power the outputs."
horizontal_alignment = 1
[node name="StarsLabel" type="Label" parent="VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.85, 0.3, 1)
theme_override_font_sizes/font_size = 20
text = "★ 0 / 30"
horizontal_alignment = 1
[node name="Spacer" type="Control" parent="VBox"]
custom_minimum_size = Vector2(0, 20)
layout_mode = 2
[node name="PlayButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(280, 55)
layout_mode = 2
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 24
text = "▶ PLAY"
[node name="LevelSelectButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(240, 45)
layout_mode = 2
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 18
text = "LEVEL SELECT"
[node name="QuitButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(180, 40)
layout_mode = 2
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 16
text = "QUIT"
[node name="Credits" type="Label" parent="."]
layout_mode = 1
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -150.0
offset_top = -30.0
offset_right = 150.0
theme_override_colors/font_color = Color(1, 1, 1, 0.4)
theme_override_font_sizes/font_size = 12
text = "Made with AeThex Engine"
horizontal_alignment = 1

View file

@ -0,0 +1,330 @@
extends Node
## Progress Manager - Tracks level completion and stars
signal level_completed(level_id: int, stars: int)
signal chapter_unlocked(chapter: int)
const SAVE_PATH = "user://circuit_logic_progress.json"
# Progress data
var completed_levels: Dictionary = {} # level_id -> {stars, moves, time}
var current_chapter: int = 1
var unlocked_chapters: Array[int] = [1]
# Level definitions
var levels: Array[Dictionary] = []
func _ready() -> void:
_define_levels()
load_progress()
func _define_levels() -> void:
# Chapter 1: Basics (5 levels)
levels.append(_create_tutorial_1())
levels.append(_create_tutorial_2())
levels.append(_create_tutorial_3())
levels.append(_create_level_4())
levels.append(_create_level_5())
# Chapter 2: Corners (5 levels)
levels.append(_create_level_6())
levels.append(_create_level_7())
levels.append(_create_level_8())
levels.append(_create_level_9())
levels.append(_create_level_10())
func _create_tutorial_1() -> Dictionary:
# Simple straight line - just one rotation
return {
"id": 1,
"name": "First Steps",
"chapter": 1,
"size": Vector2i(3, 3),
"par_moves": 1,
"tiles": [
{"x": 0, "y": 1, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 1, "y": 1, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 0},
{"x": 2, "y": 1, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
]
}
func _create_tutorial_2() -> Dictionary:
return {
"id": 2,
"name": "Turn Around",
"chapter": 1,
"size": Vector2i(3, 3),
"par_moves": 2,
"tiles": [
{"x": 0, "y": 0, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 1, "y": 0, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
{"x": 1, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
{"x": 0, "y": 1, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
]
}
func _create_tutorial_3() -> Dictionary:
return {
"id": 3,
"name": "The T",
"chapter": 1,
"size": Vector2i(3, 3),
"par_moves": 3,
"tiles": [
{"x": 1, "y": 0, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 1, "y": 1, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 0},
{"x": 0, "y": 1, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 2, "y": 1, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
]
}
func _create_level_4() -> Dictionary:
return {
"id": 4,
"name": "Crossroads",
"chapter": 1,
"size": Vector2i(5, 5),
"par_moves": 4,
"tiles": [
{"x": 2, "y": 0, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 2, "y": 1, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 0},
{"x": 2, "y": 2, "type": PuzzleManager.TileType.CROSS, "rotation": 0},
{"x": 0, "y": 2, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 4, "y": 2, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 1, "y": 2, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
{"x": 3, "y": 2, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
]
}
func _create_level_5() -> Dictionary:
return {
"id": 5,
"name": "Zig Zag",
"chapter": 1,
"size": Vector2i(4, 4),
"par_moves": 5,
"tiles": [
{"x": 0, "y": 0, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 1, "y": 0, "type": PuzzleManager.TileType.CORNER, "rotation": 2},
{"x": 1, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 3},
{"x": 2, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 2},
{"x": 2, "y": 2, "type": PuzzleManager.TileType.CORNER, "rotation": 3},
{"x": 3, "y": 2, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
]
}
func _create_level_6() -> Dictionary:
return {
"id": 6,
"name": "Two Paths",
"chapter": 2,
"size": Vector2i(5, 5),
"par_moves": 6,
"tiles": [
{"x": 0, "y": 2, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 1, "y": 2, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 0},
{"x": 1, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
{"x": 2, "y": 1, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
{"x": 3, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 1},
{"x": 1, "y": 3, "type": PuzzleManager.TileType.CORNER, "rotation": 3},
{"x": 2, "y": 3, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
{"x": 3, "y": 3, "type": PuzzleManager.TileType.CORNER, "rotation": 2},
{"x": 3, "y": 2, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 1},
{"x": 4, "y": 2, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
]
}
func _create_level_7() -> Dictionary:
return {
"id": 7,
"name": "Spiral",
"chapter": 2,
"size": Vector2i(5, 5),
"par_moves": 8,
"tiles": [
{"x": 2, "y": 2, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 2, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 1},
{"x": 3, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 2},
{"x": 3, "y": 2, "type": PuzzleManager.TileType.CORNER, "rotation": 3},
{"x": 3, "y": 3, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
{"x": 2, "y": 3, "type": PuzzleManager.TileType.CORNER, "rotation": 1},
{"x": 1, "y": 3, "type": PuzzleManager.TileType.CORNER, "rotation": 2},
{"x": 1, "y": 2, "type": PuzzleManager.TileType.CORNER, "rotation": 3},
{"x": 1, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
{"x": 0, "y": 1, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
]
}
func _create_level_8() -> Dictionary:
return {
"id": 8,
"name": "Grid Lock",
"chapter": 2,
"size": Vector2i(5, 5),
"par_moves": 10,
"tiles": [
{"x": 0, "y": 0, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 4, "y": 4, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 1, "y": 0, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 0},
{"x": 2, "y": 0, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 0},
{"x": 2, "y": 1, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
{"x": 2, "y": 2, "type": PuzzleManager.TileType.CROSS, "rotation": 0},
{"x": 3, "y": 2, "type": PuzzleManager.TileType.CORNER, "rotation": 1},
{"x": 3, "y": 3, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
{"x": 3, "y": 4, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
]
}
func _create_level_9() -> Dictionary:
return {
"id": 9,
"name": "Twin Outputs",
"chapter": 2,
"size": Vector2i(6, 4),
"par_moves": 8,
"tiles": [
{"x": 0, "y": 1, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 5, "y": 0, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 5, "y": 3, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 1, "y": 1, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 0},
{"x": 2, "y": 0, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
{"x": 2, "y": 2, "type": PuzzleManager.TileType.CORNER, "rotation": 3},
{"x": 3, "y": 0, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
{"x": 3, "y": 2, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 2},
{"x": 4, "y": 0, "type": PuzzleManager.TileType.CORNER, "rotation": 1},
{"x": 4, "y": 3, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
{"x": 1, "y": 0, "type": PuzzleManager.TileType.CORNER, "rotation": 3},
{"x": 1, "y": 2, "type": PuzzleManager.TileType.CORNER, "rotation": 2},
]
}
func _create_level_10() -> Dictionary:
return {
"id": 10,
"name": "The Challenge",
"chapter": 2,
"size": Vector2i(6, 6),
"par_moves": 12,
"tiles": [
{"x": 0, "y": 0, "type": PuzzleManager.TileType.SOURCE, "rotation": 0, "locked": true},
{"x": 5, "y": 5, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 0, "y": 5, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 5, "y": 0, "type": PuzzleManager.TileType.OUTPUT, "rotation": 0, "locked": true},
{"x": 1, "y": 0, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 0},
{"x": 2, "y": 0, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
{"x": 3, "y": 0, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 0},
{"x": 4, "y": 0, "type": PuzzleManager.TileType.CORNER, "rotation": 1},
{"x": 0, "y": 1, "type": PuzzleManager.TileType.CORNER, "rotation": 2},
{"x": 0, "y": 2, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 0},
{"x": 0, "y": 3, "type": PuzzleManager.TileType.T_JUNCTION, "rotation": 3},
{"x": 0, "y": 4, "type": PuzzleManager.TileType.CORNER, "rotation": 3},
{"x": 3, "y": 3, "type": PuzzleManager.TileType.CROSS, "rotation": 0},
{"x": 3, "y": 5, "type": PuzzleManager.TileType.CORNER, "rotation": 0},
{"x": 4, "y": 5, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 1},
{"x": 5, "y": 3, "type": PuzzleManager.TileType.CORNER, "rotation": 2},
{"x": 5, "y": 4, "type": PuzzleManager.TileType.STRAIGHT, "rotation": 0},
]
}
func get_level(level_id: int) -> Dictionary:
for level in levels:
if level.id == level_id:
return level
return {}
func get_levels_for_chapter(chapter: int) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for level in levels:
if level.chapter == chapter:
result.append(level)
return result
func complete_level(level_id: int, moves: int, time: float) -> int:
var level = get_level(level_id)
if level.is_empty():
return 0
# Calculate stars
var stars = 1 # Base star for completion
if moves <= level.par_moves:
stars = 3
elif moves <= level.par_moves * 1.5:
stars = 2
# Save progress
var previous = completed_levels.get(level_id, {"stars": 0})
if stars > previous.get("stars", 0) or not completed_levels.has(level_id):
completed_levels[level_id] = {
"stars": stars,
"moves": moves,
"time": time,
}
# Check chapter unlock
_check_chapter_unlock()
save_progress()
level_completed.emit(level_id, stars)
return stars
func _check_chapter_unlock() -> void:
# Unlock chapter 2 if all chapter 1 levels completed
var chapter1_complete = true
for level in get_levels_for_chapter(1):
if not completed_levels.has(level.id):
chapter1_complete = false
break
if chapter1_complete and 2 not in unlocked_chapters:
unlocked_chapters.append(2)
chapter_unlocked.emit(2)
func is_level_unlocked(level_id: int) -> bool:
var level = get_level(level_id)
if level.is_empty():
return false
# First level always unlocked
if level_id == 1:
return true
# Unlock if previous level completed
return completed_levels.has(level_id - 1)
func get_total_stars() -> int:
var total = 0
for data in completed_levels.values():
total += data.get("stars", 0)
return total
func save_progress() -> void:
var data = {
"completed_levels": completed_levels,
"unlocked_chapters": unlocked_chapters,
}
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data))
func load_progress() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if not file:
return
var json = JSON.new()
if json.parse(file.get_as_text()) == OK:
var data = json.get_data()
completed_levels = data.get("completed_levels", {})
# Convert string keys back to ints
var converted = {}
for key in completed_levels.keys():
converted[int(key)] = completed_levels[key]
completed_levels = converted
var chapters = data.get("unlocked_chapters", [1])
unlocked_chapters.clear()
for c in chapters:
unlocked_chapters.append(int(c))

View file

@ -0,0 +1,208 @@
extends Node
## Puzzle Manager - Handles puzzle logic, tile connections, and win conditions
signal puzzle_solved
signal power_changed(tile: Node2D, powered: bool)
signal move_made(moves: int)
# Tile types
enum TileType {
EMPTY,
STRAIGHT, # | or -
CORNER, # L shape
T_JUNCTION, # T shape
CROSS, # + shape
SOURCE, # Power source (always powered)
OUTPUT, # Target (must be powered to win)
}
# Connection directions (bitflags)
const DIR_UP = 1
const DIR_RIGHT = 2
const DIR_DOWN = 4
const DIR_LEFT = 8
# Base connections for each tile type (rotation 0)
const TILE_CONNECTIONS = {
TileType.EMPTY: 0,
TileType.STRAIGHT: DIR_UP | DIR_DOWN,
TileType.CORNER: DIR_UP | DIR_RIGHT,
TileType.T_JUNCTION: DIR_UP | DIR_RIGHT | DIR_DOWN,
TileType.CROSS: DIR_UP | DIR_RIGHT | DIR_DOWN | DIR_LEFT,
TileType.SOURCE: DIR_UP | DIR_RIGHT | DIR_DOWN | DIR_LEFT,
TileType.OUTPUT: DIR_UP | DIR_RIGHT | DIR_DOWN | DIR_LEFT,
}
var current_puzzle: Dictionary = {}
var grid_size: Vector2i = Vector2i(5, 5)
var tiles: Array = [] # 2D array of tile data
var move_count: int = 0
var undo_stack: Array = []
func _ready() -> void:
pass
func load_puzzle(puzzle_data: Dictionary) -> void:
current_puzzle = puzzle_data
grid_size = puzzle_data.get("size", Vector2i(5, 5))
move_count = 0
undo_stack.clear()
# Initialize tiles array
tiles = []
for y in grid_size.y:
var row = []
for x in grid_size.x:
row.append({
"type": TileType.EMPTY,
"rotation": 0,
"powered": false,
"locked": false,
})
tiles.append(row)
# Load puzzle tiles
for tile_data in puzzle_data.get("tiles", []):
var x = tile_data.get("x", 0)
var y = tile_data.get("y", 0)
if x < grid_size.x and y < grid_size.y:
tiles[y][x] = {
"type": tile_data.get("type", TileType.EMPTY),
"rotation": tile_data.get("rotation", 0),
"powered": false,
"locked": tile_data.get("locked", false),
}
func rotate_tile(x: int, y: int, clockwise: bool = true) -> bool:
if x < 0 or x >= grid_size.x or y < 0 or y >= grid_size.y:
return false
var tile = tiles[y][x]
if tile.locked or tile.type == TileType.EMPTY:
return false
# Save state for undo
undo_stack.append({"x": x, "y": y, "rotation": tile.rotation})
# Rotate
if clockwise:
tile.rotation = (tile.rotation + 1) % 4
else:
tile.rotation = (tile.rotation + 3) % 4
move_count += 1
move_made.emit(move_count)
# Update power flow
_update_power_flow()
return true
func undo() -> bool:
if undo_stack.is_empty():
return false
var last_move = undo_stack.pop_back()
tiles[last_move.y][last_move.x].rotation = last_move.rotation
move_count = max(0, move_count - 1)
move_made.emit(move_count)
_update_power_flow()
return true
func reset_puzzle() -> void:
load_puzzle(current_puzzle)
_update_power_flow()
func get_tile_connections(tile: Dictionary) -> int:
var base = TILE_CONNECTIONS.get(tile.type, 0)
var rotation = tile.rotation
# Rotate connections
for i in rotation:
var new_connections = 0
if base & DIR_UP:
new_connections |= DIR_RIGHT
if base & DIR_RIGHT:
new_connections |= DIR_DOWN
if base & DIR_DOWN:
new_connections |= DIR_LEFT
if base & DIR_LEFT:
new_connections |= DIR_UP
base = new_connections
return base
func _update_power_flow() -> void:
# Reset all power states
for y in grid_size.y:
for x in grid_size.x:
tiles[y][x].powered = false
# Find all sources and propagate power
var sources: Array[Vector2i] = []
for y in grid_size.y:
for x in grid_size.x:
if tiles[y][x].type == TileType.SOURCE:
sources.append(Vector2i(x, y))
# BFS from each source
for source in sources:
_propagate_power(source)
# Check win condition
_check_win_condition()
func _propagate_power(start: Vector2i) -> void:
var visited: Array[Vector2i] = []
var queue: Array[Vector2i] = [start]
while not queue.is_empty():
var current = queue.pop_front()
if current in visited:
continue
visited.append(current)
var x = current.x
var y = current.y
var tile = tiles[y][x]
tile.powered = true
power_changed.emit(null, true) # Signal for UI update
var connections = get_tile_connections(tile)
# Check each direction
if connections & DIR_UP and y > 0:
var neighbor = tiles[y - 1][x]
if get_tile_connections(neighbor) & DIR_DOWN:
queue.append(Vector2i(x, y - 1))
if connections & DIR_DOWN and y < grid_size.y - 1:
var neighbor = tiles[y + 1][x]
if get_tile_connections(neighbor) & DIR_UP:
queue.append(Vector2i(x, y + 1))
if connections & DIR_LEFT and x > 0:
var neighbor = tiles[y][x - 1]
if get_tile_connections(neighbor) & DIR_RIGHT:
queue.append(Vector2i(x - 1, y))
if connections & DIR_RIGHT and x < grid_size.x - 1:
var neighbor = tiles[y][x + 1]
if get_tile_connections(neighbor) & DIR_LEFT:
queue.append(Vector2i(x + 1, y))
func _check_win_condition() -> void:
for y in grid_size.y:
for x in grid_size.x:
var tile = tiles[y][x]
if tile.type == TileType.OUTPUT and not tile.powered:
return # Not all outputs powered
# All outputs are powered!
puzzle_solved.emit()
func is_tile_powered(x: int, y: int) -> bool:
if x < 0 or x >= grid_size.x or y < 0 or y >= grid_size.y:
return false
return tiles[y][x].powered

View file

@ -0,0 +1,310 @@
extends Control
## Game Controller for Circuit Logic
@onready var grid_container: Control = $GridContainer
@onready var level_name: Label = $UI/TopBar/LevelName
@onready var moves_label: Label = $UI/TopBar/MovesLabel
@onready var par_label: Label = $UI/BottomBar/ParLabel
@onready var back_button: Button = $UI/TopBar/BackButton
@onready var reset_button: Button = $UI/BottomBar/ResetButton
@onready var undo_button: Button = $UI/BottomBar/UndoButton
@onready var win_panel: PanelContainer = $WinPanel
@onready var stars_label: Label = $WinPanel/VBox/StarsLabel
@onready var moves_result: Label = $WinPanel/VBox/MovesResult
@onready var next_button: Button = $WinPanel/VBox/NextButton
@onready var menu_button: Button = $WinPanel/VBox/MenuButton
const TILE_SIZE: float = 80.0
const TILE_PADDING: float = 4.0
var current_level_id: int = 1
var tile_nodes: Array = [] # 2D array of tile Control nodes
var start_time: float = 0.0
var is_solved: bool = false
# Colors
const COLOR_EMPTY = Color(0.15, 0.17, 0.22, 1)
const COLOR_UNPOWERED = Color(0.25, 0.28, 0.35, 1)
const COLOR_POWERED = Color(0.3, 0.8, 0.5, 1)
const COLOR_SOURCE = Color(1, 0.85, 0.3, 1)
const COLOR_OUTPUT_OFF = Color(0.8, 0.3, 0.3, 1)
const COLOR_OUTPUT_ON = Color(0.3, 0.8, 0.5, 1)
const COLOR_WIRE = Color(0.4, 0.45, 0.55, 1)
const COLOR_WIRE_POWERED = Color(0.4, 1, 0.6, 1)
func _ready() -> void:
# Get level ID from scene tree or default to 1
current_level_id = get_tree().get_meta("current_level", 1)
# Connect buttons
back_button.pressed.connect(_on_back)
reset_button.pressed.connect(_on_reset)
undo_button.pressed.connect(_on_undo)
next_button.pressed.connect(_on_next)
menu_button.pressed.connect(_on_back)
# Connect puzzle signals
PuzzleManager.puzzle_solved.connect(_on_puzzle_solved)
PuzzleManager.move_made.connect(_on_move_made)
# Load and start level
_load_level(current_level_id)
# Fade in
modulate.a = 0.0
var tween = create_tween()
tween.tween_property(self, "modulate:a", 1.0, 0.3)
func _load_level(level_id: int) -> void:
var level_data = ProgressManager.get_level(level_id)
if level_data.is_empty():
push_error("Level not found: ", level_id)
return
is_solved = false
win_panel.visible = false
start_time = Time.get_ticks_msec() / 1000.0
# Update UI
level_name.text = "Level %d: %s" % [level_data.id, level_data.name]
par_label.text = "Par: %d moves" % level_data.par_moves
moves_label.text = "Moves: 0"
# Load puzzle
PuzzleManager.load_puzzle(level_data)
# Build grid
_build_grid()
# Initial power update
PuzzleManager._update_power_flow()
_update_tile_visuals()
func _build_grid() -> void:
# Clear existing tiles
for child in grid_container.get_children():
child.queue_free()
tile_nodes.clear()
var grid_size = PuzzleManager.grid_size
var total_width = grid_size.x * TILE_SIZE
var total_height = grid_size.y * TILE_SIZE
# Center the grid
grid_container.position = Vector2(
(get_viewport_rect().size.x - total_width) / 2,
(get_viewport_rect().size.y - total_height) / 2
)
grid_container.size = Vector2(total_width, total_height)
# Create tiles
for y in grid_size.y:
var row = []
for x in grid_size.x:
var tile_data = PuzzleManager.tiles[y][x]
var tile_node = _create_tile_node(x, y, tile_data)
grid_container.add_child(tile_node)
row.append(tile_node)
tile_nodes.append(row)
func _create_tile_node(x: int, y: int, tile_data: Dictionary) -> Control:
var container = Control.new()
container.size = Vector2(TILE_SIZE, TILE_SIZE)
container.position = Vector2(x * TILE_SIZE, y * TILE_SIZE)
# Background
var bg = ColorRect.new()
bg.name = "Background"
bg.size = Vector2(TILE_SIZE - TILE_PADDING * 2, TILE_SIZE - TILE_PADDING * 2)
bg.position = Vector2(TILE_PADDING, TILE_PADDING)
bg.color = COLOR_EMPTY
container.add_child(bg)
# Wire container
var wire_container = Control.new()
wire_container.name = "Wires"
wire_container.size = bg.size
wire_container.position = bg.position
container.add_child(wire_container)
# Center node for source/output
var center = ColorRect.new()
center.name = "Center"
center.size = Vector2(20, 20)
center.position = Vector2(TILE_SIZE/2 - 10, TILE_SIZE/2 - 10)
center.visible = false
container.add_child(center)
# Click detection
var button = Button.new()
button.name = "ClickArea"
button.size = Vector2(TILE_SIZE, TILE_SIZE)
button.flat = true
button.modulate.a = 0.0
button.pressed.connect(_on_tile_clicked.bind(x, y))
button.mouse_entered.connect(_on_tile_hover.bind(container, true))
button.mouse_exited.connect(_on_tile_hover.bind(container, false))
container.add_child(button)
# Store position
container.set_meta("grid_x", x)
container.set_meta("grid_y", y)
return container
func _update_tile_visuals() -> void:
for y in PuzzleManager.grid_size.y:
for x in PuzzleManager.grid_size.x:
var tile_data = PuzzleManager.tiles[y][x]
var tile_node = tile_nodes[y][x]
_draw_tile(tile_node, tile_data)
func _draw_tile(tile_node: Control, tile_data: Dictionary) -> void:
var bg: ColorRect = tile_node.get_node("Background")
var wire_container: Control = tile_node.get_node("Wires")
var center: ColorRect = tile_node.get_node("Center")
# Clear wires
for child in wire_container.get_children():
child.queue_free()
# Set background color
if tile_data.type == PuzzleManager.TileType.EMPTY:
bg.color = COLOR_EMPTY
center.visible = false
return
bg.color = COLOR_UNPOWERED if not tile_data.powered else COLOR_POWERED.darkened(0.5)
# Get connections
var connections = PuzzleManager.get_tile_connections(tile_data)
var wire_color = COLOR_WIRE_POWERED if tile_data.powered else COLOR_WIRE
var tile_size = bg.size.x
var wire_width = 8.0
var center_size = tile_size * 0.25
# Draw wires
if connections & PuzzleManager.DIR_UP:
var wire = ColorRect.new()
wire.size = Vector2(wire_width, tile_size/2)
wire.position = Vector2(tile_size/2 - wire_width/2, 0)
wire.color = wire_color
wire_container.add_child(wire)
if connections & PuzzleManager.DIR_DOWN:
var wire = ColorRect.new()
wire.size = Vector2(wire_width, tile_size/2)
wire.position = Vector2(tile_size/2 - wire_width/2, tile_size/2)
wire.color = wire_color
wire_container.add_child(wire)
if connections & PuzzleManager.DIR_LEFT:
var wire = ColorRect.new()
wire.size = Vector2(tile_size/2, wire_width)
wire.position = Vector2(0, tile_size/2 - wire_width/2)
wire.color = wire_color
wire_container.add_child(wire)
if connections & PuzzleManager.DIR_RIGHT:
var wire = ColorRect.new()
wire.size = Vector2(tile_size/2, wire_width)
wire.position = Vector2(tile_size/2, tile_size/2 - wire_width/2)
wire.color = wire_color
wire_container.add_child(wire)
# Draw center node
center.visible = true
center.size = Vector2(center_size, center_size)
center.position = Vector2(TILE_SIZE/2 - center_size/2 - TILE_PADDING, TILE_SIZE/2 - center_size/2 - TILE_PADDING)
match tile_data.type:
PuzzleManager.TileType.SOURCE:
center.color = COLOR_SOURCE
PuzzleManager.TileType.OUTPUT:
center.color = COLOR_OUTPUT_ON if tile_data.powered else COLOR_OUTPUT_OFF
_:
center.color = wire_color
center.size = Vector2(wire_width * 1.5, wire_width * 1.5)
center.position = Vector2(TILE_SIZE/2 - center.size.x/2 - TILE_PADDING, TILE_SIZE/2 - center.size.y/2 - TILE_PADDING)
func _on_tile_clicked(x: int, y: int) -> void:
if is_solved:
return
if PuzzleManager.rotate_tile(x, y, true):
_animate_tile_rotation(x, y)
_update_tile_visuals()
func _animate_tile_rotation(x: int, y: int) -> void:
var tile_node = tile_nodes[y][x]
var tween = create_tween()
tween.tween_property(tile_node, "rotation", deg_to_rad(90), 0.15)
tween.tween_callback(func(): tile_node.rotation = 0)
func _on_tile_hover(tile_node: Control, hovering: bool) -> void:
if is_solved:
return
var x = tile_node.get_meta("grid_x")
var y = tile_node.get_meta("grid_y")
var tile_data = PuzzleManager.tiles[y][x]
if tile_data.type == PuzzleManager.TileType.EMPTY or tile_data.locked:
return
var bg: ColorRect = tile_node.get_node("Background")
if hovering:
bg.modulate = Color(1.2, 1.2, 1.2, 1)
else:
bg.modulate = Color.WHITE
func _on_move_made(moves: int) -> void:
moves_label.text = "Moves: %d" % moves
func _on_puzzle_solved() -> void:
is_solved = true
var elapsed = (Time.get_ticks_msec() / 1000.0) - start_time
var stars = ProgressManager.complete_level(current_level_id, PuzzleManager.move_count, elapsed)
# Update win panel
var star_text = ""
for i in 3:
star_text += "" if i < stars else ""
stars_label.text = star_text.strip_edges()
moves_result.text = "Completed in %d moves" % PuzzleManager.move_count
# Check if there's a next level
var next_level = ProgressManager.get_level(current_level_id + 1)
next_button.visible = not next_level.is_empty()
# Show win panel with animation
await get_tree().create_timer(0.5).timeout
win_panel.modulate.a = 0.0
win_panel.visible = true
var tween = create_tween()
tween.tween_property(win_panel, "modulate:a", 1.0, 0.3)
func _on_back() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _on_reset() -> void:
PuzzleManager.reset_puzzle()
_update_tile_visuals()
moves_label.text = "Moves: 0"
is_solved = false
win_panel.visible = false
func _on_undo() -> void:
if PuzzleManager.undo():
_update_tile_visuals()
func _on_next() -> void:
current_level_id += 1
get_tree().set_meta("current_level", current_level_id)
_load_level(current_level_id)
func _input(event: InputEvent) -> void:
if event.is_action_pressed("reset_level"):
_on_reset()
elif event.is_action_pressed("undo"):
_on_undo()

View file

@ -0,0 +1,112 @@
extends Control
## Level Select Screen
@onready var back_button: Button = $VBox/Header/BackButton
@onready var total_stars: Label = $VBox/Header/TotalStars
@onready var chapter1_btn: Button = $VBox/ChapterTabs/Chapter1
@onready var chapter2_btn: Button = $VBox/ChapterTabs/Chapter2
@onready var level_grid: GridContainer = $VBox/ScrollContainer/LevelGrid
var current_chapter: int = 1
func _ready() -> void:
back_button.pressed.connect(_on_back)
chapter1_btn.pressed.connect(_on_chapter_selected.bind(1))
chapter2_btn.pressed.connect(_on_chapter_selected.bind(2))
_update_stars_display()
_update_chapter_buttons()
_populate_levels(1)
# Fade in
modulate.a = 0.0
var tween = create_tween()
tween.tween_property(self, "modulate:a", 1.0, 0.3)
func _update_stars_display() -> void:
var stars = ProgressManager.get_total_stars()
var max_stars = ProgressManager.levels.size() * 3
total_stars.text = "%d / %d" % [stars, max_stars]
func _update_chapter_buttons() -> void:
chapter1_btn.disabled = false
chapter2_btn.disabled = 2 not in ProgressManager.unlocked_chapters
func _on_chapter_selected(chapter: int) -> void:
current_chapter = chapter
_populate_levels(chapter)
func _populate_levels(chapter: int) -> void:
# Clear existing
for child in level_grid.get_children():
child.queue_free()
var levels = ProgressManager.get_levels_for_chapter(chapter)
for level in levels:
var card = _create_level_card(level)
level_grid.add_child(card)
func _create_level_card(level: Dictionary) -> Control:
var card = PanelContainer.new()
card.custom_minimum_size = Vector2(180, 150)
var vbox = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 10)
card.add_child(vbox)
# Level number
var num_label = Label.new()
num_label.text = str(level.id)
num_label.add_theme_font_size_override("font_size", 36)
num_label.add_theme_color_override("font_color", Color(0.3, 0.8, 0.5))
num_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(num_label)
# Level name
var name_label = Label.new()
name_label.text = level.name
name_label.add_theme_font_size_override("font_size", 16)
name_label.add_theme_color_override("font_color", Color(0.7, 0.8, 0.9))
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
vbox.add_child(name_label)
# Stars
var stars_label = Label.new()
var progress = ProgressManager.completed_levels.get(level.id, {})
var stars = progress.get("stars", 0)
var star_text = ""
for i in 3:
star_text += "" if i < stars else ""
stars_label.text = star_text
stars_label.add_theme_font_size_override("font_size", 24)
stars_label.add_theme_color_override("font_color", Color(1, 0.85, 0.3))
stars_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(stars_label)
# Play button
var play_btn = Button.new()
var unlocked = ProgressManager.is_level_unlocked(level.id)
play_btn.text = "PLAY" if unlocked else "🔒"
play_btn.disabled = not unlocked
play_btn.custom_minimum_size = Vector2(100, 35)
play_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
play_btn.pressed.connect(_on_level_selected.bind(level.id))
vbox.add_child(play_btn)
# Locked visual
if not unlocked:
card.modulate = Color(0.5, 0.5, 0.5, 1)
return card
func _on_level_selected(level_id: int) -> void:
get_tree().set_meta("current_level", level_id)
var tween = create_tween()
tween.tween_property(self, "modulate:a", 0.0, 0.3)
await tween.finished
get_tree().change_scene_to_file("res://scenes/game.tscn")
func _on_back() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")

View file

@ -0,0 +1,78 @@
extends Control
## Main Menu for Circuit Logic
@onready var play_button: Button = $VBox/PlayButton
@onready var level_select_button: Button = $VBox/LevelSelectButton
@onready var quit_button: Button = $VBox/QuitButton
@onready var stars_label: Label = $VBox/StarsLabel
@onready var circuit_pattern: Control = $Background/CircuitPattern
func _ready() -> void:
play_button.pressed.connect(_on_play)
level_select_button.pressed.connect(_on_level_select)
quit_button.pressed.connect(_on_quit)
# Update stars display
var total = ProgressManager.get_total_stars()
var max_stars = ProgressManager.levels.size() * 3
stars_label.text = "%d / %d" % [total, max_stars]
# Draw decorative circuit pattern
_draw_circuit_pattern()
# Fade in
modulate.a = 0.0
var tween = create_tween()
tween.tween_property(self, "modulate:a", 1.0, 0.5)
func _draw_circuit_pattern() -> void:
# Create decorative background circuits
var viewport = get_viewport_rect().size
for i in 20:
var line = ColorRect.new()
line.color = Color(0.3, 0.8, 0.5, 0.1)
if randf() > 0.5:
# Horizontal line
line.size = Vector2(randf_range(50, 200), 2)
line.position = Vector2(randf() * viewport.x, randf() * viewport.y)
else:
# Vertical line
line.size = Vector2(2, randf_range(50, 200))
line.position = Vector2(randf() * viewport.x, randf() * viewport.y)
circuit_pattern.add_child(line)
# Add nodes at endpoints
var node_size = 6
var node = ColorRect.new()
node.color = Color(0.3, 0.8, 0.5, 0.15)
node.size = Vector2(node_size, node_size)
node.position = line.position - Vector2(node_size/2, node_size/2)
circuit_pattern.add_child(node)
func _on_play() -> void:
# Find the first unlocked incomplete level, or first level
var next_level = 1
for level in ProgressManager.levels:
if ProgressManager.is_level_unlocked(level.id) and not ProgressManager.completed_levels.has(level.id):
next_level = level.id
break
_start_level(next_level)
func _on_level_select() -> void:
get_tree().change_scene_to_file("res://scenes/level_select.tscn")
func _start_level(level_id: int) -> void:
var tween = create_tween()
tween.tween_property(self, "modulate:a", 0.0, 0.3)
await tween.finished
# Store level to load
get_tree().set_meta("current_level", level_id)
get_tree().change_scene_to_file("res://scenes/game.tscn")
func _on_quit() -> void:
get_tree().quit()

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="neon_grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00ffff"/>
<stop offset="100%" style="stop-color:#ff1493"/>
</linearGradient>
</defs>
<rect width="128" height="128" fill="#0d0221"/>
<path d="M20 100 L40 60 L50 80 L70 40 L90 70 L108 30" stroke="url(#neon_grad)" stroke-width="4" fill="none" stroke-linecap="round"/>
<circle cx="108" cy="30" r="8" fill="#00ffff"/>
<rect x="15" y="105" width="98" height="8" fill="#ff1493"/>
<text x="64" y="125" font-family="Arial" font-size="12" fill="#00ffff" text-anchor="middle">NEON</text>
</svg>

After

Width:  |  Height:  |  Size: 694 B

View file

@ -0,0 +1,61 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Neon Runner"
config/description="A synthwave-themed endless runner. Dash through neon-lit cityscapes, dodge obstacles, and chase high scores."
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
[autoload]
GameManager="*res://scripts/autoload/game_manager.gd"
AudioManager="*res://scripts/autoload/audio_manager.gd"
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
[input]
jump={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
slide={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
pause={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[layer_names]
2d_physics/layer_1="player"
2d_physics/layer_2="obstacles"
2d_physics/layer_3="collectibles"
2d_physics/layer_4="ground"
[rendering]
renderer/rendering_method="forward_plus"
environment/defaults/default_clear_color=Color(0.05, 0.02, 0.1, 1)

View file

@ -0,0 +1,222 @@
[gd_scene load_steps=5 format=3 uid="uid://game_scene"]
[ext_resource type="Script" path="res://scripts/game/game_controller.gd" id="1_game"]
[ext_resource type="PackedScene" uid="uid://player_scene" path="res://scenes/player.tscn" id="2_player"]
[ext_resource type="Script" path="res://scripts/game/level_generator.gd" id="3_gen"]
[ext_resource type="Script" path="res://scripts/ui/game_ui.gd" id="4_ui"]
[node name="Game" type="Node2D"]
script = ExtResource("1_game")
[node name="ParallaxBackground" type="ParallaxBackground" parent="."]
[node name="Sky" type="ParallaxLayer" parent="ParallaxBackground"]
motion_scale = Vector2(0.1, 0.1)
[node name="ColorRect" type="ColorRect" parent="ParallaxBackground/Sky"]
offset_left = -100.0
offset_top = -100.0
offset_right = 2020.0
offset_bottom = 1180.0
color = Color(0.05, 0.02, 0.15, 1)
[node name="Buildings" type="ParallaxLayer" parent="ParallaxBackground"]
motion_scale = Vector2(0.3, 0.3)
[node name="FarBuildings" type="ParallaxLayer" parent="ParallaxBackground"]
motion_scale = Vector2(0.5, 0.5)
[node name="Ground" type="StaticBody2D" parent="."]
position = Vector2(960, 900)
[node name="CollisionShape2D" type="CollisionShape2D" parent="Ground"]
shape = SubResource("RectangleShape2D_ground")
[node name="GroundVisual" type="ColorRect" parent="Ground"]
offset_left = -1000.0
offset_top = -10.0
offset_right = 1000.0
offset_bottom = 200.0
color = Color(0.1, 0.05, 0.2, 1)
[node name="GroundLine" type="ColorRect" parent="Ground"]
offset_left = -1000.0
offset_top = -12.0
offset_right = 1000.0
offset_bottom = -8.0
color = Color(1, 0.2, 0.6, 1)
[node name="Player" parent="." instance=ExtResource("2_player")]
position = Vector2(300, 800)
[node name="LevelGenerator" type="Node2D" parent="."]
script = ExtResource("3_gen")
[node name="ObstacleContainer" type="Node2D" parent="LevelGenerator"]
[node name="CollectibleContainer" type="Node2D" parent="LevelGenerator"]
[node name="Camera2D" type="Camera2D" parent="."]
position = Vector2(960, 540)
[node name="CanvasLayer" type="CanvasLayer" parent="."]
[node name="GameUI" type="Control" parent="CanvasLayer"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("4_ui")
[node name="ScoreLabel" type="Label" parent="CanvasLayer/GameUI"]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -200.0
offset_top = 20.0
offset_right = -20.0
offset_bottom = 60.0
grow_horizontal = 0
theme_override_colors/font_color = Color(0, 1, 1, 1)
theme_override_font_sizes/font_size = 32
text = "0"
horizontal_alignment = 2
[node name="MultiplierLabel" type="Label" parent="CanvasLayer/GameUI"]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -200.0
offset_top = 60.0
offset_right = -20.0
offset_bottom = 90.0
grow_horizontal = 0
theme_override_colors/font_color = Color(1, 0.8, 0, 1)
theme_override_font_sizes/font_size = 20
text = "x1.0"
horizontal_alignment = 2
[node name="PauseMenu" type="Control" parent="CanvasLayer/GameUI"]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Overlay" type="ColorRect" parent="CanvasLayer/GameUI/PauseMenu"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0, 0, 0, 0.7)
[node name="VBox" type="VBoxContainer" parent="CanvasLayer/GameUI/PauseMenu"]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -150.0
offset_top = -100.0
offset_right = 150.0
offset_bottom = 100.0
theme_override_constants/separation = 20
[node name="PausedLabel" type="Label" parent="CanvasLayer/GameUI/PauseMenu/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.2, 0.6, 1)
theme_override_font_sizes/font_size = 48
text = "PAUSED"
horizontal_alignment = 1
[node name="ResumeButton" type="Button" parent="CanvasLayer/GameUI/PauseMenu/VBox"]
custom_minimum_size = Vector2(200, 50)
layout_mode = 2
size_flags_horizontal = 4
text = "RESUME"
[node name="RestartButton" type="Button" parent="CanvasLayer/GameUI/PauseMenu/VBox"]
custom_minimum_size = Vector2(200, 50)
layout_mode = 2
size_flags_horizontal = 4
text = "RESTART"
[node name="MenuButton" type="Button" parent="CanvasLayer/GameUI/PauseMenu/VBox"]
custom_minimum_size = Vector2(200, 50)
layout_mode = 2
size_flags_horizontal = 4
text = "MAIN MENU"
[node name="GameOverMenu" type="Control" parent="CanvasLayer/GameUI"]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Overlay" type="ColorRect" parent="CanvasLayer/GameUI/GameOverMenu"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.1, 0, 0.05, 0.9)
[node name="VBox" type="VBoxContainer" parent="CanvasLayer/GameUI/GameOverMenu"]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -200.0
offset_top = -150.0
offset_right = 200.0
offset_bottom = 150.0
theme_override_constants/separation = 15
[node name="GameOverLabel" type="Label" parent="CanvasLayer/GameUI/GameOverMenu/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.2, 0.6, 1)
theme_override_font_sizes/font_size = 56
text = "GAME OVER"
horizontal_alignment = 1
[node name="FinalScoreLabel" type="Label" parent="CanvasLayer/GameUI/GameOverMenu/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0, 1, 1, 1)
theme_override_font_sizes/font_size = 32
text = "SCORE: 0"
horizontal_alignment = 1
[node name="HighScoreLabel" type="Label" parent="CanvasLayer/GameUI/GameOverMenu/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.8, 0, 1)
theme_override_font_sizes/font_size = 24
text = "HIGH SCORE: 0"
horizontal_alignment = 1
[node name="NewHighScore" type="Label" parent="CanvasLayer/GameUI/GameOverMenu/VBox"]
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 0, 1)
theme_override_font_sizes/font_size = 20
text = "★ NEW HIGH SCORE! ★"
horizontal_alignment = 1
[node name="RetryButton" type="Button" parent="CanvasLayer/GameUI/GameOverMenu/VBox"]
custom_minimum_size = Vector2(200, 50)
layout_mode = 2
size_flags_horizontal = 4
text = "RETRY"
[node name="MenuButton" type="Button" parent="CanvasLayer/GameUI/GameOverMenu/VBox"]
custom_minimum_size = Vector2(200, 50)
layout_mode = 2
size_flags_horizontal = 4
text = "MAIN MENU"
[sub_resource type="RectangleShape2D" id="RectangleShape2D_ground"]
size = Vector2(4000, 20)

View file

@ -0,0 +1,100 @@
[gd_scene load_steps=2 format=3 uid="uid://main_menu_scene"]
[ext_resource type="Script" path="res://scripts/ui/main_menu.gd" id="1_menu"]
[node name="MainMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_menu")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.05, 0.02, 0.1, 1)
[node name="GridLines" type="ColorRect" parent="Background"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -200.0
offset_top = -150.0
offset_right = 200.0
offset_bottom = 150.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 20
[node name="Title" type="Label" parent="VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.2, 0.6, 1)
theme_override_colors/font_outline_color = Color(0, 1, 1, 1)
theme_override_constants/outline_size = 4
theme_override_font_sizes/font_size = 72
text = "NEON RUNNER"
horizontal_alignment = 1
[node name="Subtitle" type="Label" parent="VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0, 1, 1, 0.8)
theme_override_font_sizes/font_size = 18
text = "Dash through the neon city"
horizontal_alignment = 1
[node name="Spacer" type="Control" parent="VBox"]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
[node name="PlayButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(300, 60)
layout_mode = 2
size_flags_horizontal = 4
theme_override_colors/font_color = Color(0, 0, 0, 1)
theme_override_colors/font_hover_color = Color(0.05, 0.02, 0.1, 1)
theme_override_font_sizes/font_size = 28
text = "▶ PLAY"
[node name="HighScoreLabel" type="Label" parent="VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.8, 0, 1)
theme_override_font_sizes/font_size = 24
text = "HIGH SCORE: 0"
horizontal_alignment = 1
[node name="QuitButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(200, 40)
layout_mode = 2
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 18
text = "QUIT"
[node name="Credits" type="Label" parent="."]
layout_mode = 1
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -150.0
offset_top = -40.0
offset_right = 150.0
grow_horizontal = 2
grow_vertical = 0
theme_override_colors/font_color = Color(1, 1, 1, 0.5)
theme_override_font_sizes/font_size = 14
text = "Made with AeThex Engine"
horizontal_alignment = 1

View file

@ -0,0 +1,79 @@
[gd_scene load_steps=3 format=3 uid="uid://player_scene"]
[ext_resource type="Script" path="res://scripts/game/player.gd" id="1_player"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_player"]
size = Vector2(40, 80)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_slide"]
size = Vector2(60, 30)
[node name="Player" type="CharacterBody2D"]
collision_layer = 1
collision_mask = 6
script = ExtResource("1_player")
[node name="Sprite" type="ColorRect" parent="."]
offset_left = -20.0
offset_top = -80.0
offset_right = 20.0
color = Color(0, 1, 1, 1)
[node name="GlowEffect" type="ColorRect" parent="Sprite"]
modulate = Color(1, 1, 1, 0.3)
offset_left = -5.0
offset_top = -5.0
offset_right = 45.0
offset_bottom = 85.0
color = Color(0, 1, 1, 1)
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
position = Vector2(0, -40)
shape = SubResource("RectangleShape2D_player")
[node name="SlideCollision" type="CollisionShape2D" parent="."]
visible = false
position = Vector2(0, -15)
disabled = true
shape = SubResource("RectangleShape2D_slide")
[node name="TrailParticles" type="GPUParticles2D" parent="."]
position = Vector2(-20, 0)
emitting = false
amount = 20
process_material = SubResource("ParticlesMaterial_trail")
lifetime = 0.5
[node name="JumpParticles" type="GPUParticles2D" parent="."]
emitting = false
amount = 15
one_shot = true
explosiveness = 1.0
process_material = SubResource("ParticlesMaterial_jump")
lifetime = 0.3
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
[sub_resource type="ParticleProcessMaterial" id="ParticlesMaterial_trail"]
emission_shape = 1
emission_sphere_radius = 5.0
direction = Vector3(-1, 0, 0)
spread = 15.0
initial_velocity_min = 50.0
initial_velocity_max = 100.0
gravity = Vector3(0, 0, 0)
scale_min = 0.5
scale_max = 1.5
color = Color(0, 1, 1, 0.8)
[sub_resource type="ParticleProcessMaterial" id="ParticlesMaterial_jump"]
emission_shape = 1
emission_sphere_radius = 20.0
direction = Vector3(0, 1, 0)
spread = 60.0
initial_velocity_min = 100.0
initial_velocity_max = 200.0
gravity = Vector3(0, 300, 0)
scale_min = 0.3
scale_max = 1.0
color = Color(1, 0.2, 0.6, 1)

View file

@ -0,0 +1,66 @@
extends Node
## Audio Manager - Handles all game audio with synthwave vibes
var music_player: AudioStreamPlayer
var sfx_players: Array[AudioStreamPlayer] = []
var current_sfx_index: int = 0
const MAX_SFX_PLAYERS = 8
var music_volume: float = 0.8:
set(value):
music_volume = clamp(value, 0.0, 1.0)
if music_player:
music_player.volume_db = linear_to_db(music_volume)
var sfx_volume: float = 1.0:
set(value):
sfx_volume = clamp(value, 0.0, 1.0)
func _ready() -> void:
# Create music player
music_player = AudioStreamPlayer.new()
music_player.bus = "Music"
add_child(music_player)
# Create SFX player pool
for i in MAX_SFX_PLAYERS:
var player = AudioStreamPlayer.new()
player.bus = "SFX"
add_child(player)
sfx_players.append(player)
func play_music(stream: AudioStream, fade_in: float = 1.0) -> void:
if music_player.playing:
var tween = create_tween()
tween.tween_property(music_player, "volume_db", -40.0, 0.5)
await tween.finished
music_player.stream = stream
music_player.volume_db = -40.0
music_player.play()
var tween = create_tween()
tween.tween_property(music_player, "volume_db", linear_to_db(music_volume), fade_in)
func stop_music(fade_out: float = 1.0) -> void:
if music_player.playing:
var tween = create_tween()
tween.tween_property(music_player, "volume_db", -40.0, fade_out)
await tween.finished
music_player.stop()
func play_sfx(stream: AudioStream, pitch_variance: float = 0.0) -> void:
var player = sfx_players[current_sfx_index]
current_sfx_index = (current_sfx_index + 1) % MAX_SFX_PLAYERS
player.stream = stream
player.volume_db = linear_to_db(sfx_volume)
if pitch_variance > 0:
player.pitch_scale = randf_range(1.0 - pitch_variance, 1.0 + pitch_variance)
else:
player.pitch_scale = 1.0
player.play()
func play_sfx_at_position(stream: AudioStream, _position: Vector2) -> void:
# For 2D positional audio - simplified for this game
play_sfx(stream)

View file

@ -0,0 +1,89 @@
extends Node
## Game Manager - Global game state and scoring
signal score_changed(new_score: int)
signal high_score_changed(new_high_score: int)
signal game_over
signal game_started
signal multiplier_changed(multiplier: float)
var score: int = 0:
set(value):
score = value
score_changed.emit(score)
if score > high_score:
high_score = score
var high_score: int = 0:
set(value):
high_score = value
high_score_changed.emit(high_score)
_save_high_score()
var distance: float = 0.0
var multiplier: float = 1.0:
set(value):
multiplier = value
multiplier_changed.emit(multiplier)
var game_speed: float = 400.0
var base_speed: float = 400.0
var max_speed: float = 1200.0
var speed_increase_rate: float = 0.5
var is_playing: bool = false
var is_paused: bool = false
const SAVE_PATH = "user://neon_runner_save.dat"
func _ready() -> void:
_load_high_score()
func _process(delta: float) -> void:
if is_playing and not is_paused:
distance += game_speed * delta
# Gradually increase speed
game_speed = min(game_speed + speed_increase_rate * delta, max_speed)
# Add distance-based score
score += int(game_speed * delta * 0.01 * multiplier)
func start_game() -> void:
score = 0
distance = 0.0
multiplier = 1.0
game_speed = base_speed
is_playing = true
is_paused = false
game_started.emit()
func end_game() -> void:
is_playing = false
game_over.emit()
func pause_game() -> void:
is_paused = true
get_tree().paused = true
func resume_game() -> void:
is_paused = false
get_tree().paused = false
func add_score(amount: int) -> void:
score += int(amount * multiplier)
func add_multiplier(amount: float) -> void:
multiplier = min(multiplier + amount, 5.0)
func reset_multiplier() -> void:
multiplier = 1.0
func _save_high_score() -> void:
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file:
file.store_var(high_score)
func _load_high_score() -> void:
if FileAccess.file_exists(SAVE_PATH):
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file:
high_score = file.get_var()

View file

@ -0,0 +1,33 @@
extends Node2D
## Game Controller - Main game loop and coordination
@onready var player: Player = $Player
@onready var level_generator: Node2D = $LevelGenerator
@onready var game_ui: Control = $CanvasLayer/GameUI
func _ready() -> void:
# Connect player signals
player.died.connect(_on_player_died)
# Start the game
await get_tree().create_timer(0.5).timeout
GameManager.start_game()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("pause") and GameManager.is_playing:
if GameManager.is_paused:
GameManager.resume_game()
else:
GameManager.pause_game()
func _on_player_died() -> void:
# Game over is handled by GameManager
pass
func restart_game() -> void:
GameManager.start_game()
level_generator.reset()
func return_to_menu() -> void:
GameManager.is_playing = false
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")

View file

@ -0,0 +1,272 @@
extends Node2D
## Level Generator - Procedural obstacle and collectible spawning
@onready var obstacle_container: Node2D = $ObstacleContainer
@onready var collectible_container: Node2D = $CollectibleContainer
const SPAWN_X: float = 2100.0 # Off-screen right
const DESPAWN_X: float = -200.0 # Off-screen left
var spawn_timer: float = 0.0
var min_spawn_interval: float = 0.8
var max_spawn_interval: float = 2.0
var current_spawn_interval: float = 1.5
var obstacle_scene: PackedScene
var collectible_scene: PackedScene
# Obstacle patterns
enum ObstacleType { LOW, HIGH, DOUBLE, MOVING }
var last_obstacle_type: ObstacleType = ObstacleType.LOW
func _ready() -> void:
# Create obstacle and collectible scenes programmatically
_create_obstacle_scene()
_create_collectible_scene()
GameManager.game_started.connect(_on_game_started)
func _process(delta: float) -> void:
if not GameManager.is_playing or GameManager.is_paused:
return
# Move all obstacles and collectibles
var speed = GameManager.game_speed * delta
for obstacle in obstacle_container.get_children():
obstacle.position.x -= speed
if obstacle.position.x < DESPAWN_X:
obstacle.queue_free()
for collectible in collectible_container.get_children():
collectible.position.x -= speed
if collectible.position.x < DESPAWN_X:
collectible.queue_free()
# Spawn new obstacles
spawn_timer -= delta
if spawn_timer <= 0:
_spawn_obstacle()
_maybe_spawn_collectibles()
spawn_timer = randf_range(min_spawn_interval, current_spawn_interval)
# Increase difficulty over time
current_spawn_interval = max(min_spawn_interval,
current_spawn_interval - 0.01)
func _spawn_obstacle() -> void:
var obstacle = _create_obstacle()
obstacle_container.add_child(obstacle)
func _create_obstacle() -> Node2D:
var obstacle = Area2D.new()
obstacle.collision_layer = 2
obstacle.collision_mask = 1
obstacle.position = Vector2(SPAWN_X, 0)
# Choose obstacle type (avoid repeating)
var obstacle_type = _choose_obstacle_type()
last_obstacle_type = obstacle_type
match obstacle_type:
ObstacleType.LOW:
_setup_low_obstacle(obstacle)
ObstacleType.HIGH:
_setup_high_obstacle(obstacle)
ObstacleType.DOUBLE:
_setup_double_obstacle(obstacle)
ObstacleType.MOVING:
_setup_moving_obstacle(obstacle)
obstacle.body_entered.connect(_on_obstacle_hit)
return obstacle
func _choose_obstacle_type() -> ObstacleType:
var types = [ObstacleType.LOW, ObstacleType.HIGH, ObstacleType.DOUBLE]
# Add moving obstacles once player has progressed
if GameManager.distance > 5000:
types.append(ObstacleType.MOVING)
# Remove last type to add variety
types.erase(last_obstacle_type)
return types[randi() % types.size()]
func _setup_low_obstacle(obstacle: Area2D) -> void:
# Ground-level obstacle - player must jump
obstacle.position.y = 850
var shape = CollisionShape2D.new()
var rect = RectangleShape2D.new()
rect.size = Vector2(60, 80)
shape.shape = rect
shape.position = Vector2(0, -40)
obstacle.add_child(shape)
var visual = ColorRect.new()
visual.size = Vector2(60, 80)
visual.position = Vector2(-30, -80)
visual.color = Color(1, 0.2, 0.6, 1)
obstacle.add_child(visual)
# Glow effect
var glow = ColorRect.new()
glow.size = Vector2(70, 90)
glow.position = Vector2(-35, -85)
glow.color = Color(1, 0.2, 0.6, 0.3)
obstacle.add_child(glow)
glow.z_index = -1
func _setup_high_obstacle(obstacle: Area2D) -> void:
# Floating obstacle - player must slide under
obstacle.position.y = 750
var shape = CollisionShape2D.new()
var rect = RectangleShape2D.new()
rect.size = Vector2(100, 40)
shape.shape = rect
obstacle.add_child(shape)
var visual = ColorRect.new()
visual.size = Vector2(100, 40)
visual.position = Vector2(-50, -20)
visual.color = Color(1, 0.5, 0, 1)
obstacle.add_child(visual)
func _setup_double_obstacle(obstacle: Area2D) -> void:
# Two obstacles with a gap - requires precise timing
obstacle.position.y = 850
# First obstacle (low)
var shape1 = CollisionShape2D.new()
var rect1 = RectangleShape2D.new()
rect1.size = Vector2(40, 60)
shape1.shape = rect1
shape1.position = Vector2(-60, -30)
obstacle.add_child(shape1)
var visual1 = ColorRect.new()
visual1.size = Vector2(40, 60)
visual1.position = Vector2(-80, -60)
visual1.color = Color(1, 0.2, 0.6, 1)
obstacle.add_child(visual1)
# Second obstacle (low)
var shape2 = CollisionShape2D.new()
var rect2 = RectangleShape2D.new()
rect2.size = Vector2(40, 60)
shape2.shape = rect2
shape2.position = Vector2(60, -30)
obstacle.add_child(shape2)
var visual2 = ColorRect.new()
visual2.size = Vector2(40, 60)
visual2.position = Vector2(40, -60)
visual2.color = Color(1, 0.2, 0.6, 1)
obstacle.add_child(visual2)
func _setup_moving_obstacle(obstacle: Area2D) -> void:
# Obstacle that moves up and down
obstacle.position.y = 800
var shape = CollisionShape2D.new()
var rect = RectangleShape2D.new()
rect.size = Vector2(50, 50)
shape.shape = rect
obstacle.add_child(shape)
var visual = ColorRect.new()
visual.size = Vector2(50, 50)
visual.position = Vector2(-25, -25)
visual.color = Color(0.8, 0, 1, 1)
obstacle.add_child(visual)
# Add movement animation
var tween = create_tween().set_loops()
tween.tween_property(obstacle, "position:y", 700, 0.5)
tween.tween_property(obstacle, "position:y", 850, 0.5)
func _maybe_spawn_collectibles() -> void:
if randf() > 0.6: # 40% chance
return
var count = randi_range(3, 7)
var pattern = randi() % 3 # 0: line, 1: arc, 2: zigzag
for i in count:
var collectible = _create_collectible()
match pattern:
0: # Line
collectible.position = Vector2(SPAWN_X + i * 80, 700)
1: # Arc
var arc_y = 750 - sin(i * PI / count) * 150
collectible.position = Vector2(SPAWN_X + i * 80, arc_y)
2: # Zigzag
var zig_y = 750 if i % 2 == 0 else 650
collectible.position = Vector2(SPAWN_X + i * 60, zig_y)
collectible_container.add_child(collectible)
func _create_collectible() -> Area2D:
var collectible = Area2D.new()
collectible.collision_layer = 4
collectible.collision_mask = 1
var shape = CollisionShape2D.new()
var circle = CircleShape2D.new()
circle.radius = 15
shape.shape = circle
collectible.add_child(shape)
var visual = ColorRect.new()
visual.size = Vector2(30, 30)
visual.position = Vector2(-15, -15)
visual.color = Color(1, 0.8, 0, 1)
collectible.add_child(visual)
# Rotation animation
var tween = create_tween().set_loops()
tween.tween_property(visual, "rotation", TAU, 1.0)
collectible.body_entered.connect(_on_collectible_collected.bind(collectible))
return collectible
func _on_obstacle_hit(body: Node2D) -> void:
if body is Player:
body.hit_obstacle()
func _on_collectible_collected(body: Node2D, collectible: Area2D) -> void:
if body is Player:
body.collect_item(100, 0.1)
# Collection effect
var tween = create_tween()
tween.tween_property(collectible, "scale", Vector2(1.5, 1.5), 0.1)
tween.parallel().tween_property(collectible, "modulate:a", 0.0, 0.1)
await tween.finished
collectible.queue_free()
func reset() -> void:
# Clear all obstacles and collectibles
for child in obstacle_container.get_children():
child.queue_free()
for child in collectible_container.get_children():
child.queue_free()
spawn_timer = 1.0
current_spawn_interval = max_spawn_interval
func _on_game_started() -> void:
reset()
func _create_obstacle_scene() -> void:
# Scenes created programmatically in _create_obstacle()
pass
func _create_collectible_scene() -> void:
# Scenes created programmatically in _create_collectible()
pass

View file

@ -0,0 +1,149 @@
extends CharacterBody2D
class_name Player
## Neon Runner Player Controller
signal died
signal jumped
signal landed
signal started_slide
signal ended_slide
const GRAVITY: float = 2000.0
const JUMP_VELOCITY: float = -800.0
const DOUBLE_JUMP_VELOCITY: float = -600.0
const MAX_JUMPS: int = 2
@onready var sprite: ColorRect = $Sprite
@onready var collision_standing: CollisionShape2D = $CollisionShape2D
@onready var collision_sliding: CollisionShape2D = $SlideCollision
@onready var trail_particles: GPUParticles2D = $TrailParticles
@onready var jump_particles: GPUParticles2D = $JumpParticles
var jumps_remaining: int = MAX_JUMPS
var is_sliding: bool = false
var is_dead: bool = false
var original_sprite_size: Vector2
func _ready() -> void:
original_sprite_size = sprite.size
GameManager.game_started.connect(_on_game_started)
GameManager.game_over.connect(_on_game_over)
func _physics_process(delta: float) -> void:
if is_dead:
return
if not GameManager.is_playing:
return
# Apply gravity
velocity.y += GRAVITY * delta
# Handle jump input
if Input.is_action_just_pressed("jump") and jumps_remaining > 0:
_jump()
# Handle slide input
if Input.is_action_pressed("slide") and is_on_floor() and not is_sliding:
_start_slide()
elif Input.is_action_just_released("slide") and is_sliding:
_end_slide()
# Auto end slide if in air
if is_sliding and not is_on_floor():
_end_slide()
move_and_slide()
# Check if landed
if is_on_floor() and velocity.y >= 0:
if jumps_remaining < MAX_JUMPS:
landed.emit()
jumps_remaining = MAX_JUMPS
# Update trail particles
trail_particles.emitting = GameManager.is_playing and not is_dead
func _jump() -> void:
var jump_power = JUMP_VELOCITY if jumps_remaining == MAX_JUMPS else DOUBLE_JUMP_VELOCITY
velocity.y = jump_power
jumps_remaining -= 1
jumped.emit()
# Visual feedback
jump_particles.restart()
jump_particles.emitting = true
# Squash and stretch
var tween = create_tween()
tween.tween_property(sprite, "scale", Vector2(0.8, 1.3), 0.1)
tween.tween_property(sprite, "scale", Vector2(1.0, 1.0), 0.2)
if is_sliding:
_end_slide()
func _start_slide() -> void:
is_sliding = true
collision_standing.disabled = true
collision_sliding.disabled = false
collision_sliding.visible = true
# Squash sprite for slide
var tween = create_tween()
tween.tween_property(sprite, "scale", Vector2(1.5, 0.4), 0.1)
tween.parallel().tween_property(sprite, "position:y", -20, 0.1)
started_slide.emit()
func _end_slide() -> void:
is_sliding = false
collision_standing.disabled = false
collision_sliding.disabled = true
collision_sliding.visible = false
# Return sprite to normal
var tween = create_tween()
tween.tween_property(sprite, "scale", Vector2(1.0, 1.0), 0.15)
tween.parallel().tween_property(sprite, "position:y", 0, 0.15)
ended_slide.emit()
func hit_obstacle() -> void:
if is_dead:
return
is_dead = true
trail_particles.emitting = false
# Death animation
var tween = create_tween()
tween.tween_property(sprite, "modulate", Color(1, 0, 0, 1), 0.1)
tween.tween_property(self, "rotation", PI * 2, 0.5)
tween.parallel().tween_property(self, "position:y", position.y - 100, 0.3)
tween.tween_property(self, "position:y", position.y + 500, 0.5)
died.emit()
GameManager.end_game()
func collect_item(value: int, multiplier_bonus: float = 0.0) -> void:
GameManager.add_score(value)
if multiplier_bonus > 0:
GameManager.add_multiplier(multiplier_bonus)
# Flash effect
var original_color = sprite.color
sprite.color = Color(1, 1, 0, 1)
await get_tree().create_timer(0.1).timeout
sprite.color = original_color
func _on_game_started() -> void:
is_dead = false
jumps_remaining = MAX_JUMPS
is_sliding = false
rotation = 0
sprite.modulate = Color.WHITE
sprite.scale = Vector2.ONE
position = Vector2(300, 800)
func _on_game_over() -> void:
trail_particles.emitting = false

View file

@ -0,0 +1,98 @@
extends Control
## Game UI - HUD, pause menu, and game over screen
@onready var score_label: Label = $ScoreLabel
@onready var multiplier_label: Label = $MultiplierLabel
@onready var pause_menu: Control = $PauseMenu
@onready var game_over_menu: Control = $GameOverMenu
@onready var resume_button: Button = $PauseMenu/VBox/ResumeButton
@onready var restart_button: Button = $PauseMenu/VBox/RestartButton
@onready var menu_button_pause: Button = $PauseMenu/VBox/MenuButton
@onready var final_score_label: Label = $GameOverMenu/VBox/FinalScoreLabel
@onready var high_score_label: Label = $GameOverMenu/VBox/HighScoreLabel
@onready var new_high_score_label: Label = $GameOverMenu/VBox/NewHighScore
@onready var retry_button: Button = $GameOverMenu/VBox/RetryButton
@onready var menu_button_gameover: Button = $GameOverMenu/VBox/MenuButton
var previous_high_score: int = 0
func _ready() -> void:
# Connect GameManager signals
GameManager.score_changed.connect(_on_score_changed)
GameManager.multiplier_changed.connect(_on_multiplier_changed)
GameManager.game_over.connect(_on_game_over)
GameManager.game_started.connect(_on_game_started)
# Connect pause menu buttons
resume_button.pressed.connect(_on_resume_pressed)
restart_button.pressed.connect(_on_restart_pressed)
menu_button_pause.pressed.connect(_on_menu_pressed)
# Connect game over buttons
retry_button.pressed.connect(_on_restart_pressed)
menu_button_gameover.pressed.connect(_on_menu_pressed)
# Store initial high score
previous_high_score = GameManager.high_score
func _process(_delta: float) -> void:
# Update pause menu visibility
pause_menu.visible = GameManager.is_paused
func _on_score_changed(new_score: int) -> void:
score_label.text = str(new_score)
# Pulse animation on score change
var tween = create_tween()
tween.tween_property(score_label, "scale", Vector2(1.2, 1.2), 0.05)
tween.tween_property(score_label, "scale", Vector2(1.0, 1.0), 0.1)
func _on_multiplier_changed(multiplier: float) -> void:
multiplier_label.text = "x%.1f" % multiplier
# Color based on multiplier
if multiplier >= 3.0:
multiplier_label.modulate = Color(1, 0.2, 0.6, 1) # Pink/hot
elif multiplier >= 2.0:
multiplier_label.modulate = Color(1, 0.5, 0, 1) # Orange
else:
multiplier_label.modulate = Color(1, 0.8, 0, 1) # Gold
func _on_game_over() -> void:
await get_tree().create_timer(1.0).timeout
final_score_label.text = "SCORE: %d" % GameManager.score
high_score_label.text = "HIGH SCORE: %d" % GameManager.high_score
# Check for new high score
new_high_score_label.visible = GameManager.score > previous_high_score and GameManager.score == GameManager.high_score
# Animate game over menu
game_over_menu.modulate.a = 0.0
game_over_menu.visible = true
var tween = create_tween()
tween.tween_property(game_over_menu, "modulate:a", 1.0, 0.5)
func _on_game_started() -> void:
game_over_menu.visible = false
pause_menu.visible = false
previous_high_score = GameManager.high_score
# Reset UI
score_label.text = "0"
multiplier_label.text = "x1.0"
multiplier_label.modulate = Color(1, 0.8, 0, 1)
func _on_resume_pressed() -> void:
GameManager.resume_game()
func _on_restart_pressed() -> void:
GameManager.resume_game() # Unpause if paused
get_parent().get_parent().restart_game()
func _on_menu_pressed() -> void:
GameManager.resume_game() # Unpause if paused
get_parent().get_parent().return_to_menu()

View file

@ -0,0 +1,40 @@
extends Control
## Main Menu Controller
@onready var play_button: Button = $VBox/PlayButton
@onready var quit_button: Button = $VBox/QuitButton
@ontml var high_score_label: Label = $VBox/HighScoreLabel
@onready var title: Label = $VBox/Title
var title_tween: Tween
func _ready() -> void:
play_button.pressed.connect(_on_play_pressed)
quit_button.pressed.connect(_on_quit_pressed)
# Update high score display
high_score_label.text = "HIGH SCORE: %d" % GameManager.high_score
GameManager.high_score_changed.connect(_on_high_score_changed)
# Animate title with neon glow effect
_animate_title()
func _animate_title() -> void:
title_tween = create_tween().set_loops()
title_tween.tween_property(title, "theme_override_colors/font_color",
Color(0, 1, 1, 1), 1.0)
title_tween.tween_property(title, "theme_override_colors/font_color",
Color(1, 0.2, 0.6, 1), 1.0)
func _on_play_pressed() -> void:
# Transition to game
var tween = create_tween()
tween.tween_property(self, "modulate:a", 0.0, 0.5)
await tween.finished
get_tree().change_scene_to_file("res://scenes/game.tscn")
func _on_quit_pressed() -> void:
get_tree().quit()
func _on_high_score_changed(new_score: int) -> void:
high_score_label.text = "HIGH SCORE: %d" % new_score

View file

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<radialGradient id="void_grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color:#1a1a2e"/>
<stop offset="100%" style="stop-color:#000008"/>
</radialGradient>
</defs>
<rect width="128" height="128" fill="url(#void_grad)"/>
<!-- Stars -->
<circle cx="20" cy="30" r="1" fill="white" opacity="0.8"/>
<circle cx="100" cy="20" r="1.5" fill="white" opacity="0.6"/>
<circle cx="45" cy="15" r="1" fill="white" opacity="0.9"/>
<circle cx="80" cy="50" r="1" fill="white" opacity="0.7"/>
<circle cx="110" cy="80" r="1" fill="white" opacity="0.5"/>
<circle cx="15" cy="90" r="1.5" fill="white" opacity="0.8"/>
<!-- Station silhouette -->
<rect x="30" y="55" width="68" height="25" rx="3" fill="#2a2a4a"/>
<rect x="45" y="45" width="38" height="40" rx="2" fill="#3a3a5a"/>
<rect x="55" y="40" width="18" height="50" rx="2" fill="#4a4a6a"/>
<!-- Windows -->
<rect x="48" y="50" width="6" height="6" fill="#4a8fff"/>
<rect x="58" y="50" width="6" height="6" fill="#4a8fff"/>
<rect x="68" y="50" width="6" height="6" fill="#ff4a4a"/>
<!-- Antenna -->
<line x1="64" y1="40" x2="64" y2="25" stroke="#6a6a8a" stroke-width="2"/>
<circle cx="64" cy="23" r="3" fill="#ff4a4a"/>
<text x="64" y="115" font-family="Arial" font-size="10" fill="#4a8fff" text-anchor="middle">VOID</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,96 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Void Explorer"
config/description="A first-person space station exploration game. Investigate an abandoned station, solve environmental puzzles, and uncover what happened to the crew."
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
[autoload]
GameState="*res://scripts/autoload/game_state.gd"
SaveManager="*res://scripts/autoload/save_manager.gd"
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
[input]
move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
move_back={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
interact={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null)
]
}
flashlight={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
]
}
sprint={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
crouch={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
journal={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
]
}
pause={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[layer_names]
3d_physics/layer_1="player"
3d_physics/layer_2="world"
3d_physics/layer_3="interactables"
3d_physics/layer_4="triggers"
[rendering]
renderer/rendering_method="forward_plus"
lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3
environment/defaults/default_clear_color=Color(0.01, 0.01, 0.02, 1)
anti_aliasing/quality/msaa_3d=2

View file

@ -0,0 +1,101 @@
[gd_scene load_steps=2 format=3 uid="uid://void_main_menu"]
[ext_resource type="Script" path="res://scripts/ui/main_menu.gd" id="1_menu"]
[node name="MainMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_menu")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.01, 0.01, 0.02, 1)
[node name="Stars" type="Control" parent="Background"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -200.0
offset_top = -180.0
offset_right = 200.0
offset_bottom = 180.0
theme_override_constants/separation = 15
[node name="Title" type="Label" parent="VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.4, 0.6, 1, 1)
theme_override_colors/font_outline_color = Color(0.1, 0.2, 0.4, 1)
theme_override_constants/outline_size = 3
theme_override_font_sizes/font_size = 64
text = "VOID EXPLORER"
horizontal_alignment = 1
[node name="Subtitle" type="Label" parent="VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.6, 0.7, 0.9, 0.7)
theme_override_font_sizes/font_size = 16
text = "What happened to the crew of Station Erebus?"
horizontal_alignment = 1
[node name="Spacer" type="Control" parent="VBox"]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
[node name="NewGameButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(300, 50)
layout_mode = 2
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 22
text = "NEW GAME"
[node name="ContinueButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(300, 50)
layout_mode = 2
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 22
text = "CONTINUE"
[node name="SettingsButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(250, 40)
layout_mode = 2
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 18
text = "SETTINGS"
[node name="QuitButton" type="Button" parent="VBox"]
custom_minimum_size = Vector2(200, 40)
layout_mode = 2
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 18
text = "QUIT"
[node name="Version" type="Label" parent="."]
layout_mode = 1
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -150.0
offset_top = -30.0
offset_right = -10.0
grow_horizontal = 0
grow_vertical = 0
theme_override_colors/font_color = Color(1, 1, 1, 0.3)
theme_override_font_sizes/font_size = 12
text = "Made with AeThex Engine"
horizontal_alignment = 2

View file

@ -0,0 +1,46 @@
[gd_scene load_steps=3 format=3 uid="uid://void_player"]
[ext_resource type="Script" path="res://scripts/player/player_controller.gd" id="1_player"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_standing"]
height = 1.8
radius = 0.4
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_crouch"]
height = 1.0
radius = 0.4
[node name="Player" type="CharacterBody3D"]
collision_layer = 1
collision_mask = 2
script = ExtResource("1_player")
[node name="StandingCollision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
shape = SubResource("CapsuleShape3D_standing")
[node name="CrouchCollision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
disabled = true
shape = SubResource("CapsuleShape3D_crouch")
[node name="Head" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.6, 0)
[node name="Camera3D" type="Camera3D" parent="Head"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
fov = 75.0
[node name="Flashlight" type="SpotLight3D" parent="Head/Camera3D"]
visible = false
light_color = Color(0.95, 0.95, 1, 1)
light_energy = 2.0
light_indirect_energy = 0.0
shadow_enabled = true
spot_range = 20.0
spot_attenuation = 0.5
spot_angle = 25.0
[node name="InteractionRay" type="RayCast3D" parent="Head/Camera3D"]
target_position = Vector3(0, 0, -3)
collision_mask = 4

View file

@ -0,0 +1,188 @@
[gd_scene load_steps=6 format=3 uid="uid://void_station"]
[ext_resource type="PackedScene" uid="uid://void_player" path="res://scenes/player.tscn" id="1_player"]
[ext_resource type="Script" path="res://scripts/station/station_controller.gd" id="2_station"]
[ext_resource type="Script" path="res://scripts/ui/game_ui.gd" id="3_ui"]
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_space"]
sky_top_color = Color(0, 0, 0.02, 1)
sky_horizon_color = Color(0.01, 0.01, 0.05, 1)
ground_bottom_color = Color(0, 0, 0, 1)
ground_horizon_color = Color(0.01, 0.01, 0.03, 1)
[sub_resource type="Sky" id="Sky_space"]
sky_material = SubResource("ProceduralSkyMaterial_space")
[sub_resource type="Environment" id="Environment_station"]
background_mode = 2
sky = SubResource("Sky_space")
ambient_light_source = 1
ambient_light_color = Color(0.05, 0.05, 0.1, 1)
ambient_light_energy = 0.3
tonemap_mode = 2
glow_enabled = true
glow_intensity = 0.5
glow_bloom = 0.3
fog_enabled = true
fog_light_color = Color(0.02, 0.02, 0.05, 1)
fog_density = 0.01
volumetric_fog_enabled = true
volumetric_fog_density = 0.02
volumetric_fog_emission = Color(0.05, 0.05, 0.1, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(50, 1, 50)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
albedo_color = Color(0.1, 0.1, 0.15, 1)
metallic = 0.3
roughness = 0.7
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
material = SubResource("StandardMaterial3D_floor")
size = Vector3(50, 1, 50)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_wall"]
albedo_color = Color(0.15, 0.15, 0.2, 1)
metallic = 0.2
roughness = 0.8
[node name="Station" type="Node3D"]
script = ExtResource("2_station")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_station")
[node name="Player" parent="." instance=ExtResource("1_player")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
[node name="StationGeometry" type="Node3D" parent="."]
[node name="Floor" type="StaticBody3D" parent="StationGeometry"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
collision_layer = 2
[node name="CollisionShape3D" type="CollisionShape3D" parent="StationGeometry/Floor"]
shape = SubResource("BoxShape3D_floor")
[node name="MeshInstance3D" type="MeshInstance3D" parent="StationGeometry/Floor"]
mesh = SubResource("BoxMesh_floor")
[node name="Walls" type="Node3D" parent="StationGeometry"]
[node name="NorthWall" type="StaticBody3D" parent="StationGeometry/Walls"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, -25)
collision_layer = 2
[node name="CollisionShape3D" type="CollisionShape3D" parent="StationGeometry/Walls/NorthWall"]
shape = SubResource("BoxShape3D_wall")
[node name="MeshInstance3D" type="MeshInstance3D" parent="StationGeometry/Walls/NorthWall"]
mesh = SubResource("BoxMesh_wall")
[node name="SouthWall" type="StaticBody3D" parent="StationGeometry/Walls"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 25)
collision_layer = 2
[node name="EastWall" type="StaticBody3D" parent="StationGeometry/Walls"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 25, 2, 0)
collision_layer = 2
[node name="WestWall" type="StaticBody3D" parent="StationGeometry/Walls"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -25, 2, 0)
collision_layer = 2
[node name="Lighting" type="Node3D" parent="."]
[node name="EmergencyLight1" type="OmniLight3D" parent="Lighting"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 3, 5)
light_color = Color(1, 0.2, 0.1, 1)
light_energy = 0.5
omni_range = 8.0
[node name="EmergencyLight2" type="OmniLight3D" parent="Lighting"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 3, -5)
light_color = Color(1, 0.2, 0.1, 1)
light_energy = 0.5
omni_range = 8.0
[node name="ConsoleLight" type="OmniLight3D" parent="Lighting"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, -10)
light_color = Color(0.3, 0.6, 1, 1)
light_energy = 0.8
omni_range = 5.0
[node name="Interactables" type="Node3D" parent="."]
[node name="CanvasLayer" type="CanvasLayer" parent="."]
[node name="GameUI" type="Control" parent="CanvasLayer"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("3_ui")
[node name="Crosshair" type="ColorRect" parent="CanvasLayer/GameUI"]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -2.0
offset_top = -2.0
offset_right = 2.0
offset_bottom = 2.0
color = Color(1, 1, 1, 0.5)
[node name="InteractPrompt" type="Label" parent="CanvasLayer/GameUI"]
visible = false
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -100.0
offset_top = 30.0
offset_right = 100.0
offset_bottom = 60.0
theme_override_colors/font_color = Color(1, 1, 1, 0.8)
theme_override_font_sizes/font_size = 16
text = "[E] Interact"
horizontal_alignment = 1
[node name="ObjectivePanel" type="PanelContainer" parent="CanvasLayer/GameUI"]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -300.0
offset_top = 20.0
offset_right = -20.0
offset_bottom = 100.0
[node name="VBox" type="VBoxContainer" parent="CanvasLayer/GameUI/ObjectivePanel"]
layout_mode = 2
theme_override_constants/separation = 5
[node name="ObjectiveTitle" type="Label" parent="CanvasLayer/GameUI/ObjectivePanel/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.4, 0.6, 1, 1)
theme_override_font_sizes/font_size = 14
text = "OBJECTIVE"
[node name="ObjectiveText" type="Label" parent="CanvasLayer/GameUI/ObjectivePanel/VBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.8, 0.9, 1, 1)
theme_override_font_sizes/font_size = 16
text = "Explore the station"
autowrap_mode = 2
[sub_resource type="BoxShape3D" id="BoxShape3D_wall"]
size = Vector3(50, 5, 1)
[sub_resource type="BoxMesh" id="BoxMesh_wall"]
material = SubResource("StandardMaterial3D_wall")
size = Vector3(50, 5, 1)

View file

@ -0,0 +1,101 @@
extends Node
## Game State - Tracks exploration progress, inventory, and story state
signal item_collected(item_id: String)
signal log_discovered(log_id: String)
signal objective_updated(objective: String)
signal power_restored(section: String)
# Player inventory
var inventory: Array[String] = []
# Discovered logs/audio recordings
var discovered_logs: Array[String] = []
# Story progress flags
var story_flags: Dictionary = {
"intro_complete": false,
"found_first_log": false,
"power_section_a": false,
"power_section_b": false,
"power_section_c": false,
"found_keycard_red": false,
"found_keycard_blue": false,
"found_keycard_gold": false,
"opened_lab": false,
"opened_bridge": false,
"found_truth": false,
"game_complete": false,
}
# Current objectives
var current_objective: String = "Explore the station"
var completed_objectives: Array[String] = []
# Player stats
var total_logs: int = 12
var total_items: int = 15
func _ready() -> void:
pass
func has_item(item_id: String) -> bool:
return item_id in inventory
func add_item(item_id: String) -> void:
if item_id not in inventory:
inventory.append(item_id)
item_collected.emit(item_id)
func remove_item(item_id: String) -> void:
inventory.erase(item_id)
func discover_log(log_id: String) -> void:
if log_id not in discovered_logs:
discovered_logs.append(log_id)
log_discovered.emit(log_id)
if not story_flags["found_first_log"]:
set_flag("found_first_log", true)
set_objective("Find more crew logs to piece together what happened")
func set_flag(flag: String, value: bool) -> void:
if flag in story_flags:
story_flags[flag] = value
# Check for power restoration
if flag.begins_with("power_section_"):
power_restored.emit(flag.replace("power_section_", ""))
_check_all_power()
func get_flag(flag: String) -> bool:
return story_flags.get(flag, false)
func set_objective(objective: String) -> void:
if current_objective != "":
completed_objectives.append(current_objective)
current_objective = objective
objective_updated.emit(objective)
func _check_all_power() -> void:
if story_flags["power_section_a"] and story_flags["power_section_b"] and story_flags["power_section_c"]:
set_objective("All power restored - Access the bridge")
func get_completion_percentage() -> float:
var logs = discovered_logs.size() / float(total_logs)
var items = inventory.size() / float(total_items)
var flags = 0
for flag in story_flags.values():
if flag:
flags += 1
var flag_percent = flags / float(story_flags.size())
return (logs + items + flag_percent) / 3.0 * 100.0
func reset() -> void:
inventory.clear()
discovered_logs.clear()
for flag in story_flags.keys():
story_flags[flag] = false
current_objective = "Explore the station"
completed_objectives.clear()

View file

@ -0,0 +1,93 @@
extends Node
## Save Manager - Handles game saves and loads
const SAVE_PATH = "user://void_explorer_save.json"
const SETTINGS_PATH = "user://void_explorer_settings.json"
signal save_completed
signal load_completed
var settings: Dictionary = {
"mouse_sensitivity": 0.3,
"invert_y": false,
"master_volume": 1.0,
"music_volume": 0.8,
"sfx_volume": 1.0,
"graphics_quality": 2, # 0=Low, 1=Medium, 2=High
}
func _ready() -> void:
load_settings()
func save_game(player_position: Vector3, player_rotation: Vector3) -> void:
var save_data = {
"version": 1,
"timestamp": Time.get_datetime_string_from_system(),
"player": {
"position": {"x": player_position.x, "y": player_position.y, "z": player_position.z},
"rotation": {"x": player_rotation.x, "y": player_rotation.y, "z": player_rotation.z},
},
"inventory": GameState.inventory,
"discovered_logs": GameState.discovered_logs,
"story_flags": GameState.story_flags,
"current_objective": GameState.current_objective,
"completed_objectives": GameState.completed_objectives,
}
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(save_data, "\t"))
save_completed.emit()
func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if not file:
return {}
var json = JSON.new()
var error = json.parse(file.get_as_text())
if error != OK:
return {}
var save_data = json.get_data()
# Restore game state
GameState.inventory = save_data.get("inventory", [])
GameState.discovered_logs = save_data.get("discovered_logs", [])
GameState.story_flags = save_data.get("story_flags", GameState.story_flags)
GameState.current_objective = save_data.get("current_objective", "Explore the station")
GameState.completed_objectives = save_data.get("completed_objectives", [])
load_completed.emit()
return save_data
func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)
func delete_save() -> void:
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)
func save_settings() -> void:
var file = FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(settings, "\t"))
func load_settings() -> void:
if not FileAccess.file_exists(SETTINGS_PATH):
return
var file = FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if not file:
return
var json = JSON.new()
var error = json.parse(file.get_as_text())
if error == OK:
var loaded = json.get_data()
for key in loaded.keys():
if key in settings:
settings[key] = loaded[key]

View file

@ -0,0 +1,149 @@
extends CharacterBody3D
class_name VoidPlayer
## First-person player controller for space station exploration
signal interacted_with(object: Node3D)
const WALK_SPEED: float = 4.0
const SPRINT_SPEED: float = 7.0
const CROUCH_SPEED: float = 2.0
const JUMP_VELOCITY: float = 4.5
const GRAVITY: float = 9.8
@export var mouse_sensitivity: float = 0.3
@onready var head: Node3D = $Head
@onready var camera: Camera3D = $Head/Camera3D
@onready var flashlight: SpotLight3D = $Head/Camera3D/Flashlight
@onready var interaction_ray: RayCast3D = $Head/Camera3D/InteractionRay
@onready var standing_collision: CollisionShape3D = $StandingCollision
@onready var crouch_collision: CollisionShape3D = $CrouchCollision
var current_speed: float = WALK_SPEED
var is_crouching: bool = false
var flashlight_on: bool = false
var can_interact: bool = false
var interaction_target: Node3D = null
# Head bob variables
var head_bob_timer: float = 0.0
var head_bob_amount: float = 0.05
var head_bob_frequency: float = 2.0
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
flashlight.visible = false
# Apply settings
mouse_sensitivity = SaveManager.settings.get("mouse_sensitivity", 0.3)
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
var invert = -1 if SaveManager.settings.get("invert_y", false) else 1
rotate_y(deg_to_rad(-event.relative.x * mouse_sensitivity))
head.rotate_x(deg_to_rad(-event.relative.y * mouse_sensitivity * invert))
head.rotation.x = clamp(head.rotation.x, deg_to_rad(-89), deg_to_rad(89))
if event.is_action_pressed("flashlight"):
_toggle_flashlight()
if event.is_action_pressed("interact") and can_interact and interaction_target:
_interact()
func _physics_process(delta: float) -> void:
# Gravity
if not is_on_floor():
velocity.y -= GRAVITY * delta
# Handle sprint
if Input.is_action_pressed("sprint") and not is_crouching:
current_speed = SPRINT_SPEED
elif is_crouching:
current_speed = CROUCH_SPEED
else:
current_speed = WALK_SPEED
# Handle crouch
if Input.is_action_just_pressed("crouch"):
_start_crouch()
elif Input.is_action_just_released("crouch"):
_end_crouch()
# Get input direction
var input_dir = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
# Apply movement
if direction:
velocity.x = direction.x * current_speed
velocity.z = direction.z * current_speed
# Head bob when moving
if is_on_floor():
_apply_head_bob(delta)
else:
velocity.x = move_toward(velocity.x, 0, current_speed)
velocity.z = move_toward(velocity.z, 0, current_speed)
move_and_slide()
# Check for interactables
_check_interaction()
func _apply_head_bob(delta: float) -> void:
head_bob_timer += delta * head_bob_frequency * (current_speed / WALK_SPEED)
var bob_offset = sin(head_bob_timer) * head_bob_amount
camera.position.y = 0.6 + bob_offset
func _check_interaction() -> void:
if interaction_ray.is_colliding():
var collider = interaction_ray.get_collider()
if collider.is_in_group("interactable"):
can_interact = true
interaction_target = collider
return
can_interact = false
interaction_target = null
func _interact() -> void:
if interaction_target and interaction_target.has_method("interact"):
interaction_target.interact(self)
interacted_with.emit(interaction_target)
func _toggle_flashlight() -> void:
flashlight_on = not flashlight_on
flashlight.visible = flashlight_on
# Flashlight flicker effect on toggle
if flashlight_on:
var tween = create_tween()
tween.tween_property(flashlight, "light_energy", 0.0, 0.05)
tween.tween_property(flashlight, "light_energy", 2.0, 0.05)
tween.tween_property(flashlight, "light_energy", 0.5, 0.05)
tween.tween_property(flashlight, "light_energy", 2.0, 0.1)
func _start_crouch() -> void:
is_crouching = true
standing_collision.disabled = true
crouch_collision.disabled = false
var tween = create_tween()
tween.tween_property(head, "position:y", 0.8, 0.2)
func _end_crouch() -> void:
# Check if there's room to stand
# Simplified - just uncrouch
is_crouching = false
standing_collision.disabled = false
crouch_collision.disabled = true
var tween = create_tween()
tween.tween_property(head, "position:y", 1.6, 0.2)
func collect_item(item_id: String) -> void:
GameState.add_item(item_id)
func read_log(log_id: String, content: String) -> void:
GameState.discover_log(log_id)
# UI should display the log content

View file

@ -0,0 +1,164 @@
extends Node3D
## Station Controller - Manages the space station environment
@onready var player: VoidPlayer = $Player
@onready var lighting: Node3D = $Lighting
@onready var interactables: Node3D = $Interactables
var emergency_lights: Array[OmniLight3D] = []
var emergency_light_timer: float = 0.0
func _ready() -> void:
# Collect emergency lights
for child in lighting.get_children():
if child is OmniLight3D and "Emergency" in child.name:
emergency_lights.append(child)
# Connect game state signals
GameState.power_restored.connect(_on_power_restored)
# Set up procedural elements
_spawn_interactables()
# Initialize atmosphere
_setup_atmosphere()
func _process(delta: float) -> void:
# Emergency light flicker
emergency_light_timer += delta
if emergency_light_timer > 0.5:
emergency_light_timer = 0.0
_flicker_emergency_lights()
func _flicker_emergency_lights() -> void:
for light in emergency_lights:
if randf() > 0.7:
var original = light.light_energy
light.light_energy = 0.0
await get_tree().create_timer(randf_range(0.05, 0.15)).timeout
light.light_energy = original
func _spawn_interactables() -> void:
# Spawn crew logs
_spawn_log(Vector3(5, 1, -8), "log_001", "Commander's Log - Day 1",
"Station Erebus is now fully operational. The crew is in good spirits. We've begun our research into the anomaly detected in sector 7...")
_spawn_log(Vector3(-8, 1, 3), "log_002", "Engineer's Notes",
"The power fluctuations are getting worse. I've rerouted auxiliary power but something is draining the main reactor...")
_spawn_log(Vector3(12, 1, 12), "log_003", "Dr. Chen's Research Notes",
"The samples we collected from the anomaly... they're not behaving like normal matter. They seem to react to human presence...")
# Spawn keycards
_spawn_keycard(Vector3(-5, 1, -12), "keycard_red", Color.RED)
_spawn_keycard(Vector3(15, 1, 8), "keycard_blue", Color.BLUE)
# Spawn power switches
_spawn_power_switch(Vector3(20, 1.5, 0), "power_section_a", "Section A")
_spawn_power_switch(Vector3(-20, 1.5, 10), "power_section_b", "Section B")
_spawn_power_switch(Vector3(0, 1.5, 20), "power_section_c", "Section C")
func _spawn_log(pos: Vector3, log_id: String, title: String, content: String) -> void:
var log_obj = _create_interactable_base(pos, Color(0.2, 0.4, 0.8))
log_obj.name = "Log_" + log_id
log_obj.set_meta("log_id", log_id)
log_obj.set_meta("title", title)
log_obj.set_meta("content", content)
log_obj.set_meta("type", "log")
# Add glow
var light = OmniLight3D.new()
light.light_color = Color(0.2, 0.4, 1.0)
light.light_energy = 0.5
light.omni_range = 2.0
log_obj.add_child(light)
interactables.add_child(log_obj)
func _spawn_keycard(pos: Vector3, item_id: String, color: Color) -> void:
var card = _create_interactable_base(pos, color)
card.name = "Keycard_" + item_id
card.set_meta("item_id", item_id)
card.set_meta("type", "keycard")
card.scale = Vector3(0.3, 0.05, 0.2)
interactables.add_child(card)
func _spawn_power_switch(pos: Vector3, flag_id: String, label: String) -> void:
var switch_obj = _create_interactable_base(pos, Color(0.8, 0.2, 0.2))
switch_obj.name = "Switch_" + flag_id
switch_obj.set_meta("flag_id", flag_id)
switch_obj.set_meta("label", label)
switch_obj.set_meta("type", "switch")
switch_obj.set_meta("activated", false)
switch_obj.scale = Vector3(0.3, 0.5, 0.1)
interactables.add_child(switch_obj)
func _create_interactable_base(pos: Vector3, color: Color) -> StaticBody3D:
var body = StaticBody3D.new()
body.position = pos
body.collision_layer = 4
body.add_to_group("interactable")
var shape = CollisionShape3D.new()
var box = BoxShape3D.new()
box.size = Vector3(0.5, 0.5, 0.5)
shape.shape = box
body.add_child(shape)
var mesh_instance = MeshInstance3D.new()
var mesh = BoxMesh.new()
mesh.size = Vector3(0.5, 0.5, 0.5)
var material = StandardMaterial3D.new()
material.albedo_color = color
material.emission_enabled = true
material.emission = color * 0.3
material.emission_energy_multiplier = 0.5
mesh.material = material
mesh_instance.mesh = mesh
body.add_child(mesh_instance)
# Add interact method via script
var script = GDScript.new()
script.source_code = """
extends StaticBody3D
func interact(player):
var obj_type = get_meta("type", "")
match obj_type:
"log":
player.read_log(get_meta("log_id"), get_meta("content"))
# Visual feedback
var tween = create_tween()
tween.tween_property(self, "scale", scale * 1.2, 0.1)
tween.tween_property(self, "scale", scale, 0.1)
"keycard":
player.collect_item(get_meta("item_id"))
queue_free()
"switch":
if not get_meta("activated"):
set_meta("activated", true)
GameState.set_flag(get_meta("flag_id"), true)
# Turn green
$MeshInstance3D.mesh.material.albedo_color = Color.GREEN
$MeshInstance3D.mesh.material.emission = Color.GREEN * 0.5
"""
body.set_script(script)
return body
func _on_power_restored(section: String) -> void:
# Add lights for restored section
print("Power restored to: ", section)
func _setup_atmosphere() -> void:
# Add ambient sounds, particles, etc.
pass
func _input(event: InputEvent) -> void:
if event.is_action_pressed("pause"):
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
else:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED

View file

@ -0,0 +1,68 @@
extends Control
## Game UI for Void Explorer
@onready var crosshair: ColorRect = $Crosshair
@onready var interact_prompt: Label = $InteractPrompt
@onready var objective_text: Label = $ObjectivePanel/VBox/ObjectiveText
var player: VoidPlayer
func _ready() -> void:
GameState.objective_updated.connect(_on_objective_updated)
GameState.item_collected.connect(_on_item_collected)
GameState.log_discovered.connect(_on_log_discovered)
# Get player reference
await get_tree().process_frame
player = get_tree().get_first_node_in_group("player")
if not player:
var players = get_tree().get_nodes_in_group("player")
if players.size() > 0:
player = players[0]
# Update objective
objective_text.text = GameState.current_objective
func _process(_delta: float) -> void:
if player:
# Update interact prompt
interact_prompt.visible = player.can_interact
# Update crosshair color based on interaction
if player.can_interact:
crosshair.color = Color(0.4, 0.8, 1, 0.8)
else:
crosshair.color = Color(1, 1, 1, 0.5)
func _on_objective_updated(objective: String) -> void:
objective_text.text = objective
# Flash effect
var original_color = objective_text.modulate
objective_text.modulate = Color.YELLOW
var tween = create_tween()
tween.tween_property(objective_text, "modulate", original_color, 0.5)
func _on_item_collected(item_id: String) -> void:
# Show pickup notification
_show_notification("Collected: " + item_id.replace("_", " ").capitalize())
func _on_log_discovered(log_id: String) -> void:
_show_notification("New Log Discovered")
func _show_notification(text: String) -> void:
var notification = Label.new()
notification.text = text
notification.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
notification.add_theme_font_size_override("font_size", 24)
notification.add_theme_color_override("font_color", Color(0.4, 0.8, 1, 1))
notification.position = Vector2(get_viewport_rect().size.x / 2 - 150, get_viewport_rect().size.y - 150)
notification.size = Vector2(300, 40)
notification.modulate.a = 0.0
add_child(notification)
var tween = create_tween()
tween.tween_property(notification, "modulate:a", 1.0, 0.3)
tween.tween_interval(2.0)
tween.tween_property(notification, "modulate:a", 0.0, 0.5)
tween.tween_callback(notification.queue_free)

View file

@ -0,0 +1,63 @@
extends Control
## Main Menu for Void Explorer
@onready var new_game_button: Button = $VBox/NewGameButton
@onready var continue_button: Button = $VBox/ContinueButton
@onready var settings_button: Button = $VBox/SettingsButton
@onready var quit_button: Button = $VBox/QuitButton
@onready var stars_container: Control = $Background/Stars
var star_positions: Array[Vector2] = []
func _ready() -> void:
new_game_button.pressed.connect(_on_new_game)
continue_button.pressed.connect(_on_continue)
settings_button.pressed.connect(_on_settings)
quit_button.pressed.connect(_on_quit)
# Enable/disable continue based on save existence
continue_button.disabled = not SaveManager.has_save()
# Generate stars
_generate_stars()
# Fade in
modulate.a = 0.0
var tween = create_tween()
tween.tween_property(self, "modulate:a", 1.0, 1.0)
func _generate_stars() -> void:
var viewport_size = get_viewport_rect().size
for i in 100:
var star = ColorRect.new()
star.size = Vector2(2, 2) if randf() > 0.7 else Vector2(1, 1)
star.position = Vector2(randf() * viewport_size.x, randf() * viewport_size.y)
star.color = Color(1, 1, 1, randf_range(0.3, 1.0))
stars_container.add_child(star)
star_positions.append(star.position)
# Twinkle animation
var tween = create_tween().set_loops()
tween.tween_property(star, "modulate:a", randf_range(0.3, 0.7), randf_range(1.0, 3.0))
tween.tween_property(star, "modulate:a", 1.0, randf_range(1.0, 3.0))
func _on_new_game() -> void:
GameState.reset()
_transition_to_game()
func _on_continue() -> void:
SaveManager.load_game()
_transition_to_game()
func _transition_to_game() -> void:
var tween = create_tween()
tween.tween_property(self, "modulate:a", 0.0, 0.5)
await tween.finished
get_tree().change_scene_to_file("res://scenes/station.tscn")
func _on_settings() -> void:
# TODO: Open settings panel
pass
func _on_quit() -> void:
get_tree().quit()