This commit is contained in:
2025-07-30 21:52:27 -07:00
commit a429a3e06b
29 changed files with 3068 additions and 0 deletions

59
cutscene/Action.gd Normal file
View File

@@ -0,0 +1,59 @@
class_name Action
extends RefCounted
# Action states
enum State {
PENDING, # Action is waiting to be executed
RUNNING, # Action is currently executing
COMPLETED, # Action has finished executing
FAILED # Action failed during execution
}
# Public properties
var state: int = State.PENDING
var name: String = "Action"
# Signals
signal started()
signal completed()
signal failed(error_message)
signal stopped()
# Virtual methods to be implemented by subclasses
func start() -> void:
# Start executing the action
# This method should be overridden by subclasses
pass
func update(delta: float) -> void:
# Update the action (called every frame while running)
# This method can be overridden by subclasses
pass
func is_completed() -> bool:
# Check if the action has completed
# This method should be overridden by subclasses
return state == State.COMPLETED
func stop() -> void:
# Stop the action (if it's running)
# This method can be overridden by subclasses
if state == State.RUNNING:
state = State.FAILED
stopped.emit()
# Helper methods
func _set_running() -> void:
# Set the action state to RUNNING and emit started signal
state = State.RUNNING
started.emit()
func _set_completed() -> void:
# Set the action state to COMPLETED and emit completed signal
state = State.COMPLETED
completed.emit()
func _set_failed(error_message: String) -> void:
# Set the action state to FAILED and emit failed signal
state = State.FAILED
failed.emit(error_message)

228
cutscene/CutsceneManager.gd Normal file
View File

