From 9f65823f2d1d16394f3f3465ad323fe41fabba53 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Fri, 2 Oct 2026 12:27:26 +0200 Subject: [PATCH] Vitrine der Stilproben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Startmenü führt zu einer Vitrine, die den gewählten Stil an einer kleinen Szene zeigt: Fels, toter und lebendiger Boden, Lebensmoos, eine Ranke, die Steine in eine erwachende Ruine trägt, eine wachsende Faserpflanze, ein hüpfendes Weidetier und zwei Spielfiguren. Knöpfe, die Tasten 1 bis 3 und F2 wechseln den Stil, Esc führt zurück ins Menü. Co-Authored-By: Claude Haiku 4.5 --- game/i18n/texts.csv | 12 ++ game/scripts/input_setup.gd | 5 + game/scripts/main.gd | 31 ++++ game/scripts/menu.gd | 8 +- game/scripts/showcase.gd | 268 ++++++++++++++++++++++++++++++++ game/scripts/showcase.gd.uid | 1 + game/tests/test_input_setup.gd | 2 +- game/tests/test_main.gd | 15 ++ game/tests/test_menu.gd | 10 ++ game/tests/test_showcase.gd | 61 ++++++++ game/tests/test_showcase.gd.uid | 1 + 11 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 game/scripts/showcase.gd create mode 100644 game/scripts/showcase.gd.uid create mode 100644 game/tests/test_showcase.gd create mode 100644 game/tests/test_showcase.gd.uid diff --git a/game/i18n/texts.csv b/game/i18n/texts.csv index d6d2985..edc5fb7 100644 --- a/game/i18n/texts.csv +++ b/game/i18n/texts.csv @@ -38,3 +38,15 @@ SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be STYLE_WARM,Warm,Warm STYLE_PASTEL,Pastell,Pastel STYLE_GLOW,Glimmen,Glow +MENU_SHOWCASE,Stilproben ansehen,See the style samples +SHOWCASE_TITLE,Stilprobe: %s,Style sample: %s +SHOWCASE_BACK,Zurück,Back +SHOWCASE_ROCK,Weicher und harter Fels,Soft and hard rock +SHOWCASE_DEAD_SOIL,Toter Boden,Dead soil +SHOWCASE_LIVING_SOIL,Lebendiger Boden,Living soil +SHOWCASE_MOSS,Lebensmoos,Life moss +SHOWCASE_PLANT,Faserpflanze wächst,A fibre plant grows +SHOWCASE_GRAZER,Weidetier,Grazer +SHOWCASE_VINE,Ranke trägt Steine,A vine carries stones +SHOWCASE_RUIN,Ruine erwacht,A ruin wakes +SHOWCASE_FIGURES,Spielfiguren,Player figures diff --git a/game/scripts/input_setup.gd b/game/scripts/input_setup.gd index 5c57097..f1c84ca 100644 --- a/game/scripts/input_setup.gd +++ b/game/scripts/input_setup.gd @@ -13,6 +13,11 @@ static func install() -> void: _add_keys("save_game", [KEY_F5]) _add_keys("load_game", [KEY_F9]) _add_keys("toggle_pause", [KEY_ESCAPE]) + _add_keys("next_style", [KEY_F2]) + _add_keys("back", [KEY_ESCAPE]) + _add_keys("style_1", [KEY_1]) + _add_keys("style_2", [KEY_2]) + _add_keys("style_3", [KEY_3]) _add_mouse_button("dig", MOUSE_BUTTON_LEFT) diff --git a/game/scripts/main.gd b/game/scripts/main.gd index 67bd0bc..d62787d 100644 --- a/game/scripts/main.gd +++ b/game/scripts/main.gd @@ -43,6 +43,7 @@ var clock: TickClock ## The style the playing field is drawn in. var art: Art var menu: Menu +var showcase: Showcase var map_view: MapView var marker: TileMarker var avatar: Avatar @@ -103,8 +104,16 @@ func _ready() -> void: add_child(menu) menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name() menu.address_field.text = options.address if not options.address.is_empty() else store.last_address() + art.changed.connect(_redraw_art) menu.play_chosen.connect(play) menu.join_chosen.connect(join) + menu.showcase_chosen.connect(open_showcase) + showcase = Showcase.new() + showcase.art = art + showcase.closed.connect(close_showcase) + showcase.hide() + showcase.process_mode = Node.PROCESS_MODE_DISABLED + add_child(showcase) _show_menu("") match options.start: "solo": @@ -223,6 +232,28 @@ func _show_world() -> void: _own_hits.clear() +## Shows the style samples instead of the menu. +func open_showcase() -> void: + menu.hide() + showcase.time = 0.0 + showcase.process_mode = Node.PROCESS_MODE_INHERIT + showcase.show() + + +## Goes back from the style samples to the menu. +func close_showcase() -> void: + showcase.hide() + showcase.process_mode = Node.PROCESS_MODE_DISABLED + menu.show() + + +## Draws the playing field again after the style changed. +func _redraw_art() -> void: + map_view.redraw() + for node: Node2D in [marker, figures, avatar]: + node.queue_redraw() + + ## Says in the status line how the game is shared, and with how many. func _update_status() -> void: if link == null: diff --git a/game/scripts/menu.gd b/game/scripts/menu.gd index 2c8e515..8f4e32d 100644 --- a/game/scripts/menu.gd +++ b/game/scripts/menu.gd @@ -1,16 +1,18 @@ class_name Menu extends CanvasLayer ## The start screen: the player's name, then playing alone, opening the world -## for guests, or joining a host. +## for guests, or joining a host. Below, the way to the style samples. signal play_chosen(open: bool) signal join_chosen(address: String) +signal showcase_chosen 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 showcase_button := Button.new() var _status := Label.new() @@ -39,15 +41,17 @@ func _ready() -> void: row.add_child(button) address_field.placeholder_text = tr("MENU_ADDRESS") join_button.text = tr("MENU_JOIN") + showcase_button.text = tr("MENU_SHOWCASE") _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]: + for control: Control in [title, name_field, row, HSeparator.new(), address_field, join_button, HSeparator.new(), showcase_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()) + showcase_button.pressed.connect(func() -> void: showcase_chosen.emit()) ## Shows `text` below the buttons. diff --git a/game/scripts/showcase.gd b/game/scripts/showcase.gd new file mode 100644 index 0000000..b641fd3 --- /dev/null +++ b/game/scripts/showcase.gd @@ -0,0 +1,268 @@ +class_name Showcase +extends CanvasLayer +## The showcase of the style samples: one small scene in the chosen style, +## with the terrain of the test map and things of later milestones. Dead +## soil turns living, moss glows, a vine carries stones into a waking ruin, a +## plant grows and an animal hops. Buttons switch the style. + +## The player wants to go back to the menu. +signal closed + +## The scene, one letter per tile: B edge of the map, S soft rock, H hard +## rock, . dead soil, L living soil. +const GRID: Array[String] = [ + "BBSSSHHH............", + "BSSSHHH.............", + "SS...H..............", + "..........LLLLLL....", + ".......LLLLLLLLLLLL.", + ".....LLLLLLLLLLLLLL.", + ".....LLLLLLLLLLLLLL.", + ".......LLLLLLLLLLL..", + ".........LLLLLL.....", + "....................", +] +## Pixels of the scene per pixel on screen. +const ZOOM := 3.0 +## Seconds from seed to grown plant, and the pause before it starts again. +const GROW_SECONDS := 6.0 +const GROWN_SECONDS := 2.0 +## Seconds between two hops, and how long a hop lasts. +const HOP_EVERY := 1.2 +const HOP_SECONDS := 0.5 +## How far stones travel along the vine per second, as a share of its length. +const STONE_SPEED := 0.1 +const STONES := 3 +## The path of the vine, in tiles: along the living soil into the ruin. +const VINE: Array[Vector2] = [ + Vector2(6.8, 6.9), Vector2(9.0, 7.3), Vector2(11.0, 6.9), + Vector2(13.0, 7.1), Vector2(15.0, 6.4), Vector2(17.3, 5.7), +] +## Where things stand, in tiles: the middle of their foot; for the figures +## the middle of the tile they stand on. +const MOSS: Array[Vector2] = [Vector2(8.0, 4.6), Vector2(9.8, 3.3), Vector2(14.2, 3.4), Vector2(16.8, 6.8)] +const PLANT := Vector2(10.9, 4.4) +const GRAZER_HOME := Vector2(12.8, 5.2) +## The ruin covers three by three tiles from here. +const RUIN := Vector2i(16, 3) +## The figures of two players: the own one and one of another colour. +const FIGURES: Array[Vector2] = [Vector2(3.0, 5.0), Vector2(1.6, 6.6)] + +var art: Art +## Seconds since the showcase opened. +var time := 0.0 +var style_buttons: Array[Button] = [] +var back_button := Button.new() +var _view := ShowcaseView.new() +var _title := Label.new() +var _captions: Array[Label] = [] +var _grid_codes := PackedByteArray() +var _solid := PackedByteArray() +var _living := PackedByteArray() + + +## The node that draws the scene. +class ShowcaseView extends Node2D: + var showcase: Showcase + + func _draw() -> void: + showcase.draw_scene(self) + + +func _ready() -> void: + _read_grid() + var background := ColorRect.new() + background.color = Color(0.08, 0.08, 0.09) + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(background) + _view.showcase = self + _view.scale = Vector2(ZOOM, ZOOM) + add_child(_view) + _title.add_theme_font_size_override("font_size", 28) + _title.position = Vector2(32, 20) + add_child(_title) + var row := HBoxContainer.new() + row.position = Vector2(32, 660) + row.add_theme_constant_override("separation", 12) + add_child(row) + for index in art.styles.size(): + var button := Button.new() + button.text = "%d %s" % [index + 1, tr(art.styles[index].title_key())] + button.pressed.connect(func() -> void: art.select(art.styles[index].key())) + style_buttons.append(button) + row.add_child(button) + back_button.text = tr("SHOWCASE_BACK") + back_button.pressed.connect(func() -> void: closed.emit()) + row.add_child(back_button) + _add_captions() + art.changed.connect(_on_style_changed) + _on_style_changed() + get_viewport().size_changed.connect(_layout) + _layout() + + +func _process(delta: float) -> void: + time += delta + _view.queue_redraw() + + +func _unhandled_input(event: InputEvent) -> void: + if not visible: + return + for index in mini(3, art.styles.size()): + if event.is_action_pressed("style_%d" % (index + 1)): + art.select(art.styles[index].key()) + if event.is_action_pressed("next_style"): + art.select_next() + elif event.is_action_pressed("back"): + closed.emit() + + +## How far the plant has grown at `seconds`, from 0 (a seed) to 1, over and +## over again. +static func growth_at(seconds: float) -> float: + return clampf(fposmod(seconds, GROW_SECONDS + GROWN_SECONDS) / GROW_SECONDS, 0.0, 1.0) + + +## How far the animal is through its hop at `seconds`: from 0 when it takes +## off to 1 when it lands, then 1 while it rests. +static func hop_at(seconds: float) -> float: + return clampf(fposmod(seconds, HOP_EVERY) / HOP_SECONDS, 0.0, 1.0) + + +## Where the animal is at `seconds`, in tiles: it hops around its home. +static func grazer_at(seconds: float) -> Vector2: + var hops := floorf(seconds / HOP_EVERY) + hop_at(seconds) + return GRAZER_HOME + Vector2(cos(hops * 0.9), sin(hops * 0.9) * 0.4) + + +## How far each stone is along the vine at `seconds`, from 0 to 1. +static func stones_at(seconds: float) -> Array[float]: + var shares: Array[float] = [] + for stone in STONES: + shares.append(fposmod(seconds * STONE_SPEED + float(stone) / STONES, 1.0)) + return shares + + +## How awake the ruin is at `seconds`: it wakes and dozes in slow breaths. +static func awake_at(seconds: float) -> float: + return 0.65 + 0.35 * sin(seconds * 1.2) + + +## The texts under the things in the scene, as keys. Tests read them. +func caption_keys() -> PackedStringArray: + var keys := PackedStringArray() + for label in _captions: + keys.append(label.get_meta("key")) + return keys + + +## Draws the whole scene onto `canvas`, while it draws. +func draw_scene(canvas: CanvasItem) -> void: + if art == null: + return + var size := GridMotion.TILE_SIZE + var width := GRID[0].length() + var height := GRID.size() + for y in height: + for x in width: + var tile := Vector2i(x, y) + var rect := Rect2(Vector2(tile) * size, Vector2(size, size)) + var code := _grid_codes[y * width + x] + var state := { + "solid": TileShape.mask(_solid, width, height, tile, false), + "living": TileShape.mask(_living, width, height, tile, false), + "variant": TileShape.variant(tile), + } + art.draw(canvas, _id_of(code), rect, state) + for at in MOSS: + art.draw(canvas, Art.MOSS, _tile_rect(at), {"variant": TileShape.variant(Vector2i(at)), "time": time}) + var path := PackedVector2Array() + for point in VINE: + path.append(point * size) + art.draw(canvas, Art.VINE, _bounds(path), {"points": path, "time": time}) + for share in stones_at(time): + var at := ArtStyle.along(path, share) + art.draw(canvas, Art.STONE, Rect2(at - Vector2(4, 5), Vector2(8, 8))) + art.draw(canvas, Art.FIBER_PLANT, _tile_rect(PLANT, Vector2(1.0, 1.8)), {"growth": growth_at(time), "time": time}) + var grazer := grazer_at(time) + var heading := grazer_at(time + 0.1) - grazer + art.draw(canvas, Art.GRAZER, _tile_rect(grazer), {"hop": hop_at(time), "facing": 1.0 if heading.x >= 0.0 else -1.0}) + var ruin := Rect2(Vector2(RUIN) * size, Vector2(3, 3) * size) + art.draw(canvas, Art.RUIN, ruin, {"awake": awake_at(time), "time": time}) + for player in FIGURES.size(): + var at := (FIGURES[player] + Vector2(0.5, 0.5)) * size + art.draw(canvas, Art.FIGURE, Avatar.figure_rect(at), {"player": player, "facing": 1.0, "step": time * 4.0 + player, "moving": true}) + + +func _read_grid() -> void: + var codes := {".": 0, "L": 1, "S": 2, "H": 3, "B": 4} + _grid_codes.clear() + for line in GRID: + for letter in line: + _grid_codes.append(codes[letter]) + _solid = TileShape.flags(_grid_codes, [2, 3, 4]) + _living = TileShape.flags(_grid_codes, [1]) + + +static func _id_of(code: int) -> StringName: + return [Art.DEAD_SOIL, Art.LIVING_SOIL, Art.SOFT_ROCK, Art.HARD_ROCK, Art.BEDROCK][code] + + +## A square of one tile, or of `size` tiles, whose bottom middle stands at +## `at` (in tiles) plus half a tile. +static func _tile_rect(at: Vector2, size := Vector2.ONE) -> Rect2: + var tile := GridMotion.TILE_SIZE + var foot := (at + Vector2(0.5, 1.0)) * tile + return Rect2(foot - Vector2(size.x * 0.5, size.y) * tile, size * tile) + + +static func _bounds(points: PackedVector2Array) -> Rect2: + var rect := Rect2(points[0], Vector2.ZERO) + for point in points: + rect = rect.expand(point) + return rect + + +func _add_captions() -> void: + var entries := [ + ["SHOWCASE_ROCK", Vector2(4.0, 3.1)], + ["SHOWCASE_DEAD_SOIL", Vector2(2.6, 8.8)], + ["SHOWCASE_LIVING_SOIL", Vector2(12.5, 9.15)], + ["SHOWCASE_MOSS", Vector2(8.4, 5.6)], + ["SHOWCASE_PLANT", Vector2(11.4, 2.3)], + ["SHOWCASE_GRAZER", Vector2(13.3, 6.3)], + ["SHOWCASE_VINE", Vector2(10.6, 7.7)], + ["SHOWCASE_RUIN", Vector2(17.5, 1.9)], + ["SHOWCASE_FIGURES", Vector2(2.6, 7.7)], + ] + for entry: Array in entries: + var label := Label.new() + label.text = tr(entry[0]) + label.set_meta("key", entry[0]) + label.set_meta("at", entry[1]) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 13) + label.add_theme_color_override("font_outline_color", Color(0.05, 0.05, 0.06)) + label.add_theme_constant_override("outline_size", 5) + _captions.append(label) + add_child(label) + + +## Puts the scene in the middle of the screen and the texts under their +## things. +func _layout() -> void: + var scene_size := Vector2(GRID[0].length(), GRID.size()) * GridMotion.TILE_SIZE * ZOOM + var screen := get_viewport().get_visible_rect().size + _view.position = ((screen - scene_size) / 2.0).floor() + for label in _captions: + var at: Vector2 = label.get_meta("at") + label.size = Vector2(160, 20) + label.position = _view.position + at * GridMotion.TILE_SIZE * ZOOM - Vector2(80, 0) + + +func _on_style_changed() -> void: + _title.text = tr("SHOWCASE_TITLE") % tr(art.style.title_key()) + for index in style_buttons.size(): + style_buttons[index].disabled = art.styles[index] == art.style + _view.queue_redraw() diff --git a/game/scripts/showcase.gd.uid b/game/scripts/showcase.gd.uid new file mode 100644 index 0000000..4f554d6 --- /dev/null +++ b/game/scripts/showcase.gd.uid @@ -0,0 +1 @@ +uid://bscs3cghniuuq diff --git a/game/tests/test_input_setup.gd b/game/tests/test_input_setup.gd index e6f5aae..19addff 100644 --- a/game/tests/test_input_setup.gd +++ b/game/tests/test_input_setup.gd @@ -3,7 +3,7 @@ extends TestCase func test_every_action_of_the_game_exists() -> void: InputSetup.install() - for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause"]: + for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause", "next_style", "back", "style_1", "style_2", "style_3"]: check(InputMap.has_action(action), "action %s" % action) diff --git a/game/tests/test_main.gd b/game/tests/test_main.gd index f8222e2..9475375 100644 --- a/game/tests/test_main.gd +++ b/game/tests/test_main.gd @@ -425,3 +425,18 @@ func test_own_hits_reported_late_show_no_second_burst() -> void: main._apply(reports) check_eq(main.map_view.burst_count(), 2, "two own hits, two bursts") _clean_up([directory]) + + +func test_the_style_samples_open_from_the_menu_and_lead_back_to_it() -> void: + var directory := TestFiles.temporary_dir("showcase") + var main := _start_main(directory) + main.menu.showcase_button.pressed.emit() + check(main.showcase.visible, "the samples show") + check(not main.menu.visible, "instead of the menu") + main.showcase.style_buttons[2].pressed.emit() + check_eq(main.art.style.key(), GlowStyle.KEY, "a button there chooses the style") + await tree.process_frame + main.showcase.back_button.pressed.emit() + check(main.menu.visible, "the menu is back") + check(not main.showcase.visible, "the samples are gone") + _clean_up([directory]) diff --git a/game/tests/test_menu.gd b/game/tests/test_menu.gd index 7238019..9818984 100644 --- a/game/tests/test_menu.gd +++ b/game/tests/test_menu.gd @@ -35,3 +35,13 @@ func test_the_name_and_the_status_line() -> void: 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) + + +func test_the_way_to_the_style_samples() -> void: + var menu := _menu() + var chosen := [0] + menu.showcase_chosen.connect(func() -> void: chosen[0] += 1) + check_eq(menu.showcase_button.text, tr("MENU_SHOWCASE"), "the button says where it leads") + menu.showcase_button.pressed.emit() + check_eq(chosen[0], 1, "it asks for the samples") + _free(menu) diff --git a/game/tests/test_showcase.gd b/game/tests/test_showcase.gd new file mode 100644 index 0000000..21b669d --- /dev/null +++ b/game/tests/test_showcase.gd @@ -0,0 +1,61 @@ +extends TestCase +## The showcase of the style samples: how its things move, and that it shows +## every style. + + +func test_the_plant_grows_from_a_seed_and_starts_again() -> void: + check_eq(Showcase.growth_at(0.0), 0.0, "a seed at first") + check(is_equal_approx(Showcase.growth_at(Showcase.GROW_SECONDS / 2.0), 0.5), "half grown halfway") + check_eq(Showcase.growth_at(Showcase.GROW_SECONDS), 1.0, "grown") + check_eq(Showcase.growth_at(Showcase.GROW_SECONDS + Showcase.GROWN_SECONDS - 0.01), 1.0, "it stays grown for a while") + check_eq(Showcase.growth_at(Showcase.GROW_SECONDS + Showcase.GROWN_SECONDS), 0.0, "then a seed again") + + +func test_the_animal_hops_then_rests_near_its_home() -> void: + check_eq(Showcase.hop_at(0.0), 0.0, "taking off") + check(is_equal_approx(Showcase.hop_at(Showcase.HOP_SECONDS / 2.0), 0.5), "in the air") + check_eq(Showcase.hop_at(Showcase.HOP_SECONDS), 1.0, "landed") + check_eq(Showcase.hop_at(Showcase.HOP_EVERY - 0.01), 1.0, "resting until the next hop") + for tenth in 100: + var at := Showcase.grazer_at(tenth * 0.1) + check(at.distance_to(Showcase.GRAZER_HOME) <= 1.01, "near home at %.1f s: %s" % [tenth * 0.1, at]) + + +func test_stones_travel_along_the_vine_one_after_another() -> void: + var start := Showcase.stones_at(0.0) + check_eq(start.size(), Showcase.STONES, "every stone") + check_eq(start[0], 0.0, "the first one starts") + for stone in start: + check(stone >= 0.0 and stone < 1.0, "on the vine: %f" % stone) + var later := Showcase.stones_at(1.0) + check(is_equal_approx(later[0], Showcase.STONE_SPEED), "it moves on") + var round_trip := Showcase.stones_at(1.0 / Showcase.STONE_SPEED) + check(is_equal_approx(round_trip[0], 0.0) or is_equal_approx(round_trip[0], 1.0), "and comes round again") + + +func test_the_ruin_wakes_and_dozes() -> void: + for tenth in 100: + var awake := Showcase.awake_at(tenth * 0.1) + check(awake >= 0.29 and awake <= 1.0, "between dozing and awake: %f" % awake) + + +func test_the_showcase_shows_every_style_and_names_its_things() -> void: + I18n.install() + InputSetup.install() + var art := Art.new() + var showcase := Showcase.new() + showcase.art = art + tree.root.add_child(showcase) + for index in art.styles.size(): + showcase.style_buttons[index].pressed.emit() + check_eq(art.style, art.styles[index], "button %d chooses its style" % (index + 1)) + check(showcase.style_buttons[index].disabled, "the chosen one cannot be pressed again") + await tree.process_frame + for key in showcase.caption_keys(): + check(tr(key) != key, "a text for %s" % key) + var closed := [0] + showcase.closed.connect(func() -> void: closed[0] += 1) + showcase.back_button.pressed.emit() + check_eq(closed[0], 1, "back leads away") + tree.root.remove_child(showcase) + showcase.free() diff --git a/game/tests/test_showcase.gd.uid b/game/tests/test_showcase.gd.uid new file mode 100644 index 0000000..dc49606 --- /dev/null +++ b/game/tests/test_showcase.gd.uid @@ -0,0 +1 @@ +uid://bx2nja50aj8ju