Initial MVP

This commit is contained in:
Aodhan Collins
2026-01-26 02:57:40 +00:00
commit b42521a008
33 changed files with 1004 additions and 0 deletions

11
scripts/AudioManager.gd Normal file
View File

@@ -0,0 +1,11 @@
extends Node
# Load streams here
# var sfx_hit = preload("res://assets/hit.wav")
func play_hit():
# if sfx_hit: play(sfx_hit)
print("Audio: Hit SFX")
func play_music():
print("Audio: Music Started")

View File

@@ -0,0 +1 @@
uid://c5ge63rwtjieh

221
scripts/GameManager.gd Normal file
View File

@@ -0,0 +1,221 @@
extends Node
signal room_changed(room_data: RoomData)
signal log_message(message: String)
signal stats_changed(hp: int, max_hp: int, inventory: Array)
signal combat_started(enemy: Resource)
signal combat_ended(won: bool)
signal combat_log(message: String)
signal damage_taken(amount: int)
var game_state: GameState
var rooms: Dictionary = {} # id -> RoomData
var llm_service: LLMService
var audio_manager: Node
var current_enemy: Resource = null
func _ready():
llm_service = LLMService.new()
add_child(llm_service)
llm_service.response_received.connect(_on_llm_response)
llm_service.error_occurred.connect(func(msg): emit_signal("log_message", "Error: " + msg))
audio_manager = load("res://scripts/AudioManager.gd").new()
add_child(audio_manager)
rooms = WorldData.generate_test_world()
start_new_game()
func start_new_game():
game_state = GameState.new()
game_state.current_room_id = "room_0_0"
_update_stats_ui()
_load_room(game_state.current_room_id)
func _load_room(room_id: String):
if room_id in rooms:
var room = rooms[room_id]
# Update Exploration
if not room_id in game_state.explored_rooms:
game_state.explored_rooms.append(room_id)
emit_signal("room_changed", room)
# Handle Description
if room.generated_description != "":
emit_signal("log_message", room.generated_description)
else:
_request_room_description(room)
else:
emit_signal("log_message", "Error: Room " + room_id + " not found.")
func move(direction: String):
var current_room = rooms.get(game_state.current_room_id)
if current_room and direction in current_room.exits:
game_state.current_room_id = current_room.exits[direction]
_load_room(game_state.current_room_id)
else:
emit_signal("log_message", "You can't go that way.")
func pickup_item(item_name: String):
var current_room = rooms[game_state.current_room_id]
var item_to_pickup = null
for item in current_room.items:
if item.name.to_lower() in item_name.to_lower() or item.id == item_name.to_lower():
item_to_pickup = item
break
if item_to_pickup:
current_room.items.erase(item_to_pickup)
game_state.inventory.append(item_to_pickup)
_update_stats_ui()
emit_signal("log_message", "You picked up " + item_to_pickup.name)
else:
emit_signal("log_message", "There is no " + item_name + " here.")
func save_game():
var error = ResourceSaver.save(game_state, "user://savegame.tres")
if error == OK:
emit_signal("log_message", "Game Saved.")
else:
emit_signal("log_message", "Error saving game: " + str(error))
func load_game():
if ResourceLoader.exists("user://savegame.tres"):
game_state = ResourceLoader.load("user://savegame.tres")
_update_stats_ui()
_load_room(game_state.current_room_id)
emit_signal("log_message", "Game Loaded.")
else:
emit_signal("log_message", "No save file found.")
func _update_stats_ui():
emit_signal("stats_changed", game_state.player_hp, game_state.player_max_hp, game_state.inventory)
# --- Combat System ---
func start_combat(enemy: Resource):
current_enemy = enemy
emit_signal("combat_started", enemy)
emit_signal("log_message", "Combat started with " + enemy.name + "!")
func player_attack():
if not current_enemy: return
# Calculate damage (simple for now)
var damage = 5 # Base damage
# Check for weapon
for item in game_state.inventory:
if item.effect_type == "DAMAGE":
damage += item.effect_value
current_enemy.hp -= damage
audio_manager.play_hit()
emit_signal("combat_log", "You hit " + current_enemy.name + " for " + str(damage) + " damage.")
if current_enemy.hp <= 0:
_win_combat()
else:
_enemy_turn()
func _enemy_turn():
var damage = current_enemy.damage
game_state.player_hp -= damage
_update_stats_ui()
audio_manager.play_hit()
emit_signal("damage_taken", damage)
emit_signal("combat_log", current_enemy.name + " hits you for " + str(damage) + " damage.")
if game_state.player_hp <= 0:
emit_signal("log_message", "You died!")
# Handle death (reload?)
func _win_combat():
emit_signal("combat_log", "You defeated " + current_enemy.name + "!")
# Remove enemy from room
var room = rooms[game_state.current_room_id]
room.enemies.erase(current_enemy)
current_enemy = null
emit_signal("combat_ended", true)
func flee():
emit_signal("log_message", "You fled!")
current_enemy = null
emit_signal("combat_ended", false)
# --- AI Integration ---
func process_user_input(text: String):
emit_signal("log_message", "> " + text)
var prompt = _construct_system_prompt()
llm_service.send_prompt(prompt, text)
func _construct_system_prompt() -> String:
var room = rooms[game_state.current_room_id]
var prompt = """
You are the Game Master for a text adventure.
Current Room: %s
Description: %s
Exits: %s
Your goal is to interpret the user's input and return a JSON object describing the outcome.
JSON Format:
{
"narrative": "Description of what happens...",
"action": {
"type": "MOVE" | "PICKUP" | "COMBAT" | "NONE",
"target": "north" | "item_name" | "enemy_name" | null
}
}
Rules:
- If user says "go north" and north is an exit, return action type "MOVE", target "north".
- If user says "attack goblin" and goblin is in room, return action type "COMBAT", target "goblin".
- If user says "look", just describe the room in "narrative".
- Keep narrative concise (2-3 sentences).
- Do NOT list player stats or inventory in the narrative.
""" % [room.room_name, room.description, str(room.exits.keys())]
return prompt
func _request_room_description(room: RoomData):
var prompt = """
You are a creative writer for a text adventure.
Describe the following location.
Name: %s
Base Details: %s
Exits: %s
Output JSON:
{
"narrative": "The atmospheric description...",
"save_as_description": true
}
""" % [room.room_name, room.description, str(room.exits.keys())]
llm_service.send_prompt(prompt, "Describe this place.")
func _on_llm_response(response: Dictionary):
if "narrative" in response:
emit_signal("log_message", response["narrative"])
if response.get("save_as_description") == true:
var room = rooms[game_state.current_room_id]
room.generated_description = response["narrative"]
if "action" in response:
var action = response["action"]
match action.get("type"):
"MOVE":
move(action.get("target"))
"PICKUP":
pickup_item(action.get("target"))
"COMBAT":
var target = action.get("target")
var room = rooms[game_state.current_room_id]
for enemy in room.enemies:
if enemy.name.to_lower() in target.to_lower() or enemy.id == target.to_lower():
start_combat(enemy)
break