@@ -0,0 +1,228 @@
class_name CutsceneManager
extends Node
# Action queues
var sequential_actions: Array = [] # Actions that run one after another
var current_action: Action = null # The action currently being executed
var action_index: int = 0 # Index of the next sequential action
# Parallel actions
var parallel_groups: Array = [] # Groups of actions running simultaneously
var active_parallel_groups: Array = [] # Currently running parallel action groups
# State management
enum State {
IDLE, # Not running any cutscene
RUNNING, # Currently executing a cutscene
PAUSED # Cutscene is paused
}
var state: int = State.IDLE
var is_active: bool = false
# Signals
signal cutscene_started()
signal cutscene_completed()
signal cutscene_paused()
signal cutscene_resumed()
signal action_started(action)
signal action_completed(action)
signal action_failed(action, error_message)
# Public methods
func start() -> void:
# Start executing the cutscene
if state != State.IDLE:
return
state = State.RUNNING
is_active = true
emit_signal("cutscene_started")
# Start first action
_execute_next_action()
func pause() -> void:
# Pause the current cutscene
if state == State.RUNNING:
state = State.PAUSED
emit_signal("cutscene_paused")
func resume() -> void:
# Resume a paused cutscene
if state == State.PAUSED:
state = State.RUNNING
emit_signal("cutscene_resumed")
func stop() -> void:
# Stop the current cutscene and reset
if current_action != null:
current_action.stop()
current_action = null
# Stop all active parallel groups
for group in active_parallel_groups:
for action in group["actions"]:
if action.state == action.State.RUNNING:
action.stop()
_reset()
func add_action(action: Action) -> void:
# Add a single action to the sequential queue
if state != State.IDLE:
print("Warning: Cannot add actions while cutscene is running")
return
sequential_actions.append(action)
func add_parallel_actions(actions: Array) -> void:
# Add a group of actions to run in parallel
if state != State.IDLE:
print("Warning: Cannot add actions while cutscene is running")
return
if actions.size() == 0:
return
# Create a parallel action group
var group = {
"actions": actions,
"completed_count": 0,
"total_count": actions.size()
}
# Add to sequential actions as a single item
sequential_actions.append(group)
# Internal methods
func _process(delta: float) -> void:
# Main update loop
if state != State.RUNNING:
return
# Update current sequential action
if current_action != null and current_action.state == current_action.State.RUNNING:
current_action.update(delta)
# Update active parallel groups
for group in active_parallel_groups:
for action in group["actions"]:
if action.state == action.State.RUNNING:
action.update(delta)
func _execute_next_action() -> void:
# Start executing the next sequential action
if action_index >= sequential_actions.size():
# No more actions, cutscene complete
_on_cutscene_completed()
return
var action_item = sequential_actions[action_index]
action_index += 1
# Check if this is a parallel group or single action
if typeof(action_item) == TYPE_DICTIONARY and action_item.has("actions"):
# This is a parallel group
_execute_parallel_group(action_item)
else:
# This is a single action
_execute_single_action(action_item)
func _execute_single_action(action: Action) -> void:
# Execute a single action
current_action = action
# Connect to action signals
if not action.is_connected("completed", _on_action_completed):
action.connect("completed", _on_action_completed.bind(action))
if not action.is_connected("failed", _on_action_failed):
action.connect("failed", _on_action_failed.bind(action))
if not action.is_connected("started", _on_action_started):
action.connect("started", _on_action_started.bind(action))
# Start the action
emit_signal("action_started", action)
action.start()
func _execute_parallel_group(group: Dictionary) -> void:
# Execute a group of parallel actions
var actions = group["actions"]
# Reset completion count
group["completed_count"] = 0
# Add to active parallel groups
active_parallel_groups.append(group)
# Connect to each action's signals
for action in actions:
if not action.is_connected("completed", _on_parallel_action_completed):
action.connect("completed", _on_parallel_action_completed.bind(group, action))
if not action.is_connected("failed", _on_parallel_action_failed):
action.connect("failed", _on_parallel_action_failed.bind(group, action))
if not action.is_connected("started", _on_action_started):
action.connect("started", _on_action_started.bind(action))
# Emit started signal and start action
emit_signal("action_started", action)
action.start()
func _on_action_started(action: Action) -> void:
# Handle action started
emit_signal("action_started", action)
func _on_action_completed(action: Action) -> void:
# Handle action completion
if action == current_action:
current_action = null
emit_signal("action_completed", action)
# Move to next action
_execute_next_action()
func _on_action_failed(action: Action, error_message: String) -> void:
# Handle action failure
if action == current_action:
current_action = null
emit_signal("action_failed", action, error_message)
print("Action failed: %s - %s" % [action.name, error_message])
# Stop the cutscene
stop()
func _on_parallel_action_completed(group: Dictionary, action: Action) -> void:
# Increment completed count
group["completed_count"] += 1
emit_signal("action_completed", action)
# Check if all actions in group are completed
if group["completed_count"] >= group["total_count"]:
# Remove from active groups
active_parallel_groups.erase(group)
# Continue with next sequential action
_execute_next_action()
func _on_parallel_action_failed(group: Dictionary, action: Action, error_message: String) -> void:
# Handle parallel action failure
emit_signal("action_failed", action, error_message)
print("Parallel action failed: %s - %s" % [action.name, error_message])
# Stop the cutscene
stop()
func _on_cutscene_completed() -> void:
# Handle cutscene completion
state = State.IDLE
is_active = false
emit_signal("cutscene_completed")
func _reset() -> void:
# Reset the manager to initial state
sequential_actions.clear()
parallel_groups.clear()
active_parallel_groups.clear()
current_action = null
action_index = 0
state = State.IDLE
is_active = false

135
cutscene/README.md Normal file
View File

