diff --git a/game/i18n/texts.csv b/game/i18n/texts.csv index 6df10eb..984664a 100644 --- a/game/i18n/texts.csv +++ b/game/i18n/texts.csv @@ -8,3 +8,30 @@ INVENTORY_FULL,Die Tasche ist voll,Your bag is full PAUSED,Pause,Paused SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause +DEFAULT_NAME,Spieler,Player +MENU_NAME,Dein Name,Your name +MENU_SOLO,Allein spielen,Play alone +MENU_HOST,Welt öffnen,Open your world +MENU_ADDRESS,Adresse des Hosts,Address of the host +MENU_JOIN,Beitreten,Join +CONNECTING,Verbinde mit %s …,Connecting to %s … +CONNECT_FAILED,Keine Verbindung zu %s,Could not reach %s +ADDRESS_INVALID,Das ist keine Adresse: %s,That is not an address: %s +HOST_LEFT,Der Host hat die Welt geschlossen,The host closed the world +PROTOCOL_ERROR,Die Verbindung zum Host ist gestört,The connection to the host is broken +REFUSED_OTHER_PROTOCOL,Der Host hat eine andere Version des Spiels,The host has another version of the game +REFUSED_OTHER_RULES,Der Host hat andere Spielregeln. Ihr braucht denselben Stand des Spiels.,The host has other rules. You need the same version of the game. +REFUSED_WORLD_FULL,Die Welt ist voll: Vier Spieler sind schon da,The world is full: four players are in it +REFUSED_TOO_MANY_PLAYERS,Diese Welt kennt schon 16 Spieler und nimmt keine neuen mehr auf,This world knows 16 players already and takes no new ones +REFUSED_ALREADY_PLAYING,Ein Spieler mit deinem Schlüssel ist schon in der Welt,A player with your key is in the world already +STATUS_HOST,Welt offen auf Port %d · Spieler: %d,World open on port %d · players: %d +STATUS_GUEST,Gast bei %s · Spieler: %d,Guest of %s · players: %d +OPEN_FAILED,Port %d lässt sich nicht öffnen,Port %d cannot be opened +PLAYER_JOINED,%s ist da,%s joined +PLAYER_LEFT,%s ist gegangen,%s left +RESYNCED,Welt mit dem Host abgeglichen,World matched with the host again +PAUSE_ONLY_ALONE,Pause gibt es nur ohne Gäste,Pausing only works without guests +LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when playing alone +HOST_SAVES,Nur der Host speichert,Only the host saves +KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now +SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now diff --git a/game/scripts/figures.gd b/game/scripts/figures.gd new file mode 100644 index 0000000..0809836 --- /dev/null +++ b/game/scripts/figures.gd @@ -0,0 +1,63 @@ +class_name Figures +extends Node2D +## The figures of the other players, with their names. A new position comes +## over the network about ten times a second; in between, each figure glides +## toward the latest one. + +## How quickly a figure closes the gap to its latest position, per second. +const GLIDE := 15.0 +const COLOR := Color(0.55, 0.8, 0.95) +const OUTLINE := Color(0.05, 0.1, 0.15) +const NAME_SIZE := 8 +const NAME_WIDTH := 80.0 + +var session: GwSession +var _shown: Dictionary[int, Vector2] = {} +var _latest: Dictionary[int, Vector2] = {} + + +## Moves the figure of `player` toward `position`. A figure that was not +## shown yet appears there at once. +func place(player: int, position: Vector2) -> void: + if not _shown.has(player): + _shown[player] = position + _latest[player] = position + queue_redraw() + + +## Removes the figure of `player`. +func forget(player: int) -> void: + _shown.erase(player) + _latest.erase(player) + queue_redraw() + + +func clear() -> void: + _shown.clear() + _latest.clear() + queue_redraw() + + +## Where the figure of `player` is shown, or Vector2.INF if it is not. +func shown_at(player: int) -> Vector2: + return _shown.get(player, Vector2.INF) + + +func _process(delta: float) -> void: + if _shown.is_empty(): + return + var weight := minf(1.0, GLIDE * delta) + for player in _shown: + _shown[player] = _shown[player].lerp(_latest[player], weight) + queue_redraw() + + +func _draw() -> void: + var font := ThemeDB.fallback_font + for player in _shown: + var at := _shown[player] + draw_circle(at, Avatar.RADIUS + 1.0, OUTLINE) + draw_circle(at, Avatar.RADIUS, COLOR) + if session != null: + var label := Vector2(at.x - NAME_WIDTH / 2.0, at.y - Avatar.RADIUS - 3.0) + draw_string(font, label, session.player_name(player), HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH, NAME_SIZE) diff --git a/game/scripts/figures.gd.uid b/game/scripts/figures.gd.uid new file mode 100644 index 0000000..f3220f0 --- /dev/null +++ b/game/scripts/figures.gd.uid @@ -0,0 +1 @@ +uid://0krucvyguufx diff --git a/game/scripts/hud.gd b/game/scripts/hud.gd index e81b625..e46352e 100644 --- a/game/scripts/hud.gd +++ b/game/scripts/hud.gd @@ -1,11 +1,13 @@ class_name Hud extends CanvasLayer -## Text over the game: the stone count, short messages and a help line. +## Text over the game: the stone count, short messages, a status line for +## playing together and a help line. const MESSAGE_SECONDS := 2.5 var _stone := Label.new() var _message := Label.new() +var _status := Label.new() var _help := Label.new() var _message_left := 0.0 @@ -15,9 +17,12 @@ func _ready() -> void: _stone.add_theme_font_size_override("font_size", 22) _message.position = Vector2(16, 48) _message.add_theme_font_size_override("font_size", 18) + _status.add_theme_font_size_override("font_size", 14) + _status.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT, Control.PRESET_MODE_MINSIZE, 16) + _status.grow_horizontal = Control.GROW_DIRECTION_BEGIN _help.text = tr("HELP") _help.add_theme_font_size_override("font_size", 14) - for label: Label in [_stone, _message, _help]: + for label: Label in [_stone, _message, _status, _help]: label.add_theme_color_override("font_outline_color", Color.BLACK) label.add_theme_constant_override("outline_size", 4) add_child(label) @@ -36,9 +41,10 @@ func show_stone(count: int) -> void: _stone.text = tr("STONE_COUNT") % count -## Shows the text for `key` for a few seconds. -func flash(key: String) -> void: - _message.text = tr(key) +## Shows the text for `key` for a few seconds, with `values` filled into +## its placeholders. +func flash(key: String, values: Array = []) -> void: + _message.text = tr(key) % values if not values.is_empty() else tr(key) _message_left = MESSAGE_SECONDS @@ -48,6 +54,16 @@ func show_paused(paused: bool) -> void: _message_left = 0.0 +## Shows `text` in the top right corner until it is replaced. +func show_status(text: String) -> void: + _status.text = text + + +## The text currently shown in the status line. Tests read it. +func status_text() -> String: + return _status.text + + ## The text currently shown as a message. Tests read it. func message() -> String: return _message.text diff --git a/game/scripts/menu.gd b/game/scripts/menu.gd new file mode 100644 index 0000000..2c8e515 --- /dev/null +++ b/game/scripts/menu.gd @@ -0,0 +1,65 @@ +class_name Menu +extends CanvasLayer +## The start screen: the player's name, then playing alone, opening the world +## for guests, or joining a host. + +signal play_chosen(open: bool) +signal join_chosen(address: String) + +var name_field := LineEdit.new() +var address_field := LineEdit.new() +var solo_button := Button.new() +var host_button := Button.new() +var join_button := Button.new() +var _status := Label.new() + + +func _ready() -> void: + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(center) + var panel := PanelContainer.new() + center.add_child(panel) + var box := VBoxContainer.new() + box.custom_minimum_size = Vector2(380, 0) + box.add_theme_constant_override("separation", 10) + panel.add_child(box) + + var title := Label.new() + title.text = "Grovewright" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 32) + name_field.placeholder_text = tr("MENU_NAME") + name_field.max_length = 32 + solo_button.text = tr("MENU_SOLO") + host_button.text = tr("MENU_HOST") + var row := HBoxContainer.new() + for button in [solo_button, host_button]: + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(button) + address_field.placeholder_text = tr("MENU_ADDRESS") + join_button.text = tr("MENU_JOIN") + _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _status.custom_minimum_size = Vector2(380, 0) + for control: Control in [title, name_field, row, HSeparator.new(), address_field, join_button, _status]: + box.add_child(control) + + solo_button.pressed.connect(func() -> void: play_chosen.emit(false)) + host_button.pressed.connect(func() -> void: play_chosen.emit(true)) + join_button.pressed.connect(func() -> void: join_chosen.emit(address_field.text.strip_edges())) + address_field.text_submitted.connect(func(_text: String) -> void: join_button.pressed.emit()) + + +## Shows `text` below the buttons. +func show_status(text: String) -> void: + _status.text = text + + +## The text shown below the buttons. Tests read it. +func status() -> String: + return _status.text + + +## The name the player typed, without spaces around it. +func player_name() -> String: + return name_field.text.strip_edges() diff --git a/game/scripts/menu.gd.uid b/game/scripts/menu.gd.uid new file mode 100644 index 0000000..adfd865 --- /dev/null +++ b/game/scripts/menu.gd.uid @@ -0,0 +1 @@ +uid://dppcwwh67rtu6 diff --git a/game/tests/test_figures.gd b/game/tests/test_figures.gd new file mode 100644 index 0000000..31cf682 --- /dev/null +++ b/game/tests/test_figures.gd @@ -0,0 +1,34 @@ +extends TestCase + + +func test_a_figure_appears_where_it_is_first_placed() -> void: + var figures := Figures.new() + check_eq(figures.shown_at(2), Vector2.INF, "nobody is shown at first") + figures.place(2, Vector2(10.0, 20.0)) + check_eq(figures.shown_at(2), Vector2(10.0, 20.0), "at once at its first position") + figures.free() + + +func test_a_figure_glides_toward_its_latest_position() -> void: + var figures := Figures.new() + figures.place(1, Vector2.ZERO) + figures.place(1, Vector2(100.0, 0.0)) + figures._process(0.02) + var halfway := figures.shown_at(1).x + check(halfway > 0.0 and halfway < 100.0, "part of the way after a frame: %s" % halfway) + for frame in 60: + figures._process(0.02) + check(figures.shown_at(1).distance_to(Vector2(100.0, 0.0)) < 0.01, "there after a second") + figures.free() + + +func test_figures_can_be_forgotten() -> void: + var figures := Figures.new() + figures.place(1, Vector2.ZERO) + figures.place(3, Vector2.ONE) + figures.forget(1) + check_eq(figures.shown_at(1), Vector2.INF, "one is gone") + check_eq(figures.shown_at(3), Vector2.ONE, "the other stays") + figures.clear() + check_eq(figures.shown_at(3), Vector2.INF, "all are gone") + figures.free() diff --git a/game/tests/test_figures.gd.uid b/game/tests/test_figures.gd.uid new file mode 100644 index 0000000..693c9a2 --- /dev/null +++ b/game/tests/test_figures.gd.uid @@ -0,0 +1 @@ +uid://x5pqgp2p1hku diff --git a/game/tests/test_hud.gd b/game/tests/test_hud.gd new file mode 100644 index 0000000..6d1019d --- /dev/null +++ b/game/tests/test_hud.gd @@ -0,0 +1,32 @@ +extends TestCase + + +func _hud() -> Hud: + I18n.install() + var hud := Hud.new() + tree.root.add_child(hud) + return hud + + +func _free(hud: Hud) -> void: + tree.root.remove_child(hud) + hud.free() + + +func test_a_message_can_carry_values() -> void: + var hud := _hud() + hud.flash("PLAYER_JOINED", ["Grace"]) + check_eq(hud.message(), tr("PLAYER_JOINED") % "Grace", "the name is filled in") + hud.flash("SAVED") + check_eq(hud.message(), tr("SAVED"), "a message without values") + _free(hud) + + +func test_the_status_line_stays_until_it_is_replaced() -> void: + var hud := _hud() + hud.show_status(tr("STATUS_HOST") % [24680, 2]) + hud.flash("SAVED") + hud._process(10.0) + check_eq(hud.message(), "", "the message is gone after a while") + check_eq(hud.status_text(), tr("STATUS_HOST") % [24680, 2], "the status line is not") + _free(hud) diff --git a/game/tests/test_hud.gd.uid b/game/tests/test_hud.gd.uid new file mode 100644 index 0000000..8e0b698 --- /dev/null +++ b/game/tests/test_hud.gd.uid @@ -0,0 +1 @@ +uid://8e4vlt0gnipa diff --git a/game/tests/test_menu.gd b/game/tests/test_menu.gd new file mode 100644 index 0000000..7238019 --- /dev/null +++ b/game/tests/test_menu.gd @@ -0,0 +1,37 @@ +extends TestCase + + +func _menu() -> Menu: + I18n.install() + var menu := Menu.new() + tree.root.add_child(menu) + return menu + + +func _free(menu: Menu) -> void: + tree.root.remove_child(menu) + menu.free() + + +func test_the_buttons_say_what_the_player_chose() -> void: + var menu := _menu() + var chosen: Array = [] + menu.play_chosen.connect(func(open: bool) -> void: chosen.append(["play", open])) + menu.join_chosen.connect(func(address: String) -> void: chosen.append(["join", address])) + menu.address_field.text = " 192.168.178.20:25000 " + menu.solo_button.pressed.emit() + menu.host_button.pressed.emit() + menu.join_button.pressed.emit() + menu.address_field.text_submitted.emit(menu.address_field.text) + check_eq(chosen, [["play", false], ["play", true], ["join", "192.168.178.20:25000"], ["join", "192.168.178.20:25000"]], "in order") + _free(menu) + + +func test_the_name_and_the_status_line() -> void: + var menu := _menu() + menu.name_field.text = " Ada " + check_eq(menu.player_name(), "Ada", "without the spaces around it") + menu.show_status("Verbinde …") + check_eq(menu.status(), "Verbinde …", "the status is shown") + check_eq(menu.solo_button.text, tr("MENU_SOLO"), "the labels come from the texts") + _free(menu) diff --git a/game/tests/test_menu.gd.uid b/game/tests/test_menu.gd.uid new file mode 100644 index 0000000..f64ffb9 --- /dev/null +++ b/game/tests/test_menu.gd.uid @@ -0,0 +1 @@ +uid://dh6uogkq28wa4