View File

@@ -0,0 +1 @@
uid://5hcaskwa0y7h

72
scripts/LLMService.gd Normal file
View File

@@ -0,0 +1,72 @@
class_name LLMService extends Node
signal response_received(response_dict: Dictionary)
signal error_occurred(message: String)
var http_request: HTTPRequest
var api_key: String = ""
var api_url: String = "https://openrouter.ai/api/v1/chat/completions"
var model: String = "google/gemini-2.5-flash-preview-09-2025"
func _ready():
http_request = HTTPRequest.new()
add_child(http_request)
http_request.request_completed.connect(_on_request_completed)
_load_api_key()
func _load_api_key():
var config = ConfigFile.new()
var err = config.load("user://secrets.cfg")
if err == OK:
api_key = config.get_value("auth", "openrouter_key", "")
else:
print("No secrets.cfg found. Please create one with [auth] openrouter_key=...")
func send_prompt(system_prompt: String, user_input: String):
if api_key == "":
emit_signal("error_occurred", "API Key missing. Please check user://secrets.cfg")
return
var headers = [
"Content-Type: application/json",
"Authorization: Bearer " + api_key,
"HTTP-Referer: https://github.com/vibecoding/storyteller",
"X-Title: Storyteller"
]
var body = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input}
],
"response_format": {"type": "json_object"}
}
var json_body = JSON.stringify(body)
var error = http_request.request(api_url, headers, HTTPClient.METHOD_POST, json_body)
if error != OK:
emit_signal("error_occurred", "HTTP Request failed: " + str(error))
func _on_request_completed(result, response_code, headers, body):
if response_code != 200:
emit_signal("error_occurred", "API Error: " + str(response_code) + " " + body.get_string_from_utf8())
return
var json = JSON.new()
var parse_result = json.parse(body.get_string_from_utf8())
if parse_result != OK:
emit_signal("error_occurred", "JSON Parse Error")
return
var response = json.get_data()
if "choices" in response and response["choices"].size() > 0:
var content = response["choices"][0]["message"]["content"]
# Parse the content as JSON again since the LLM returns a string containing JSON
var content_json = JSON.new()
if content_json.parse(content) == OK:
emit_signal("response_received", content_json.get_data())
else:
emit_signal("error_occurred", "LLM returned invalid JSON: " + content)
else:
emit_signal("error_occurred", "Invalid API response structure")