@@ -0,0 +1,135 @@
# Cutscene System for Godot 4.3
## Overview
This is a flexible cutscene system for 2D point-and-click adventure games in Godot 4.3. It supports both sequential and parallel action execution, with a focus on extensibility and dynamic behavior.
## Features
- Sequential action execution
- Parallel action execution
- Extensible action system
- Signal-based completion system
- Error handling and recovery
## System Components
### Core Classes
- `Action`: Base class for all actions
- `CutsceneManager`: Orchestrates action execution
### Action Types
- `MoveAction`: Move characters to specific positions
- `TurnAction`: Rotate characters to face targets
- `DialogueAction`: Display dialogue text
- `AnimationAction`: Play character animations
- `WaitAction`: Time-based delays
## Usage
### Basic Setup
```gdscript
# Create a cutscene manager
var cutscene_manager = CutsceneManager.new()
add_child(cutscene_manager)
# Add sequential actions
cutscene_manager.add_action(MoveAction.new(character, Vector2(100, 100)))
cutscene_manager.add_action(DialogueAction.new(character, "Hello, world!"))
# Start the cutscene
cutscene_manager.start()
```
### Parallel Actions
```gdscript
# Create parallel actions
var parallel_actions = [
MoveAction.new(character1, Vector2(200, 200)),
MoveAction.new(character2, Vector2(300, 300))
]
cutscene_manager.add_parallel_actions(parallel_actions)
```
### The Example Scenario
The system supports complex sequences like the one described in the requirements:
```gdscript
# 1. Character1 moves to 234, 591
# 2. At the same time Character2 moves to 912, 235
# 3. When both arrive, character2 turns to character1
# 4. Character2 says a dialogue line to character1
# 5. A special animation (shocked) is played for character1
var cutscene = CutsceneManager.new()
add_child(cutscene)
# Add parallel movements
var moves = [
MoveAction.new(character1, Vector2(234, 591)),
MoveAction.new(character2, Vector2(912, 235))
]
cutscene.add_parallel_actions(moves)
// Add sequential actions
cutscene.add_action(TurnAction.new(character2, character1))
cutscene.add_action(DialogueAction.new(character2, "Hello there!"))
cutscene.add_action(AnimationAction.new(character1, "shocked"))
cutscene.start()
```
## Extending the System
### Creating New Action Types
1. Create a new class that extends `Action`
2. Implement the required methods:
- `start()`: Initialize and begin the action
- `update(delta)`: Update the action each frame (optional)
- `is_completed()`: Return true when the action is finished
3. Use the action in cutscenes like any other
```gdscript
class_name CustomAction
extends "res://cutscene/Action.gd"
func _init(custom_parameter):
# Initialize custom properties
pass
func start() -> void:
# Start the action
._set_running()
func update(delta: float) -> void:
# Update the action each frame
pass
func is_completed() -> bool:
# Return true when the action is completed
return state == State.COMPLETED
```
## File Structure
```
/cutscene/
├── Action.gd
├── CutsceneManager.gd
├── actions/
│ ├── MoveAction.gd
│ ├── TurnAction.gd
│ ├── DialogueAction.gd
│ ├── AnimationAction.gd
│ └── WaitAction.gd
├── examples/
│ ├── sample_cutscene.tscn
│ ├── sample_cutscene.gd
│ ├── animated_character.tscn
│ └── animated_character.gd
└── tests/
└── test_cutscene_system.gd
```
## Testing
Run the test script at `cutscene/tests/test_cutscene_system.gd` to verify the system is working correctly.
## License
This cutscene system is provided as-is for educational and prototyping purposes.

View File

@@ -0,0 +1,55 @@
class_name AnimationAction
extends "res://cutscene/Action.gd"
# Properties
var character: Node2D # The character to animate
var animation_name: String # Name of the animation to play
var loop: bool = false # Whether to loop the animation
func _init(character_node: Node2D, anim_name: String, should_loop: bool = false) -> void:
character = character_node
animation_name = anim_name
loop = should_loop
name = "AnimationAction"
func start() -> void:
if character == null:
self._set_failed("Character is null")
return
# Check if character has an AnimationPlayer
var anim_player = _get_animation_player()
if anim_player == null:
self._set_failed("Character has no AnimationPlayer")
return
# Connect to animation finished signal for non-looping animations
if not loop:
if not anim_player.is_connected("animation_finished", _on_animation_finished):
anim_player.connect("animation_finished", _on_animation_finished.bind(anim_player))
# Play the animation
anim_player.play(animation_name)
self._set_running()
# For looping animations, we complete immediately
# (they would be stopped by another action)
if loop:
self._set_completed()
func _get_animation_player() -> AnimationPlayer:
# Try to find AnimationPlayer as a child
for child in character.get_children():
if child is AnimationPlayer:
return child
# Try to find AnimationPlayer in the scene tree
return character.get_node_or_null("AnimationPlayer") as AnimationPlayer
func _on_animation_finished(anim_name: String, anim_player: AnimationPlayer) -> void:
# Make sure this is for our animation
if anim_name == animation_name and not loop:
self._set_completed()
func is_completed() -> bool:
return state == State.COMPLETED

