diff --git a/Scenes/main.tscn b/Scenes/main.tscn index 2d4c5e2..4c2c005 100644 --- a/Scenes/main.tscn +++ b/Scenes/main.tscn @@ -10,7 +10,7 @@ position = Vector2(620, 320) scale = Vector2(50, 30) -[node name="Timer" type="Timer" parent="."] +[node name="Timer" type="Timer" parent="." groups=["GameClock"]] wait_time = 0.3 autostart = true @@ -20,5 +20,7 @@ position = Vector2(420, 240) [node name="Fruit" parent="." instance=ExtResource("3_8gbba")] position = Vector2(707, 325) +[node name="RestartTimer" type="Timer" parent="." groups=["GameClockPause"]] + [connection signal="area_exited" from="Level" to="SnakePart" method="_on_level_area_exited"] -[connection signal="timeout" from="Timer" to="SnakePart" method="process_movement" binds= [Vector2(0, 0)]] +[connection signal="timeout" from="RestartTimer" to="Timer" method="start" binds= [0.3]] diff --git a/Scripts/fruit.gd b/Scripts/fruit.gd index 090e6f1..36233c1 100644 --- a/Scripts/fruit.gd +++ b/Scripts/fruit.gd @@ -13,21 +13,33 @@ var growth_amount : int = 1 var label : Label = $Label func _ready() -> void: + + # Determine the level limits var level : Area2D = get_tree().get_first_node_in_group("Level") var x_bounds : Vector2 = level.position.x * Vector2.ONE + Vector2(-TILE_SIZE, TILE_SIZE) * level.scale.x / 2 var y_bounds : Vector2 = level.position.y * Vector2.ONE + Vector2(-TILE_SIZE, TILE_SIZE) * level.scale.y / 2 + # Determine the possible x and y coordinates var possible_x : Array = range(x_bounds.x, x_bounds.y, TILE_SIZE) var possible_y : Array = range(y_bounds.x, y_bounds.y, TILE_SIZE) + # Add each combination of x and y to the list of positions for x in possible_x: for y in possible_y: all_positions.append(Vector2(x, y)) respawn() +func _process(_delta: float) -> void: + if !GridManager.current_allowed_spawns.is_empty() and not position in GridManager.current_allowed_spawns: + respawn() + func respawn() -> void: - var possible_positions = all_positions.duplicate() + var possible_positions : Array[Vector2] + if GridManager.current_allowed_spawns.is_empty(): + possible_positions = all_positions.duplicate() + else: + possible_positions = GridManager.current_allowed_spawns.duplicate() var obstacles : Array[Node] = get_tree().get_nodes_in_group("BlocksSpawn") diff --git a/Scripts/grid_manager.gd b/Scripts/grid_manager.gd new file mode 100644 index 0000000..5622d14 --- /dev/null +++ b/Scripts/grid_manager.gd @@ -0,0 +1,3 @@ +extends Node + +var current_allowed_spawns : Array[Vector2] diff --git a/Scripts/grid_manager.gd.uid b/Scripts/grid_manager.gd.uid new file mode 100644 index 0000000..1fc6563 --- /dev/null +++ b/Scripts/grid_manager.gd.uid @@ -0,0 +1 @@ +uid://b8hf756cxoqgb diff --git a/Scripts/snake_part.gd b/Scripts/snake_part.gd index 1dcc087..79ea0bb 100644 --- a/Scripts/snake_part.gd +++ b/Scripts/snake_part.gd @@ -2,9 +2,11 @@ extends Area2D class_name SnakePart const TILE_SIZE = 20 -enum PartTypes {HEAD, BODY, TAIL, DEAD} +enum PartTypes {HEAD, BODY, TAIL} +enum States {ALIVE, DEAD, OUROBOROS, OLD_OUROBOROS} var part_type : PartTypes = PartTypes.HEAD +var state : States = States.ALIVE var current_direction : Vector2 = Vector2.RIGHT var inputs : Dictionary[String, Vector2] = {"right": Vector2.RIGHT, @@ -13,6 +15,7 @@ var inputs : Dictionary[String, Vector2] = {"right": Vector2.RIGHT, "down": Vector2.DOWN} + @onready var raycast_right : RayCast2D = $RightRayCast2D @onready @@ -30,9 +33,11 @@ var timer_ref : Timer = $"../Timer" var next_part : SnakePart = null var skip_next_move_propagation : bool = false -var queued_growth : int = 0 +var queued_growth : int = 21 -signal do_movement(new_dir) +signal on_movement(new_dir) +signal on_death +signal on_ouroboros func _ready() -> void: if part_type == PartTypes.HEAD: @@ -40,13 +45,20 @@ func _ready() -> void: # Add to group for easy access from other nodes add_to_group("Head") + # Attach clock + var timer : Node = get_tree().get_first_node_in_group("GameClock") + if timer is Timer: + timer.timeout.connect(process_movement.bind(Vector2.ZERO)) + # Set up a body part var body = snake_part_obj.instantiate() body.part_type = PartTypes.BODY body.position = (position - current_direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) next_part = body get_parent().add_child.call_deferred(body) - do_movement.connect(body.process_movement) + on_movement.connect(body.process_movement) + on_death.connect(body.lose_game) + on_ouroboros.connect(body.ouroboros) # Set up a tail part var tail = snake_part_obj.instantiate() @@ -54,7 +66,9 @@ func _ready() -> void: tail.position = (position - current_direction * TILE_SIZE * 2).snapped(Vector2.ONE * TILE_SIZE) body.next_part = tail get_parent().add_child.call_deferred(tail) - body.do_movement.connect(tail.process_movement) + body.on_movement.connect(tail.process_movement) + on_death.connect(tail.lose_game) + on_ouroboros.connect(tail.ouroboros) else: # Free raycasts if we don't need them (consider only loading them in for the head for performance if needed) @@ -63,22 +77,81 @@ func _ready() -> void: raycast_left.queue_free() raycast_right.queue_free() +# TODO: Optimise this! +func generate_spawn_grid() -> Array[Vector2]: + + var spawn_grid : Array[Vector2] = [] + + # Determine the level limits + var level : Area2D = get_tree().get_first_node_in_group("Level") + var x_bounds : Vector2 = level.position.x * Vector2.ONE + Vector2(-TILE_SIZE, TILE_SIZE) * level.scale.x / 2 + var y_bounds : Vector2 = level.position.y * Vector2.ONE + Vector2(-TILE_SIZE, TILE_SIZE) * level.scale.y / 2 + + var possible_x : Array = range(x_bounds.x, x_bounds.y, TILE_SIZE) + var possible_y : Array = range(y_bounds.x, y_bounds.y, TILE_SIZE) + + # Flood fill all possibilities until we get an inside to use + for x in possible_x: + for y in possible_y: + var start_position : Vector2 = Vector2(x, y).snapped(Vector2.ONE * TILE_SIZE) + spawn_grid.clear() + if !check_position_for_snake(start_position) and flood_fill(spawn_grid, start_position, x_bounds, y_bounds): + GridManager.current_allowed_spawns = spawn_grid + return spawn_grid + + GridManager.current_allowed_spawns = spawn_grid + return spawn_grid +func flood_fill(positions : Array[Vector2], new_position : Vector2, x_bounds : Vector2, y_bounds : Vector2) -> bool: + var directions : Array[Vector2] = [Vector2.DOWN, Vector2.LEFT, Vector2.UP, Vector2.RIGHT] + + var possible_x : Array = range(x_bounds.x, x_bounds.y, TILE_SIZE) + var possible_y : Array = range(y_bounds.x, y_bounds.y, TILE_SIZE) + + var inside : bool = true + + for direction in directions: + var test_position : Vector2 = (new_position + direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) + if not int(test_position.x) in possible_x or not int(test_position.y) in possible_y: + inside = false + if not test_position in positions and int(test_position.x) in possible_x and int(test_position.y) in possible_y and !check_position_for_snake(test_position): + positions.append(test_position) + inside = inside and flood_fill(positions, test_position, x_bounds, y_bounds) + + return inside + +func check_position_for_snake(query_position : Vector2) -> bool: + + var obstacles : Array[Node] = get_tree().get_nodes_in_group("BlocksSpawn") + + for obstacle in obstacles: + if obstacle is SnakePart: + if obstacle.state == States.OUROBOROS and obstacle.position == query_position: + return true + return false func process_movement(new_direction : Vector2) -> void: + # Only alive snakes can move + if state != States.ALIVE: + return + # The head needs to check if we are about to collide with something if part_type == PartTypes.HEAD: check_movement() if queued_growth: extend() + # Only alive snakes can move + if state != States.ALIVE: + return + # Update the position of this part position = (position + current_direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) # Tell the next part to move and give it the direction we are moving if !skip_next_move_propagation: - do_movement.emit(current_direction) + on_movement.emit(current_direction) skip_next_move_propagation = false # Unless we are the head, change our direction to the direction given @@ -86,6 +159,7 @@ func process_movement(new_direction : Vector2) -> void: current_direction = new_direction func check_movement() -> void: + # Get the correct raycast for the direction we are moving var raycast_to_use : RayCast2D if current_direction == Vector2.RIGHT: @@ -106,28 +180,32 @@ func check_movement() -> void: # If we found a snake part, then either end the game or perform an ouroboros maneouvre if object_in_path is SnakePart: - match object_in_path.part_type: - PartTypes.BODY: - lose_game() - PartTypes.DEAD: - lose_game() - PartTypes.TAIL: - ouroboros() + if object_in_path.state == States.ALIVE: + match object_in_path.part_type: + PartTypes.BODY: + lose_game() + PartTypes.TAIL: + ouroboros() + else: + lose_game() if object_in_path is Fruit: queued_growth += object_in_path.growth_amount object_in_path.respawn() func extend() -> void: + # Remove old connection to previous next part - do_movement.disconnect(next_part.process_movement) + on_movement.disconnect(next_part.process_movement) # Set up a body part to be in the chain var new_body = snake_part_obj.instantiate() new_body.part_type = PartTypes.BODY new_body.position = (position).snapped(Vector2.ONE * TILE_SIZE) get_parent().add_child(new_body) - do_movement.connect(new_body.process_movement) - new_body.do_movement.connect(next_part.process_movement) + on_movement.connect(new_body.process_movement) + new_body.on_movement.connect(next_part.process_movement) + on_death.connect(new_body.lose_game) + on_ouroboros.connect(new_body.ouroboros) new_body.next_part = next_part # Set direction of new part @@ -144,12 +222,71 @@ func extend() -> void: func lose_game() -> void: + # You lose! print("Game lost") - timer_ref.stop() + state = States.DEAD + on_death.emit() func ouroboros() -> void: print("You have achieved the ouroboros!") + if state == States.OUROBOROS: + state = States.OLD_OUROBOROS + elif state == States.ALIVE: + state = States.OUROBOROS + on_ouroboros.emit() + + # The head can spawn a new snake + if part_type == PartTypes.HEAD: + get_tree().get_first_node_in_group("GameClockPause").start(1) + get_tree().get_first_node_in_group("GameClock").stop() + spawn_new_snake() + +func spawn_new_snake() -> void: + + # Find out where we can spawn a snake + #var start = Time.get_ticks_usec() + var all_spawn_positions : Array[Vector2] = generate_spawn_grid().duplicate() + #var end = Time.get_ticks_usec() + #var worker_time = (end-start)/1000000.0 + #print(worker_time) + + # We cannot spawn too close to a snake on the left, so add a condition + var spawn_positions : Array[Vector2] = [] + for pos in all_spawn_positions: + if (pos + Vector2.LEFT * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions and \ + (pos + Vector2.LEFT * TILE_SIZE * 2).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions: + spawn_positions.append(pos) + + var slightly_preferred_spawns : Array[Vector2] = [] + for pos in spawn_positions: + if (pos + Vector2.RIGHT * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions: + slightly_preferred_spawns.append(pos) + + var most_preferred_spawns : Array[Vector2] = [] + for pos in slightly_preferred_spawns: + if (pos + Vector2.UP * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions and \ + (pos + Vector2.DOWN * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions: + most_preferred_spawns.append(pos) + + if !most_preferred_spawns.is_empty(): + spawn_positions = most_preferred_spawns + elif !slightly_preferred_spawns.is_empty(): + spawn_positions = slightly_preferred_spawns + + # If there is no room to spawn a snake, we lose + if spawn_positions.is_empty(): + lose_game() + return + + # Pick a position + var chosen_position : Vector2 = spawn_positions.pick_random() + + # Spawn in the new snake + var new_head = snake_part_obj.instantiate() + new_head.position = chosen_position.snapped(Vector2.ONE * TILE_SIZE) + get_parent().add_child(new_head) + new_head.on_ouroboros.connect(ouroboros) func _unhandled_input(event: InputEvent) -> void: @@ -161,5 +298,5 @@ func _unhandled_input(event: InputEvent) -> void: # If we leave the arena -func _on_level_area_exited(area: Area2D) -> void: +func _on_level_area_exited(_area: Area2D) -> void: lose_game() diff --git a/project.godot b/project.godot index 00507ff..beb344d 100644 --- a/project.godot +++ b/project.godot @@ -14,10 +14,15 @@ config/name="Ouroboros" config/features=PackedStringArray("4.4", "GL Compatibility") config/icon="res://icon.svg" +[autoload] + +GridManager="*res://Scripts/grid_manager.gd" + [global_group] Level="" BlocksSpawn="" +GameClock="" [input]