extends Area2D class_name SnakePart const TILE_SIZE = 40 enum PartTypes {HEAD, BODY, TAIL} enum States {ALIVE, DEAD, OUROBOROS, OLD_OUROBOROS} const COLOURS = [Color.DARK_RED, Color.ORANGE_RED, Color.YELLOW, Color.WEB_GREEN, Color.DARK_CYAN, Color.BLUE, Color.REBECCA_PURPLE] var part_type : PartTypes = PartTypes.HEAD var state : States = States.ALIVE var colour_index : int = 0 var current_direction : Vector2 = Vector2.RIGHT var old_direction : Vector2 = Vector2.RIGHT var buffered_direction : Vector2 = Vector2.ZERO var should_buffer_direction : bool = false var inputs : Dictionary[String, Vector2] = {"right": Vector2.RIGHT, "down": Vector2.DOWN, "left": Vector2.LEFT, "up": Vector2.UP,} var possible_x : Array var possible_y : Array @onready var raycast_right : RayCast2D = $RightRayCast2D @onready var raycast_left : RayCast2D = $LeftRayCast2D @onready var raycast_up : RayCast2D = $UpRayCast2D @onready var raycast_down : RayCast2D = $DownRayCast2D var snake_part_obj : PackedScene = preload("res://Scenes/snake_part.tscn") @onready var timer_ref : Timer = $"../Timer" @onready var game_over_menu : Panel = get_tree().get_first_node_in_group("GameOverMenu") @onready var sprite : Sprite2D = $Sprite2D var image_head : CompressedTexture2D = preload("res://Sprites/snake_head.png") var image_head_dead : CompressedTexture2D = preload("res://Sprites/snake_head_dead.png") var image_head_ouroboros : CompressedTexture2D = preload("res://Sprites/snake_head_ouroboros.png") var image_head_ouroboros_left : CompressedTexture2D = preload("res://Sprites/snake_head_ouroboros_left.png") var image_head_ouroboros_right : CompressedTexture2D = preload("res://Sprites/snake_head_ouroboros_right.png") var image_left : CompressedTexture2D = preload("res://Sprites/snake_left.png") var image_right : CompressedTexture2D = preload("res://Sprites/snake_right.png") var image_straight : CompressedTexture2D = preload("res://Sprites/snake_straight.png") var image_tail : CompressedTexture2D = preload("res://Sprites/snake_tail.png") var next_part : SnakePart = null var commanding_head : SnakePart = null var skip_next_move_propagation : bool = false var queued_growth : int = 0 var screen_touching : bool = false var screen_start_pos : Vector2 = Vector2.ZERO var screen_tracking_index : int = 0 signal on_movement(new_dir, prevent_move) signal on_death signal on_ouroboros func _ready() -> void: if part_type == PartTypes.HEAD: # Add to group for easy access from other nodes add_to_group("Head") # Work out allowed positions # 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 possible_x = range(x_bounds.x, x_bounds.y + 20, TILE_SIZE) possible_y = range(y_bounds.x, y_bounds.y + 20, TILE_SIZE) # 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, false)) # Set up a body part var body = snake_part_obj.instantiate() body.part_type = PartTypes.BODY body.colour_index = colour_index body.position = (position - current_direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) next_part = body get_parent().add_child.call_deferred(body) 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() tail.part_type = PartTypes.TAIL tail.colour_index = colour_index 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.on_movement.connect(tail.process_movement) on_death.connect(tail.lose_game) on_ouroboros.connect(tail.ouroboros) tail.commanding_head = self else: # Free raycasts if we don't need them (consider only loading them in for the head for performance if needed) raycast_down.queue_free() raycast_up.queue_free() raycast_left.queue_free() raycast_right.queue_free() update_sprite() # 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, prevent_movment : bool = false) -> 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 and state == States.ALIVE: extend() # Only alive snakes can move update_sprite() if state != States.ALIVE: return # Update the position of this part if !prevent_movment: 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 on_movement.emit(current_direction, prevent_movment or skip_next_move_propagation) skip_next_move_propagation = false # Unless we are the head, change our direction to the direction given if !prevent_movment: old_direction = current_direction if new_direction != Vector2.ZERO and !prevent_movment: current_direction = new_direction # Update the sprite update_sprite() if buffered_direction != Vector2.ZERO: current_direction = buffered_direction buffered_direction = Vector2.ZERO else: should_buffer_direction = false func update_sprite() -> void: sprite.modulate = COLOURS[colour_index % len(COLOURS)] # Set the sprite based on state and part type match part_type: PartTypes.HEAD: match state: States.ALIVE: sprite.texture = image_head States.DEAD: sprite.texture = image_head_dead return _: var tail_rel_direction : float = current_direction.angle_to(old_direction) if abs(tail_rel_direction) < 0.1: sprite.texture = image_head_ouroboros elif tail_rel_direction > 0: sprite.texture = image_head_ouroboros_left elif tail_rel_direction < 0: sprite.texture = image_head_ouroboros_right PartTypes.TAIL: if state == States.OUROBOROS or state == States.OLD_OUROBOROS: var tail_rel_direction : float = current_direction.angle_to(position - commanding_head.position) if abs(tail_rel_direction) < 0.1: sprite.texture = image_straight elif tail_rel_direction > 0: sprite.texture = image_left elif tail_rel_direction < 0: sprite.texture = image_right else: sprite.texture = image_tail PartTypes.BODY: var tail_rel_direction : float = current_direction.angle_to(old_direction) if abs(tail_rel_direction) < 0.1: sprite.texture = image_straight elif tail_rel_direction > 0: sprite.texture = image_left elif tail_rel_direction < 0: sprite.texture = image_right if current_direction != Vector2.ZERO: sprite.rotation = current_direction.angle() + PI / 2 else: sprite.rotation = old_direction.angle() + PI / 2 func check_movement() -> void: # Make sure we are going to stay in the arena var new_position : Vector2 = (position + current_direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) if not int(new_position.x) in possible_x or not int(new_position.y) in possible_y: lose_game() return # Get the correct raycast for the direction we are moving var raycast_to_use : RayCast2D if current_direction == Vector2.RIGHT: raycast_to_use = raycast_right elif current_direction == Vector2.LEFT: raycast_to_use = raycast_left elif current_direction == Vector2.UP: raycast_to_use = raycast_up elif current_direction == Vector2.DOWN: raycast_to_use = raycast_down # Look using the raycast for anything to collide with var object_in_path : Object = raycast_to_use.get_collider() # If we found nothing, stop processing collision if object_in_path == null: SoundPlayer.play_sound("move") return # If we found a snake part, then either end the game or perform an ouroboros maneouvre if object_in_path is SnakePart: 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: SoundPlayer.play_sound("eat") queued_growth += object_in_path.growth_amount object_in_path.respawn() func extend() -> void: # Remove old connection to previous next part 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) new_body.colour_index = colour_index get_parent().add_child(new_body) 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 new_body.current_direction = current_direction new_body.old_direction = old_direction # Update reference to next part next_part = new_body # Mark the next movement to not propagate the movement down the chain skip_next_move_propagation = true # Mark the growth step as done queued_growth -= 1 func lose_game(from_lack_of_space : bool = false) -> void: # You lose! SoundPlayer.play_sound("die") if !from_lack_of_space: state = States.DEAD on_death.emit() game_over_menu.visible = true func ouroboros() -> void: SoundPlayer.play_sound("ouroboros") if state == States.OUROBOROS: state = States.OLD_OUROBOROS elif state == States.ALIVE: state = States.OUROBOROS if part_type == PartTypes.TAIL: update_sprite() on_ouroboros.emit() # The head can spawn a new snake if part_type == PartTypes.HEAD and state == States.OUROBOROS: GameManager.score += 1 remove_from_group("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(true) 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) new_head.colour_index = colour_index + 1 new_head.on_ouroboros.connect(ouroboros) get_parent().add_child(new_head) func _unhandled_input(event: InputEvent) -> void: # Change direction if we are the head if event is InputEventKey and part_type == PartTypes.HEAD: for dir in inputs.keys(): if event.is_action_pressed(dir) and current_direction != -inputs[dir] and current_direction != inputs[dir]: if should_buffer_direction: buffered_direction = inputs[dir] else: current_direction = inputs[dir] should_buffer_direction = true if event is InputEventScreenTouch and part_type == PartTypes.HEAD:# Start of drag if event.is_pressed() and !screen_touching: screen_start_pos = event.position screen_tracking_index = event.index screen_touching = true # End of drag elif !event.is_pressed() and screen_touching and event.index == screen_tracking_index: screen_touching = false var drag_angle : float = (event.position - screen_start_pos).angle() var idx_direction : int = roundi(drag_angle / (PI / 2)) var dir : String = inputs.keys()[idx_direction] if current_direction != -inputs[dir] and current_direction != inputs[dir]: if should_buffer_direction: buffered_direction = inputs[dir] else: current_direction = inputs[dir] should_buffer_direction = true # If we leave the arena #func _on_level_area_exited(_area: Area2D) -> void: #lose_game()