View File

@@ -0,0 +1,75 @@
class_name DialogueAction
extends "res://cutscene/Action.gd"
# Properties
var character: Node2D # The speaking character (optional)
var text: String # Dialogue text
var duration: float = 0.0 # Duration to display (0 = manual advance)
func _init(speaking_character: Node2D, dialogue_text: String, display_duration: float = 0.0) -> void:
character = speaking_character
text = dialogue_text
duration = display_duration
name = "DialogueAction"
func start() -> void:
# Display the dialogue text
_display_dialogue()
self._set_running()
# If duration is 0, this is a manual advance action
if duration <= 0:
# For demo purposes, we'll complete immediately
# In a real game, this would wait for player input
self._set_completed()
else:
# Start a timer for automatic completion
var timer = Timer.new()
timer.wait_time = duration
timer.one_shot = true
timer.connect("timeout", _on_timer_timeout)
# Add timer as a child to ensure it processes
if Engine.get_main_loop().current_scene:
Engine.get_main_loop().current_scene.add_child(timer)
#else:
## Fallback if we can't access the main scene
#var root = get_tree().root if get_tree() else null
#if root and root.get_child_count() > 0:
#root.get_child(0).add_child(timer)
#else:
## Last resort - connect directly and manage manually
#timer.connect("timeout", _on_timer_timeout_direct)
#timer.start(duration)
#return
timer.connect("timeout", _cleanup_timer.bind(timer))
timer.start()
func _display_dialogue() -> void:
# In a real implementation, this would interface with a dialogue system
# For now, we'll simulate by printing to console
var character_name = character.name if character and character.name else "Unknown"
print("%s: %s" % [character_name, text])
func _on_timer_timeout() -> void:
self._set_completed()
func _on_timer_timeout_direct() -> void:
# Direct timeout handler for when we can't add the timer to the scene tree
self._set_completed()
func _cleanup_timer(timer: Timer) -> void:
# Clean up the timer
if timer and timer.get_parent():
timer.get_parent().remove_child(timer)
if timer:
timer.queue_free()
self._set_completed()
func is_completed() -> bool:
return state == State.COMPLETED
func stop() -> void:
# Clean up any timers when stopping
super.stop()

View File

@@ -0,0 +1,54 @@
class_name MoveAction
extends "res://cutscene/Action.gd"
# Properties
var character: Node2D # The character to move
var target_position: Vector2 # Target position
var speed: float = 100.0 # Movement speed (pixels/second)
var threshold: float = 5.0 # Distance threshold for completion
# Internal
var start_position: Vector2
var distance: float
var traveled: float = 0.0
func _init(character_node: Node2D, target: Vector2, move_speed: float = 100.0) -> void:
character = character_node
target_position = target
speed = move_speed
name = "MoveAction"
func start() -> void:
if character == null:
self._set_failed("Character is null")
return
start_position = character.position
distance = start_position.distance_to(target_position)
traveled = 0.0
self._set_running()
func update(delta: float) -> void:
if state != State.RUNNING:
return
if character == null:
self._set_failed("Character was destroyed during action")
return
# Calculate movement for this frame
var frame_distance = speed * delta
traveled += frame_distance
# If we've reached or overshot the target
if traveled >= distance:
character.position = target_position
self._set_completed()
return
# Move character along the path
var direction = (target_position - start_position).normalized()
character.position = start_position + direction * traveled
func is_completed() -> bool:
return state == State.COMPLETED

View File

