# Hurry Curry! - a game about cooking # Copyright (C) 2025 Hurry Curry! contributors # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, version 3 of the License only. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # class_name Game extends Node3D signal update_players(players: Dictionary) signal data_updated() signal in_lobby_updated(in_lobby: bool) signal join_state_updated(state: JoinState) signal text_message(message: TextMessage) signal update_tutorial_running(running: bool) class TextMessage: var username #: String var color: Color var text: String var timeout_initial: float var timeout_remaining enum SpectatingMode { CENTER, FREE, } enum JoinState { SPECTATING, WAITING, JOINED, } var my_player_id: float = -1 var item_names: Array = [] var item_index_by_name: Dictionary = {} var tile_names: Array = [] var tile_index_by_name: Dictionary = {} var tile_collide: Array = [] var tile_interact: Array = [] var maps: Array = [] var bot_algos: Array var text_message_history: Array[TextMessage] = [] var join_state: JoinState = JoinState.SPECTATING var in_lobby := false var is_replay := false var tutorial_running := false var tutorial_queue := [] var last_position = null # : Vector2? var players := {} var spectating_mode: SpectatingMode = SpectatingMode.CENTER @onready var mp: Multiplayer = $Multiplayer @onready var map: Map = $Map # TODO move all of this somewhere else @onready var overlay_lobby: Lobby = $"../Overlays/Lobby" @onready var overlay_score: Overlay = $"../Overlays/Score" @onready var overlay_popup_message: PopupMessage = $"../Overlays/PopupMessage" @onready var overlay_pinned_messages: PinnedItemMessages = $"../Overlays/PinnedMessages" @onready var overlay_announce_title: AnnounceTitle = $"../Overlays/AnnounceTitle" @onready var menu: GameMenu = $".." @onready var follow_camera: FollowCamera = $FollowCamera func _ready(): mp.packet.connect(handle_packet) mp.connection_closed.connect(func(reason: String): get_parent().replace_menu("res://gui/menus/error.tscn", [reason, menu.data]) ) mp.connect_to_url(menu.data) text_message.connect(func(m): text_message_history.push_back(m) while text_message_history.size() > 64: text_message_history.pop_front() ) func handle_packet(p): match p.type: "joined": my_player_id = p.id "data": item_names = p["data"]["item_names"] tile_names = p["data"]["tile_names"] tile_collide = p["data"]["tile_collide"] tile_interact = p["data"]["tile_interact"] maps = p["data"]["maps"] bot_algos = p["data"]["bot_algos"] Global.hand_count = p["data"]["hand_count"] Global.hand_count_change.emit(Global.hand_count) tile_index_by_name.clear() for id in tile_names.size(): tile_index_by_name[tile_names[id]] = id item_index_by_name.clear() for i in range(item_names.size()): item_index_by_name[item_names[i]] = i Global.last_map_name = Global.current_map_name Global.current_map_name = p["data"]["current_map"] data_updated.emit() "add_player": var player_instance: Player if p.id == my_player_id: player_instance = ControllablePlayer.new(p.id, p.name, p.position, p.character, p.class, self) in_lobby_updated.connect(player_instance.onscreen_controls.in_lobby_updated) player_instance.onscreen_controls.in_lobby_updated(in_lobby) follow_camera.target = player_instance.movement_base follow_camera.reset() set_join_state(JoinState.JOINED) if Cli.opts.has("join-command"): mp.send_chat(my_player_id, Cli.opts["join-command"]) Cli.opts.erase("join-command") else: player_instance = Player.new(p.id, p.name, p.position, p.character, p.class, self) players[p.id] = player_instance add_child(player_instance) update_players.emit(players) "remove_player": var player: Player = players.get(p.id) if player == null: return if player.is_customer and player.current_item_message != null: tutorial_queue.erase(player.current_item_message) overlay_pinned_messages.clear_item(p.id) if p.id == my_player_id: set_join_state(JoinState.SPECTATING) follow_camera.target = $Center last_position = null for h in player.hand: if h != null: h.queue_free() players.erase(p.id) player.is_despawning = true update_players.emit(players) "movement": if not players.has(p.player): return var player_instance: Player = players[p.player] player_instance.update_position(p.pos, p.rot, p.boost) if p.player == my_player_id: last_position = p.pos "movement_sync": if not players.has(my_player_id): return var player_instance: ControllablePlayer = players[my_player_id] if last_position != null: player_instance.position_ = last_position "move_item": if "player" in p.from and "player" in p.to: players[p.from.player[0]].pass_to(players[p.to.player[0]], int(p.from.player[1]), int(p.to.player[1])) elif "tile" in p.from and "player" in p.to: var t: Tile = map.get_tile_instance(p.from.tile) players[p.to.player[0]].take_item(t, int(p.to.player[1])) elif "player" in p.from and "tile" in p.to: var t: Tile = map.get_tile_instance(p.to.tile) players[p.from.player[0]].put_item(t, int(p.from.player[1])) elif "tile" in p.from and "tile" in p.to: var from_tile2: Tile = map.get_tile_instance(p.from.tile) var to_tile2: Tile = map.get_tile_instance(p.to.tile) from_tile2.pass_to(to_tile2) "set_progress": if "tile" in p.item: var t: Tile = map.get_tile_instance(p.item.tile) t.progress(p.position, p.speed, p.warn, players.get(p.player)) else: players[p.item.player[0]].progress(p.position, p.speed, p.warn, int(p.item.player[1])) "clear_progress": if "tile" in p.item: var t: Tile = map.get_tile_instance(p.item.tile) t.finish() else: players[p.item.player[0]].finish(int(p.item.player[1])) "set_item": var location: Dictionary = p["location"] if p.item != null: if "tile" in p.location: var t: Tile = map.get_tile_instance(p.location.tile) var i = ItemFactory.produce(item_names[p.item], t.item_base) i.animate_spawn() i.position = t.item_base.global_position add_child(i) i.name = item_names[p.item] t.set_item(i) else: var pl: Player = players[p.location.player[0]] var h = p.location.player[1] var i = ItemFactory.produce(item_names[p.item], pl.hand_base[h]) i.animate_spawn() i.position = pl.hand_base[h].global_position add_child(i) i.name = item_names[p.item] pl.set_item(i, h) else: if "tile" in p.location: var t: Tile = map.get_tile_instance(p.location.tile) t.finish() t.set_item(null) else: var pl: Player = players[p.location.player[0]] var h = p.location.player[1] pl.finish(h) pl.set_item(null, h) "update_map": var neighbors: Array = p["neighbors"] if p.kind != null: if neighbors != null: neighbors = neighbors.map(func(x): return tile_names[x] if x != null else null) map.set_tile(p.tile, tile_names[p.kind], neighbors) else: map.clear_tile(p.tile) "flush_map": map.flush() "communicate": var timeout_initial: float = p.timeout.initial if p.timeout != null else 5. var timeout_remaining: float = p.timeout.remaining if p.timeout != null else 5. var pinned: bool = p.timeout.pinned if p.timeout != null and "pinned" in p.timeout else false if p.message != null: if "item" in p.message: var item_name: String = item_names[p.message.item] var parsed_item := ItemFactory.ParsedItem.new(item_name) var ingredients := [parsed_item.name] ingredients.append_array(parsed_item.contents) if pinned: overlay_pinned_messages.pin_item(item_name, timeout_initial, timeout_remaining, p.player) var player: Player = players[p.player] player.item_message(item_name, timeout_initial, timeout_remaining) # Maybe start tutorial # after joining, the last item message that popped up is ignored. the next one will # be used for the tutorial if (player.is_customer and not Settings.read("gameplay.tutorial_disabled") and join_state == JoinState.JOINED): var completed_ingredients: Array = Profile.read("tutorial_ingredients_played") var completed := Global.array_has_all(completed_ingredients, ingredients) if not completed: if tutorial_running: tutorial_queue.push_back(item_name) else: tutorial_running = true update_tutorial_running.emit(tutorial_running) mp.send_chat(my_player_id, "/start-tutorial %s" % item_name) elif "text" in p.message or "translation" in p.message: var data = TextMessage.new() data.timeout_initial = timeout_initial data.timeout_remaining = timeout_remaining if pinned: push_error("Pinned text messages are currently not supported") var player: Player = players[p.player] data.color = Character.COLORS[G.rem_euclid(player.character_style.color, Character.COLORS.size())] data.username = players[p.player].username data.text = p.message.text if "text" in p.message else get_message_str(p.message) player.text_message(data) text_message.emit(data) elif "tile" in p.message: push_error("TODO: tile message") else: push_error("unknown message kind") else: var player: Player = players[p.player] if player.is_customer and player.current_item_message != null: tutorial_queue.erase(player.current_item_message) player.clear_text_message() player.clear_item_message() player.clear_effect() overlay_pinned_messages.clear_item(p.player) "effect": players[p.player].effect_message(p.name) "set_ingame": in_lobby = p.lobby overlay_score.set_ingame(p.state, p.lobby) follow_camera.set_ingame(p.state, p.lobby) if p.state: map.gi_bake() await get_parent()._menu_open() map.autobake = true in_lobby_updated.emit(in_lobby) else: map.autobake = false await get_parent()._menu_exit() if in_lobby: overlay_lobby.select_map(0) if join_state == JoinState.SPECTATING: if in_lobby: toggle_join() elif not is_replay: menu.submenu("res://gui/menus/ingame.tscn") "score": if p.time_remaining != null: overlay_score.update(p.demands_failed, p.demands_completed, p.points, p.time_remaining) "tutorial_ended": if p.player != my_player_id: return tutorial_running = false update_tutorial_running.emit(tutorial_running) if p.success: var completed_item := ItemFactory.ParsedItem.new(item_names[p.item]) var played: Array = Profile.read("tutorial_ingredients_played") played.append(completed_item.name) played.append_array(completed_item.contents) Profile.write("tutorial_ingredients_played", played) Profile.save() while item_names[p.item] in tutorial_queue: tutorial_queue.erase(item_names[p.item]) if not tutorial_queue.is_empty() and not Settings.read("gameplay.tutorial_disabled"): tutorial_running = true update_tutorial_running.emit(tutorial_running) mp.send_chat(my_player_id, "/start-tutorial %s" % tutorial_queue.pop_front()) else: tutorial_queue.clear() "menu": match p.menu: "document": menu.submenu("res://gui/menus/document/document.tscn", p["data"]) "score": menu.submenu("res://gui/menus/rating/rating.tscn", [p.data.stars, p.data.points]) "announce_start": overlay_announce_title.announce_start() "server_message": var mstr := get_message_str(p.message) if p.error: overlay_popup_message.display_server_msg(tr("c.error.server").format([mstr])) else: overlay_popup_message.display_server_msg(mstr) "server_hint": if p.player != my_player_id: return var message = p.get("message") var position_ = p.get("position") if position_ == null: # Global hint message if message == null: overlay_popup_message.clear_server_msg() else: overlay_popup_message.display_server_msg(get_message_str(message), false) else: # Positional hint message if message == null: overlay_popup_message.clear_server_msg(position_) else: overlay_popup_message.display_server_msg_positional(get_message_str(message), position_, false) "environment": $Environment.update(p.effects) "redirect": get_parent().replace_menu("res://gui/menus/game.tscn", p.uri[0]) "replay_start": is_replay = true "replay_stop": if is_replay and OS.has_feature("movie"): menu.exit() "pause": overlay_score.set_paused(p.state) Global.game_paused = p.state _: push_error("Unrecognized packet type: %s" % p.type) func system_message(s: String): var message = TextMessage.new() message.text = s message.color = Color.GOLD message.timeout_remaining = 5. text_message.emit(message) func set_join_state(state: JoinState): join_state = state join_state_updated.emit(state) func toggle_join(): match join_state: JoinState.SPECTATING: set_join_state(JoinState.WAITING) mp.send_join(Profile.read("username"), Profile.read("character_style")) JoinState.WAITING: push_error("Join/Leave action already toggled.") JoinState.JOINED: set_join_state(JoinState.WAITING) mp.send_leave(my_player_id) func _process(delta): update_center() if is_replay and mp != null: mp.send_replay_tick(delta) # TODO: move into PopupMessage and use RichTextLabel for tile/item images func get_message_str(m: Dictionary) -> String: if "translation" in m: return tr(m.translation.id).format(m.translation.params.map(get_message_str)) if "tile" in m.keys(): return tile_names[m.tile] if "item" in m.keys(): return item_names[m.item] return Global.get_message_str(m) func get_tile_collision(pos: Vector2i) -> bool: var t = map.get_tile_name(pos) if t == null: return true else: return tile_collide[tile_index_by_name[t]] func get_tile_interactive(pos: Vector2i) -> bool: var t = map.get_tile_name(pos) if t == null: return false else: if map.get_tile_instance(pos).item != null: return true return tile_interact[tile_index_by_name[t]] func update_center(): $FollowCamera.autozoom = spectating_mode == SpectatingMode.CENTER and join_state == JoinState.SPECTATING if join_state != JoinState.SPECTATING: return if Input.get_vector("left", "right", "forwards", "backwards").normalized().length() > .1: spectating_mode = SpectatingMode.FREE if Input.is_action_just_pressed("zoom_out_discrete") or Input.is_action_just_pressed("zoom_in_discrete"): spectating_mode = SpectatingMode.FREE if abs(Input.get_axis("zoom_in", "zoom_out")) > .1: spectating_mode = SpectatingMode.FREE elif spectating_mode == SpectatingMode.FREE and Input.is_action_just_pressed("reset"): spectating_mode = SpectatingMode.CENTER match spectating_mode: SpectatingMode.CENTER: spectate_center() SpectatingMode.FREE: spectate_free() func spectate_center(): var any_chefs = false for v in players.values(): any_chefs = any_chefs or v.is_chef var no_chefs = not any_chefs var sum: int = 0 var center: Vector3 = Vector3(0.,0.,0.) for p in players.values(): if p.is_chef or no_chefs: sum += 1 center += p.movement_base.position var bmin = Vector2.INF var bmax = -Vector2.INF for p in players.values(): if p.is_chef or no_chefs: bmin.x = min(bmin.x, p.movement_base.position.x) bmin.y = min(bmin.y, p.movement_base.position.z) bmax.x = max(bmax.x, p.movement_base.position.x) bmax.y = max(bmax.y, p.movement_base.position.z) var extent = max(bmax.x - bmin.x, bmax.y - bmin.y) if sum > 0: $Center.position = center / sum $FollowCamera.camera_distance_target = max(extent * 2, 8) elif sum > 0: $Center.position = center / sum $FollowCamera.camera_distance_target = max(extent * 2, 8) else: var extents = map.extents() var map_center = ((extents[0] + extents[1]) / 2) + Vector2(.5, .5) $Center.position = Vector3(map_center.x, 0.,map_center.y) $FollowCamera.camera_distance_target = (extents[1] - extents[0]).length() / 2 func spectate_free(): var direction := Input.get_vector("left", "right", "forwards", "backwards") direction = direction.rotated(-follow_camera.angle_target) $Center.position += Vector3( direction.x, $Center.position.y, direction.y ) * get_process_delta_time() * 10. var extents = map.extents() $Center.position.x = clamp($Center.position.x, extents[0].x, extents[1].x) $Center.position.z = clamp($Center.position.z, extents[0].y, extents[1].y)