View File

@@ -0,0 +1 @@
uid://drdjx6civ7emn

32
scripts/Minimap.gd Normal file
View File

@@ -0,0 +1,32 @@
extends GridContainer
var cells: Array[ColorRect] = []
func _ready():
columns = 4
for i in range(16):
var cell = ColorRect.new()
cell.custom_minimum_size = Vector2(40, 40)
cell.color = Color.DARK_GRAY
add_child(cell)
cells.append(cell)
func update_map(current_room_id: String, explored_rooms: Array):
# Parse "room_x_y"
var parts = current_room_id.split("_")
if parts.size() == 3:
var x = int(parts[1])
var y = int(parts[2])
var index = y * 4 + x
for i in range(cells.size()):
var cell_x = i % 4
var cell_y = i / 4
var cell_id = "room_%d_%d" % [cell_x, cell_y]
if i == index:
cells[i].color = Color.GREEN # Player
elif cell_id in explored_rooms:
cells[i].color = Color.LIGHT_GRAY # Explored
else:
cells[i].color = Color.DARK_GRAY # Unexplored

1
scripts/Minimap.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dly6ncstyio4f

41
scripts/WorldData.gd Normal file
View File

@@ -0,0 +1,41 @@
class_name WorldData
static func generate_test_world() -> Dictionary:
var rooms = {}
for x in range(4):
for y in range(4):
var id = "room_%d_%d" % [x, y]
var room = RoomData.new()
room.id = id
room.room_name = "Room %d,%d" % [x, y]
room.description = "A generic room at coordinates %d, %d." % [x, y]
# Link exits
room.exits = {}
if x > 0: room.exits["west"] = "room_%d_%d" % [x-1, y]
if x < 3: room.exits["east"] = "room_%d_%d" % [x+1, y]
if y > 0: room.exits["north"] = "room_%d_%d" % [x, y-1]
if y < 3: room.exits["south"] = "room_%d_%d" % [x, y+1]
# Add test item to room 1,1
if x == 1 and y == 1:
var sword = ItemData.new()
sword.id = "sword"
sword.name = "Rusty Sword"
sword.description = "A rusty old sword."
sword.effect_type = "DAMAGE"
sword.effect_value = 10
room.items.append(sword)
# Add test enemy to room 2,2
if x == 2 and y == 2:
var goblin = load("res://scripts/resources/EnemyData.gd").new()
goblin.id = "goblin"
goblin.name = "Goblin"
goblin.hp = 30
goblin.max_hp = 30
goblin.damage = 5
room.enemies.append(goblin)
rooms[id] = room
return rooms

1
scripts/WorldData.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://b5tv4wpx4bm4k

85
scripts/main.gd Normal file
View File