@@ -0,0 +1,69 @@
class_name TurnAction
extends "res://cutscene/Action.gd"
# Properties
var character: Node2D # The character to turn
var target: Variant # Either a Vector2 position or another Node2D
var turn_speed: float = 2.0 # Rotation speed (radians/second)
func _init(character_node: Node2D, turn_target: Variant, speed: float = 2.0) -> void:
character = character_node
target = turn_target
turn_speed = speed
name = "TurnAction"
func start() -> void:
if character == null:
self._set_failed("Character is null")
return
self._set_running()
func update(delta: float) -> void:
if state != State.RUNNING:
return
if character == null:
self._set_failed("Character was destroyed during action")
return
# Calculate target rotation
var target_position: Vector2
if target is Vector2:
target_position = target
elif target is Node2D:
target_position = target.position
else:
self._set_failed("Invalid target type")
return
var direction = target_position - character.position
var target_rotation = atan2(direction.y, direction.x)
# Rotate toward target
var angle_diff = _angle_difference(character.rotation, target_rotation)
# If we're close enough, complete the action
if abs(angle_diff) < 0.01:
character.rotation = target_rotation
self._set_completed()
return
# Rotate based on turn speed
var rotation_amount = sign(angle_diff) * turn_speed * delta
if abs(rotation_amount) > abs(angle_diff):
character.rotation = target_rotation
self._set_completed()
else:
character.rotation += rotation_amount
func is_completed() -> bool:
return state == State.COMPLETED
# Helper function to calculate angle difference
func _angle_difference(from: float, to: float) -> float:
var diff = fmod(to - from, 2.0 * PI)
if diff < -PI:
diff += 2.0 * PI
elif diff > PI:
diff -= 2.0 * PI
return diff

View File

@@ -0,0 +1,25 @@
class_name WaitAction
extends "res://cutscene/Action.gd"
# Properties
var duration: float # Time to wait in seconds
var elapsed_time: float = 0.0
func _init(wait_duration: float) -> void:
duration = wait_duration
name = "WaitAction"
func start() -> void:
elapsed_time = 0.0
self._set_running()
func update(delta: float) -> void:
if state != State.RUNNING:
return
elapsed_time += delta
if elapsed_time >= duration:
self._set_completed()
func is_completed() -> bool:
return state == State.COMPLETED

View File

@@ -0,0 +1,10 @@
[gd_scene format=3 uid="uid://animatedcharacter"]
[ext_resource type="Script" path="res://cutscene/examples/animated_character.gd" id="1"]
[node name="AnimatedCharacter" type="Node2D"]
script = ExtResource( "1" )
[node name="Sprite2D" type="Sprite2D" parent="."]
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]

View File

@@ -0,0 +1,80 @@
extends Node2D
# Character nodes
@onready var character1: Node2D = $Character1
@onready var character2: Node2D = $Character2
# Cutscene manager
var cutscene_manager: CutsceneManager
func _ready() -> void:
# Initialize the cutscene system
setup_cutscene()
# Start the cutscene after a short delay to see the initial positions
var start_timer = Timer.new()
start_timer.wait_time = 1.0
start_timer.one_shot = true
start_timer.connect("timeout", start_cutscene)
add_child(start_timer)
start_timer.start()
func setup_cutscene() -> void:
# Create the cutscene manager
cutscene_manager = CutsceneManager.new()
add_child(cutscene_manager)
# Connect to cutscene signals
cutscene_manager.connect("cutscene_started", _on_cutscene_started)
cutscene_manager.connect("cutscene_completed", _on_cutscene_completed)
cutscene_manager.connect("action_started", _on_action_started)
cutscene_manager.connect("action_completed", _on_action_completed)
# Create the action sequence as described in requirements
# 1. & 2. Characters move simultaneously
var parallel_moves = [
MoveAction.new(character1, Vector2(234, 591), 100.0), # Character1 moves
MoveAction.new(character2, Vector2(912, 235), 100.0) # Character2 moves
]
cutscene_manager.add_parallel_actions(parallel_moves)
# 3. Character2 turns to face Character1
var turn_action = TurnAction.new(character2, character1, 1.0)
cutscene_manager.add_action(turn_action)
# 4. Character2 says dialogue
var dialogue_action = DialogueAction.new(character2, "Hello there, friend!", 2.0)
cutscene_manager.add_action(dialogue_action)
# 5. Character1 plays shocked animation (simulated with a wait since we don't have an actual animation)
var animation_action = WaitAction.new(1.0) # Simulate animation with wait
cutscene_manager.add_action(animation_action)
# Add a final dialogue from character1
var final_dialogue = DialogueAction.new(character1, "That was surprising!", 2.0)
cutscene_manager.add_action(final_dialogue)
func start_cutscene() -> void:
print("Starting cutscene...")
cutscene_manager.start()
func _on_cutscene_started() -> void:
print("Cutscene started!")
func _on_cutscene_completed() -> void:
print("Cutscene completed!")
print("Final positions:")
print("Character1: %s" % character1.position)
print("Character2: %s" % character2.position)
func _on_action_started(action: Action) -> void:
print("Action started: %s" % action.name)
func _on_action_completed(action: Action) -> void:
print("Action completed: %s" % action.name)
# Clean up when the node is removed
func _exit_tree() -> void:
if cutscene_manager:
cutscene_manager.queue_free()

View File

@@ -0,0 +1,18 @@
[gd_scene load_steps=2 format=3 uid="uid://bx826fm1kd3wk"]
[ext_resource type="Script" path="res://cutscene/examples/sample_cutscene.gd" id="1"]
[node name="SampleCutscene" type="Node2D"]
script = ExtResource("1")
[node name="Character1" type="Node2D" parent="."]
position = Vector2(100, 100)
[node name="Polygon2D" type="Polygon2D" parent="Character1"]
polygon = PackedVector2Array(-5, -19, -27, 2, 2, 15, 15, 6, 9, -19)
[node name="Character2" type="Node2D" parent="."]
position = Vector2(200, 100)
[node name="Polygon2D" type="Polygon2D" parent="Character2"]
polygon = PackedVector2Array(10, -25, -43, 1, -14, 40, 48, 13)

View File

@@ -0,0 +1,95 @@
extends Node2D
# Test the cutscene system
func _ready() -> void:
print("Testing cutscene system...")
# Create test characters
var char1 = Node2D.new()
char1.name = "TestCharacter1"
add_child(char1)
var char2 = Node2D.new()
char2.name = "TestCharacter2"
add_child(char2)
# Create animation player for char1
var anim_player = AnimationPlayer.new()
anim_player.name = "AnimationPlayer"
char1.add_child(anim_player)
# Create a simple animation
var animation = Animation.new()
animation.name = "test_animation"
animation.length = 1.0
anim_player.add_animation("test_animation", animation)
# Test the cutscene system
test_sequential_actions(char1, char2)
# After a delay, test parallel actions
var timer = Timer.new()
timer.wait_time = 3.0
timer.one_shot = true
timer.connect("timeout", test_parallel_actions.bind(char1, char2))
add_child(timer)
timer.start()
func test_sequential_actions(char1: Node2D, char2: Node2D) -> void:
print("\n=== Testing Sequential Actions ===")
# Create cutscene manager
var manager = CutsceneManager.new()
add_child(manager)
# Connect signals
manager.connect("cutscene_completed", _on_test_completed.bind("sequential"))
# Add sequential actions
manager.add_action(MoveAction.new(char1, Vector2(100, 100), 50.0))
manager.add_action(WaitAction.new(0.5))
manager.add_action(TurnAction.new(char1, Vector2(200, 200), 1.0))
manager.add_action(DialogueAction.new(char1, "Hello, world!", 1.0))
manager.add_action(AnimationAction.new(char1, "test_animation", false))
# Start the cutscene
manager.start()
func test_parallel_actions(char1: Node2D, char2: Node2D) -> void:
print("\n=== Testing Parallel Actions ===")
# Create cutscene manager
var manager = CutsceneManager.new()
add_child(manager)
# Connect signals
manager.connect("cutscene_completed", _on_test_completed.bind("parallel"))
# Add parallel actions
var parallel_group = [
MoveAction.new(char1, Vector2(300, 300), 100.0),
MoveAction.new(char2, Vector2(400, 400), 100.0)
]
manager.add_parallel_actions(parallel_group)
# Add sequential actions after parallel
manager.add_action(TurnAction.new(char1, char2, 2.0))
manager.add_action(DialogueAction.new(char1, "We moved together!", 1.5))
# Start the cutscene
manager.start()
func _on_test_completed(test_type: String) -> void:
print("Test %s completed successfully!" % test_type)
# Clean up after a delay
var cleanup_timer = Timer.new()
cleanup_timer.wait_time = 1.0
cleanup_timer.one_shot = true
cleanup_timer.connect("timeout", clean_up)
add_child(cleanup_timer)
cleanup_timer.start()
func clean_up() -> void:
print("\n=== All tests completed ===")
print("Cutscene system is working correctly!")