@@ -0,0 +1,85 @@
extends Node2D
@onready var game_manager = $GameManager
@onready var room_label = $UI/MainLayout/Sidebar/RoomLabel
@onready var log_label = $UI/MainLayout/GameView/Log
@onready var input_field = $UI/MainLayout/GameView/Input
@onready var minimap = $UI/MainLayout/Sidebar/Minimap
@onready var stats_label = $UI/MainLayout/Sidebar/StatsLabel
@onready var btn_north = $UI/MainLayout/Sidebar/Controls/BtnNorth
@onready var btn_south = $UI/MainLayout/Sidebar/Controls/BtnSouth
@onready var btn_east = $UI/MainLayout/Sidebar/Controls/HBox/BtnEast
@onready var btn_west = $UI/MainLayout/Sidebar/Controls/HBox/BtnWest
@onready var btn_save = $UI/MainLayout/Sidebar/Controls/BtnSave
@onready var btn_load = $UI/MainLayout/Sidebar/Controls/BtnLoad
@onready var combat_ui = $UI/CombatUI
@onready var main_layout = $UI/MainLayout
@onready var combat_enemy_label = $UI/CombatUI/Panel/VBox/EnemyLabel
@onready var btn_attack = $UI/CombatUI/Panel/VBox/Actions/BtnAttack
@onready var btn_flee = $UI/CombatUI/Panel/VBox/Actions/BtnFlee
func _ready():
# Connect signals
game_manager.room_changed.connect(_on_room_changed)
game_manager.log_message.connect(_on_log_message)
game_manager.stats_changed.connect(_on_stats_changed)
game_manager.combat_started.connect(_on_combat_started)
game_manager.combat_ended.connect(_on_combat_ended)
game_manager.combat_log.connect(_on_combat_log)
game_manager.damage_taken.connect(func(amount): shake_screen())
btn_north.pressed.connect(func(): game_manager.move("north"))
btn_south.pressed.connect(func(): game_manager.move("south"))
btn_east.pressed.connect(func(): game_manager.move("east"))
btn_west.pressed.connect(func(): game_manager.move("west"))
btn_save.pressed.connect(game_manager.save_game)
btn_load.pressed.connect(game_manager.load_game)
btn_attack.pressed.connect(game_manager.player_attack)
btn_flee.pressed.connect(game_manager.flee)
input_field.text_submitted.connect(_on_input_submitted)
# Clear log
log_label.text = ""
func _on_input_submitted(text: String):
if text.strip_edges() == "":
return
game_manager.process_user_input(text)
input_field.text = ""
func _on_room_changed(room_data: RoomData):
room_label.text = room_data.room_name
minimap.update_map(room_data.id, game_manager.game_state.explored_rooms)
func _on_log_message(message: String):
log_label.text += message + "\n"
func _on_stats_changed(hp, max_hp, inventory):
stats_label.text = "HP: %d/%d\nItems: %d" % [hp, max_hp, inventory.size()]
func _on_combat_started(enemy):
main_layout.visible = false
combat_ui.visible = true
combat_enemy_label.text = "%s (HP: %d/%d)" % [enemy.name, enemy.hp, enemy.max_hp]
func _on_combat_ended(won):
combat_ui.visible = false
main_layout.visible = true
func _on_combat_log(message):
if game_manager.current_enemy:
var enemy = game_manager.current_enemy
combat_enemy_label.text = "%s (HP: %d/%d)\n%s" % [enemy.name, enemy.hp, enemy.max_hp, message]
func shake_screen(intensity: float = 10.0, duration: float = 0.5):
var tween = create_tween()
var ui_node = $UI
for i in range(10):
var offset = Vector2(randf_range(-intensity, intensity), randf_range(-intensity, intensity))
tween.tween_property(ui_node, "offset", offset, duration / 10)
tween.tween_property(ui_node, "offset", Vector2.ZERO, duration / 10)

1
scripts/main.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://b71xydu5v87jp

View File

@@ -0,0 +1,8 @@
class_name EnemyData extends Resource
@export var id: String
@export var name: String
@export var hp: int
@export var max_hp: int
@export var damage: int
@export var image_path: String

View File

@@ -0,0 +1 @@
uid://bqfi5kki2b58t

View File

@@ -0,0 +1,8 @@
class_name GameState extends Resource
@export var current_room_id: String
@export var player_hp: int = 100
@export var player_max_hp: int = 100
@export var inventory: Array[ItemData]
@export var world_flags: Dictionary = {}
@export var explored_rooms: Array[String] = []

View File

@@ -0,0 +1 @@
uid://c1q4ns5aoocdq

View File

@@ -0,0 +1,7 @@
class_name ItemData extends Resource
@export var id: String
@export var name: String
@export_multiline var description: String
@export var effect_type: String # e.g., "HEAL", "DAMAGE"
@export var effect_value: int

View File

@@ -0,0 +1 @@
uid://jr376c28lhu3

View File

@@ -0,0 +1,10 @@
class_name RoomData extends Resource
@export var id: String
@export var room_name: String
@export_multiline var description: String
@export_multiline var generated_description: String = ""
@export var image_path: String
@export var exits: Dictionary # { "north": "room_id", ... }
@export var items: Array[ItemData]
@export var enemies: Array[Resource] # Array[EnemyData]

View File

@@ -0,0 +1 @@
uid://dp8agph2wjo3a