From 5c7d37d1d655e5a68122afb85c01789aaa8441e3 Mon Sep 17 00:00:00 2001 From: bagggage Date: Wed, 15 Oct 2025 00:50:45 +0300 Subject: [PATCH 1/6] Add a disabled state to the button --- src/gui/components/Button.zig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/gui/components/Button.zig b/src/gui/components/Button.zig index 9f60f18c6f..12b415e489 100644 --- a/src/gui/components/Button.zig +++ b/src/gui/components/Button.zig @@ -54,6 +54,7 @@ pub var buttonUniforms: struct { pos: Vec2f, size: Vec2f, +disabled: bool = false, pressed: bool = false, hovered: bool = false, onAction: gui.Callback, @@ -127,20 +128,22 @@ pub fn mainButtonPressed(self: *Button, _: Vec2f) void { pub fn mainButtonReleased(self: *Button, mousePosition: Vec2f) void { if(self.pressed) { self.pressed = false; - if(GuiComponent.contains(self.pos, self.size, mousePosition)) { + if(!self.disabled and GuiComponent.contains(self.pos, self.size, mousePosition)) { self.onAction.run(); } } } pub fn render(self: *Button, mousePosition: Vec2f) void { - const textures = if(self.pressed) + const textures = if(self.disabled) + normalTextures + else if(self.pressed) pressedTextures else if(GuiComponent.contains(self.pos, self.size, mousePosition) and self.hovered) hoveredTextures else normalTextures; - draw.setColor(0xff000000); + draw.setColor(if(self.disabled) 0xa0000000 else 0xff000000); textures.texture.bindTo(0); pipeline.bind(draw.getScissor()); self.hovered = false; @@ -151,7 +154,7 @@ pub fn render(self: *Button, mousePosition: Vec2f) void { const lowerTexture = (textures.outlineTextureSize - Vec2f{1, 1})/Vec2f{2, 2}/textures.outlineTextureSize; const upperTexture = (textures.outlineTextureSize + Vec2f{1, 1})/Vec2f{2, 2}/textures.outlineTextureSize; textures.outlineTexture.bindTo(0); - draw.setColor(0xffffffff); + draw.setColor(if(self.disabled) 0xffa0a0a0 else 0xffffffff); // Corners: graphics.draw.boundSubImage(self.pos + Vec2f{0, 0}, cornerSize, .{0, 0}, cornerSizeUV); graphics.draw.boundSubImage(self.pos + Vec2f{self.size[0], 0} - Vec2f{cornerSize[0], 0}, cornerSize, .{upperTexture[0], 0}, cornerSizeUV); From 4e69bcfa6776b695f43d9c3592cab7bda57e944d Mon Sep 17 00:00:00 2001 From: bagggage Date: Wed, 15 Oct 2025 16:09:14 +0300 Subject: [PATCH 2/6] Implement Selectable GUI component --- src/gui/components/Selectable.zig | 139 ++++++++++++++++++++++++++++++ src/gui/gui_component.zig | 2 + 2 files changed, 141 insertions(+) create mode 100644 src/gui/components/Selectable.zig diff --git a/src/gui/components/Selectable.zig b/src/gui/components/Selectable.zig new file mode 100644 index 0000000000..1686e64a8d --- /dev/null +++ b/src/gui/components/Selectable.zig @@ -0,0 +1,139 @@ +const std = @import("std"); + +const main = @import("main"); +const graphics = main.graphics; +const draw = graphics.draw; +const Texture = graphics.Texture; +const vec = main.vec; +const Vec2f = vec.Vec2f; + +const gui = @import("../gui.zig"); +const GuiComponent = gui.GuiComponent; + +const Selectable = @This(); + +pos: Vec2f, +size: Vec2f, +child: ?GuiComponent = null, +onSelect: gui.Callback = .{}, +selected: bool = false, +hovered: bool = false, +pressed: bool = false, + +const normalColor: u32 = 0x00000000; +const hoveredColor: u32 = 0x40ffffff; +const selectedColor: u32 = 0x50000000; + +pub fn init(pos: Vec2f, size: Vec2f, onSelect: gui.Callback) *Selectable { + const self = main.globalAllocator.create(Selectable); + self.* = Selectable{.pos = pos, .size = size, .onSelect = onSelect}; + return self; +} + +pub fn deinit(self: *const Selectable) void { + if(self.child) |child| { + child.deinit(); + } + main.globalAllocator.destroy(self); +} + +pub fn select(self: *Selectable) void { + if(!self.selected) { + self.selected = true; + self.onSelect.run(); + } +} + +pub fn deselect(self: *Selectable) void { + self.selected = false; +} + +pub fn setChild(self: *Selectable, _child: anytype) void { + var child: GuiComponent = undefined; + if(@TypeOf(_child) == GuiComponent) { + child = _child; + } else { + child = _child.toComponent(); + } + self.child = child; + self.size[1] = @max(self.size[1], child.pos()[1] + child.size()[1]); + self.size[0] = @max(self.size[0], child.pos()[0] + child.size()[0]); +} + +pub fn finish(self: *Selectable, alignment: graphics.TextBuffer.Alignment) void { + const child = self.child orelse return; + + const mutPos = child.mutPos(); + const size = child.size(); + switch(alignment) { + .left => {}, + .center => { + mutPos.*[0] = mutPos.*[0]/2 + self.size[0]/2 - size[0]/2; + mutPos.*[1] = mutPos.*[1]/2 + self.size[1]/2 - size[1]/2; + }, + .right => { + mutPos.*[1] = self.size[1] - size[1]; + }, + } +} + +pub fn toComponent(self: *Selectable) GuiComponent { + return .{.selectable = self}; +} + +pub fn mainButtonPressed(self: *Selectable, mousePosition: Vec2f) void { + if(self.child) |child| { + if(GuiComponent.contains(child.pos() + self.pos, child.size(), mousePosition)) { + child.mainButtonPressed(mousePosition - self.pos); + } + } + + self.pressed = true; +} + +pub fn mainButtonReleased(self: *Selectable, mousePosition: Vec2f) void { + if(self.pressed) { + self.select(); + self.pressed = false; + } + + if(self.child) |child| { + child.mainButtonReleased(mousePosition); + } +} + +pub fn updateSelected(self: *Selectable) void { + if(self.child) |child| { + child.updateSelected(); + } +} + +pub fn updateHovered(self: *Selectable, mousePosition: Vec2f) void { + if(self.child) |child| { + if(GuiComponent.contains(child.pos() + self.pos, child.size(), mousePosition)) { + child.updateHovered(mousePosition - self.pos); + } + } + + self.hovered = true; +} + +pub fn render(self: *Selectable, mousePosition: Vec2f) void { + const color = if(self.selected) + selectedColor + else if(self.hovered) + hoveredColor + else + normalColor; + + draw.setColor(color); + draw.rect(self.pos, self.size); + + const oldTranslation = draw.setTranslation(self.pos); + if(self.child) |child| { + child.render(mousePosition - self.pos); + } + draw.restoreTranslation(oldTranslation); + + self.hovered = false; +} diff --git a/src/gui/gui_component.zig b/src/gui/gui_component.zig index d9fc7790d5..5251c200e1 100644 --- a/src/gui/gui_component.zig +++ b/src/gui/gui_component.zig @@ -12,6 +12,7 @@ pub const GuiComponent = union(enum) { pub const ItemSlot = @import("components/ItemSlot.zig"); pub const Label = @import("components/Label.zig"); pub const MutexComponent = @import("components/MutexComponent.zig"); + pub const Selectable = @import("components/Selectable.zig"); pub const ScrollBar = @import("components/ScrollBar.zig"); pub const ContinuousSlider = @import("components/ContinuousSlider.zig"); pub const DiscreteSlider = @import("components/DiscreteSlider.zig"); @@ -25,6 +26,7 @@ pub const GuiComponent = union(enum) { itemSlot: *ItemSlot, label: *Label, mutexComponent: *MutexComponent, + selectable: *Selectable, scrollBar: *ScrollBar, continuousSlider: *ContinuousSlider, discreteSlider: *DiscreteSlider, From 43de69dbe61880c34f236157252ed7f5de444b98 Mon Sep 17 00:00:00 2001 From: bagggage Date: Thu, 16 Oct 2025 23:55:52 +0300 Subject: [PATCH 3/6] Add obfuscation option for the TextInput component Related to #1929 --- src/gui/components/TextInput.zig | 112 +++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 19 deletions(-) diff --git a/src/gui/components/TextInput.zig b/src/gui/components/TextInput.zig index 9172f8d1a2..2105a2a1c6 100644 --- a/src/gui/components/TextInput.zig +++ b/src/gui/components/TextInput.zig @@ -19,11 +19,15 @@ const scrollBarWidth = 5; const border: f32 = 3; const fontSize: f32 = 16; +pub const obfuscationChar = "∗".*; +pub const obfuscatedStringBuffer = obfuscationChar ** 512; + var texture: Texture = undefined; pos: Vec2f, size: Vec2f, pressed: bool = false, +obfuscated: bool = false, cursor: ?u32 = null, selectionStart: ?u32 = null, currentString: main.List(u8), @@ -119,7 +123,7 @@ pub fn mainButtonPressed(self: *TextInput, mousePosition: Vec2f) void { const diff = self.textSize[1] - (self.maxHeight - 2*border); textPos[1] -= diff*self.scrollBar.currentState; } - self.selectionStart = self.textBuffer.mousePosToIndex(mousePosition - textPos - self.pos, self.currentString.items.len); + self.selectionStart = self.textBuffer.mousePosToIndex(mousePosition - textPos - self.pos, self.getBufferLen()); self.pressed = true; } @@ -130,7 +134,7 @@ pub fn mainButtonReleased(self: *TextInput, mousePosition: Vec2f) void { const diff = self.textSize[1] - (self.maxHeight - 2*border); textPos[1] -= diff*self.scrollBar.currentState; } - self.cursor = self.textBuffer.mousePosToIndex(mousePosition - textPos - self.pos, self.currentString.items.len); + self.cursor = self.textBuffer.mousePosToIndex(mousePosition - textPos - self.pos, self.getBufferLen()); if(self.cursor == self.selectionStart) { self.selectionStart = null; } @@ -148,7 +152,7 @@ pub fn select(self: *TextInput) void { self.pressed = false; self.selectionStart = null; if(self.cursor == null) - self.cursor = @intCast(self.currentString.items.len); + self.cursor = @intCast(self.getBufferLen()); } pub fn deselect(self: *TextInput) void { @@ -156,14 +160,55 @@ pub fn deselect(self: *TextInput) void { self.selectionStart = null; } +fn cursorPosFromObfuscated(self: *const TextInput) u32 { + var iter: std.unicode.Utf8Iterator = .{.bytes = self.currentString.items, .i = 0}; + return @intCast(iter.peek(self.cursor.?/obfuscationChar.len).len); +} + +fn cursorPosToObfuscated(self: *const TextInput) u32 { + const newCursor = (std.unicode.utf8CountCodepoints(self.currentString.items[0..self.cursor.?]) catch 0)*obfuscationChar.len; + return @intCast(newCursor); +} + +fn getBufferLen(self: *const TextInput) usize { + return if(self.obfuscated) self.textBuffer.glyphs.len*obfuscationChar.len else self.currentString.items.len; +} + +pub fn obfuscate(self: *TextInput) void { + if(self.obfuscated) return; + if(self.cursor != null) { + self.cursor = self.cursorPosToObfuscated(); + } + self.selectionStart = null; + self.obfuscated = true; + self.reloadText(); +} + +pub fn deobfuscate(self: *TextInput) void { + if(!self.obfuscated) return; + if(self.cursor != null) { + self.cursor = self.cursorPosFromObfuscated(); + } + self.selectionStart = null; + self.obfuscated = false; + self.reloadText(); +} + fn reloadText(self: *TextInput) void { + const displayText = if(self.obfuscated) blk: { + const len = (std.unicode.utf8CountCodepoints(self.currentString.items) catch 0)*obfuscationChar.len; + break :blk if(len > obfuscatedStringBuffer.len) &.{} else obfuscatedStringBuffer[0..len]; + } else self.currentString.items; + self.textBuffer.deinit(); - self.textBuffer = TextBuffer.init(main.globalAllocator, self.currentString.items, .{}, true, .left); + self.textBuffer = TextBuffer.init(main.globalAllocator, displayText, .{}, true, .left); self.textSize = self.textBuffer.calculateLineBreaks(fontSize, self.maxWidth - 2*border - scrollBarWidth); } fn moveCursorLeft(self: *TextInput, mods: main.Window.Key.Modifiers) void { - if(mods.control) { + if(self.obfuscated) { + self.cursor.? -= @min(self.cursor.?, obfuscationChar.len); + } else if(mods.control) { const text = self.currentString.items; if(self.cursor.? == 0) return; self.cursor.? -= 1; @@ -209,7 +254,10 @@ pub fn left(self: *TextInput, mods: main.Window.Key.Modifiers) void { } fn moveCursorRight(self: *TextInput, mods: main.Window.Key.Modifiers) void { - if(self.cursor.? < self.currentString.items.len) { + if(self.obfuscated) { + const len = self.textBuffer.glyphs.len*obfuscationChar.len; + self.cursor.? += if(self.cursor.? < len) obfuscationChar.len else 0; + } else if(self.cursor.? < self.currentString.items.len) { if(mods.control) { const text = self.currentString.items; // Find start of next "word": @@ -251,8 +299,8 @@ pub fn right(self: *TextInput, mods: main.Window.Key.Modifiers) void { } fn moveCursorVertically(self: *TextInput, relativeLines: f32) enum {changed, same} { - const newCursor = self.textBuffer.mousePosToIndex(self.textBuffer.indexToCursorPos(self.cursor.?) + Vec2f{0, 16*relativeLines}, self.currentString.items.len); - self.cursor = newCursor; + const newCursor = self.textBuffer.mousePosToIndex(self.textBuffer.indexToCursorPos(self.cursor.?) + Vec2f{0, 16*relativeLines}, self.getBufferLen()); + defer self.cursor = newCursor; if(self.cursor != newCursor) { return .changed; } @@ -308,7 +356,7 @@ pub fn up(self: *TextInput, mods: main.Window.Key.Modifiers) void { } fn moveCursorToStart(self: *TextInput, mods: main.Window.Key.Modifiers) void { - if(mods.control) { + if(mods.control or self.obfuscated) { self.cursor.? = 0; } else { self.cursor.? = @intCast(if(std.mem.lastIndexOf(u8, self.currentString.items[0..self.cursor.?], "\n")) |nextPos| nextPos + 1 else 0); @@ -338,7 +386,9 @@ pub fn gotoStart(self: *TextInput, mods: main.Window.Key.Modifiers) void { } fn moveCursorToEnd(self: *TextInput, mods: main.Window.Key.Modifiers) void { - if(mods.control) { + if(self.obfuscated) { + self.cursor.? = @intCast(self.textBuffer.glyphs.len*obfuscationChar.len); + } else if(mods.control) { self.cursor.? = @intCast(self.currentString.items.len); } else { self.cursor.? += @intCast(std.mem.indexOf(u8, self.currentString.items[self.cursor.?..], "\n") orelse self.currentString.items.len - self.cursor.?); @@ -372,7 +422,15 @@ fn deleteSelection(self: *TextInput) void { const start = @min(selectionStart, self.cursor.?); const end = @max(selectionStart, self.cursor.?); - self.currentString.replaceRange(start, end - start, &[0]u8{}); + if(self.obfuscated) { + var iter: std.unicode.Utf8Iterator = .{.bytes = self.currentString.items, .i = 0}; + const realStart = iter.peek(start/obfuscationChar.len).len; + const realEnd = iter.peek(end/obfuscationChar.len).len; + self.currentString.replaceRange(realStart, realEnd - realStart, &[0]u8{}); + } else { + self.currentString.replaceRange(start, end - start, &[0]u8{}); + } + self.cursor.? = start; self.selectionStart = null; self.ensureCursorVisibility(); @@ -406,9 +464,10 @@ pub fn inputCharacter(self: *TextInput, character: u21) void { self.deleteSelection(); var buf: [4]u8 = undefined; const utf8 = buf[0 .. std.unicode.utf8Encode(character, &buf) catch return]; - self.currentString.insertSlice(cursor.*, utf8); + const pos = if(self.obfuscated) self.cursorPosFromObfuscated() else cursor.*; + self.currentString.insertSlice(pos, utf8); self.reloadText(); - cursor.* += @intCast(utf8.len); + cursor.* += if(self.obfuscated) obfuscationChar.len else @intCast(utf8.len); self.ensureCursorVisibility(); } } @@ -417,14 +476,17 @@ pub fn setString(self: *TextInput, utf8EncodedString: []const u8) void { self.clear(); self.currentString.insertSlice(0, utf8EncodedString); self.reloadText(); - if(self.cursor != null) self.cursor = @intCast(utf8EncodedString.len); + if(self.cursor != null) { + const len = if(self.obfuscated) self.textBuffer.glyphs.len*obfuscationChar.len else utf8EncodedString.len; + self.cursor = @intCast(len); + } self.ensureCursorVisibility(); } pub fn selectAll(self: *TextInput, mods: main.Window.Key.Modifiers) void { if(mods.control) { self.selectionStart = 0; - self.cursor = @intCast(self.currentString.items.len); + self.cursor = @intCast(self.getBufferLen()); self.ensureCursorVisibility(); } } @@ -435,7 +497,15 @@ pub fn copy(self: *TextInput, mods: main.Window.Key.Modifiers) void { if(self.selectionStart) |selectionStart| { const start = @min(cursor, selectionStart); const end = @max(cursor, selectionStart); - main.Window.setClipboardString(self.currentString.items[start..end]); + if(self.obfuscated) { + var iter: std.unicode.Utf8Iterator = .{.bytes = self.currentString.items, .i = 0}; + const realStart = iter.peek(start/obfuscationChar.len).len; + iter.i = realStart; + const realEnd = realStart + iter.peek(end/obfuscationChar.len - start/obfuscationChar.len).len; + main.Window.setClipboardString(self.currentString.items[realStart..realEnd]); + } else { + main.Window.setClipboardString(self.currentString.items[start..end]); + } } } self.ensureCursorVisibility(); @@ -446,8 +516,12 @@ pub fn paste(self: *TextInput, mods: main.Window.Key.Modifiers) void { if(mods.control) { const string = main.Window.getClipboardString(); self.deleteSelection(); - self.currentString.insertSlice(self.cursor.?, string); - self.cursor.? += @intCast(string.len); + const pos = if(self.obfuscated) self.cursorPosFromObfuscated() else self.cursor.?; + self.currentString.insertSlice(pos, string); + self.cursor.? += if(self.obfuscated) blk: { + const len = (std.unicode.utf8CountCodepoints(string) catch 0)*obfuscationChar.len; + break :blk if(len > obfuscatedStringBuffer.len) 0 else @intCast(len); + } else @intCast(string.len); self.reloadText(); self.ensureCursorVisibility(); } @@ -506,7 +580,7 @@ pub fn render(self: *TextInput, mousePosition: Vec2f) void { } self.textBuffer.render(textPos[0], textPos[1], fontSize); if(self.pressed) { - self.cursor = self.textBuffer.mousePosToIndex(mousePosition - textPos - self.pos, self.currentString.items.len); + self.cursor = self.textBuffer.mousePosToIndex(mousePosition - textPos - self.pos, self.getBufferLen()); } if(self.cursor) |cursor| { const cursorPos = textPos + self.textBuffer.indexToCursorPos(cursor); From 93aad77c637355fe2d0126232e6aa6523a7d36c5 Mon Sep 17 00:00:00 2001 From: bagggage Date: Wed, 15 Oct 2025 16:11:06 +0300 Subject: [PATCH 4/6] Rework multiplayer window, implement a client-side server list --- src/gui/windows/_windowlist.zig | 1 + src/gui/windows/add_server.zig | 78 +++++++++++ src/gui/windows/multiplayer.zig | 224 +++++++++++++++++++++++--------- 3 files changed, 239 insertions(+), 64 deletions(-) create mode 100644 src/gui/windows/add_server.zig diff --git a/src/gui/windows/_windowlist.zig b/src/gui/windows/_windowlist.zig index 7c0077415b..93c6e8cf89 100644 --- a/src/gui/windows/_windowlist.zig +++ b/src/gui/windows/_windowlist.zig @@ -1,4 +1,5 @@ pub const advanced_controls = @import("advanced_controls.zig"); +pub const add_server = @import("add_server.zig"); pub const change_name = @import("change_name.zig"); pub const chat = @import("chat.zig"); pub const chest = @import("chest.zig"); diff --git a/src/gui/windows/add_server.zig b/src/gui/windows/add_server.zig new file mode 100644 index 0000000000..c84df4c7c3 --- /dev/null +++ b/src/gui/windows/add_server.zig @@ -0,0 +1,78 @@ +const std = @import("std"); + +const main = @import("main"); +const Vec2f = main.vec.Vec2f; + +const gui = @import("../gui.zig"); +const GuiWindow = gui.GuiWindow; +const Button = @import("../components/Button.zig"); +const Label = @import("../components/Label.zig"); +const TextInput = @import("../components/TextInput.zig"); +const VerticalList = @import("../components/VerticalList.zig"); +const multiplayer = @import("multiplayer.zig"); + +const padding: f32 = 8; +const width: f32 = 380; + +var nameEntry: *TextInput = undefined; +var addressEntry: *TextInput = undefined; +var addButton: *Button = undefined; + +pub var window = GuiWindow{ + .contentSize = Vec2f{128, 256}, +}; + +fn onEnterName(_: usize) void { + addressEntry.select(); +} + +fn isCorrectInput(name: []const u8, address: []const u8) bool { + const trimName = std.mem.trim(u8, name, " \t"); + if(trimName.len == 0 or address.len == 0) return false; + + const isNameWrong = std.mem.indexOfAny(u8, trimName, "\n\r\t") != null; + const isAddressWrong = std.mem.indexOfAny(u8, address, " \n\r\t<>!@#$%^&*(){}=+/*~,;\"\'\\") != null; + if(isNameWrong or isAddressWrong) return false; + + return true; +} + +fn addServer(_: usize) void { + const name = nameEntry.currentString.items; + const trimName = std.mem.trim(u8, name, " \t"); + const address = addressEntry.currentString.items; + + multiplayer.addServer(trimName, address); + gui.closeWindowFromRef(&window); +} + +pub fn onOpen() void { + const list = VerticalList.init(.{padding, 16 + padding}, 380, padding); + list.add(Label.init(.{0, 0}, width, "Name:", .left)); + nameEntry = TextInput.init(.{0, 0}, width, 24, "My Server", .{.callback = &onEnterName}, .{}); + list.add(nameEntry); + list.add(Label.init(.{0, 0}, width, "Address:", .left)); + addressEntry = TextInput.init(.{0, 0}, width, 24, "", .{.callback = &addServer}, .{}); + list.add(addressEntry); + addButton = Button.initText(.{0, 0}, 100, "Add", .{.callback = &addServer}); + addButton.disabled = true; + list.add(addButton); + list.finish(.center); + window.rootComponent = list.toComponent(); + window.contentSize = window.rootComponent.?.pos() + window.rootComponent.?.size() + @as(Vec2f, @splat(padding)); + window.scale = 1; + gui.updateWindowPositions(); +} + +pub fn onClose() void { + if(window.rootComponent) |*comp| { + comp.deinit(); + } +} + +pub fn update() void { + const name = nameEntry.currentString.items; + const address = addressEntry.currentString.items; + + addButton.disabled = !isCorrectInput(name, address); +} diff --git a/src/gui/windows/multiplayer.zig b/src/gui/windows/multiplayer.zig index c60a5751c1..193086f7ee 100644 --- a/src/gui/windows/multiplayer.zig +++ b/src/gui/windows/multiplayer.zig @@ -4,11 +4,14 @@ const main = @import("main"); const ConnectionManager = main.network.ConnectionManager; const settings = main.settings; const Vec2f = main.vec.Vec2f; +const ZonElement = @import("../../zon.zig").ZonElement; const gui = @import("../gui.zig"); const GuiComponent = gui.GuiComponent; const GuiWindow = gui.GuiWindow; const Button = @import("../components/Button.zig"); +const HorizontalList = @import("../components/HorizontalList.zig"); +const Selectable = @import("../components/Selectable.zig"); const Label = @import("../components/Label.zig"); const TextInput = @import("../components/TextInput.zig"); const VerticalList = @import("../components/VerticalList.zig"); @@ -17,48 +20,40 @@ pub var window = GuiWindow{ .contentSize = Vec2f{128, 256}, }; -var ipAddressLabel: *Label = undefined; -var ipAddressEntry: *TextInput = undefined; +const ServerInfo = struct { + name: []const u8 = &.{}, + address: []const u8 = &.{}, + pub fn deinit(self: *ServerInfo) void { + main.globalAllocator.free(self.name); + main.globalAllocator.free(self.address); + } +}; + +const serverListPath = "server_list.zig.zon"; +const width: f32 = 420; const padding: f32 = 8; +var servers: main.List(ServerInfo) = .init(main.globalAllocator); +var serverList: *VerticalList = undefined; +var selectedServerIdx: ?u32 = null; +var joinButton: *Button = undefined; +var removeButton: *Button = undefined; var connection: ?*ConnectionManager = null; -var ipAddress: []const u8 = ""; -var gotIpAddress: std.atomic.Value(bool) = .init(false); -var thread: ?std.Thread = null; -const width: f32 = 420; +var refresh: bool = false; -fn discoverIpAddress() void { - connection = ConnectionManager.init(main.settings.defaultPort, true) catch |err| { +fn join(_: usize) void { + const server = &servers.items[selectedServerIdx.?]; + connection = if(connection == null) ConnectionManager.init(main.settings.defaultPort, true) catch |err| { std.log.err("Could not open Connection: {s}", .{@errorName(err)}); - ipAddress = main.globalAllocator.dupe(u8, @errorName(err)); return; - }; - ipAddress = std.fmt.allocPrint(main.globalAllocator.allocator, "{f}", .{connection.?.externalAddress}) catch unreachable; - gotIpAddress.store(true, .release); -} - -fn discoverIpAddressFromNewThread() void { - main.initThreadLocals(); - defer main.deinitThreadLocals(); - - discoverIpAddress(); -} + } else connection; -fn join(_: usize) void { - if(thread) |_thread| { - _thread.join(); - thread = null; - } - if(ipAddress.len != 0) { - main.globalAllocator.free(ipAddress); - ipAddress = ""; - } if(connection) |_connection| { _connection.world = &main.game.testWorld; main.game.world = &main.game.testWorld; - std.log.info("Connecting to server: {s}", .{ipAddressEntry.currentString.items}); - main.game.testWorld.init(ipAddressEntry.currentString.items, _connection) catch |err| { + std.log.info("Connecting to server: {s}", .{server.address}); + main.game.testWorld.init(server.address, _connection) catch |err| { const formattedError = std.fmt.allocPrint(main.stackAllocator.allocator, "Encountered error while opening world: {s}", .{@errorName(err)}) catch unreachable; defer main.stackAllocator.free(formattedError); std.log.err("{s}", .{formattedError}); @@ -67,9 +62,6 @@ fn join(_: usize) void { _connection.world = null; return; }; - main.globalAllocator.free(settings.lastUsedIPAddress); - settings.lastUsedIPAddress = main.globalAllocator.dupe(u8, ipAddressEntry.currentString.items); - settings.save(); connection = null; } else { std.log.err("No connection found. Cannot connect.", .{}); @@ -81,44 +73,150 @@ fn join(_: usize) void { gui.openHud(); } -fn copyIp(_: usize) void { - main.Window.setClipboardString(ipAddress); +fn loadServerList() void { + servers.clearRetainingCapacity(); + const zon: ZonElement = main.files.cubyzDir().readToZon(main.stackAllocator, serverListPath) catch |err| blk: { + if(err != error.FileNotFound) { + std.log.err("Could not read server list file: {s}", .{@errorName(err)}); + } + break :blk .null; + }; + defer zon.deinit(main.stackAllocator); + + if(zon == .null) return; + if(zon != .array) { + std.log.err("Invalid format of server list file: {s}", .{@tagName(zon)}); + } + + const items = zon.array.items; + for(items, 0..) |*item, i| { + if(item.* != .object) { + std.log.err("Invalid entry type in server list file: {s}:{}", .{@tagName(item.*), i}); + } + + const name = item.object.get("name"); + const address = item.object.get("address"); + if(name == null or address == null) { + std.log.err("Invalid entry in server list file: {}", .{i}); + continue; + } + servers.append(.{ + .name = main.globalAllocator.dupe(u8, name.?.stringOwned), + .address = main.globalAllocator.dupe(u8, address.?.stringOwned) + }); + } +} + +fn saveServerList() void { + const zon = ZonElement.initArray(main.stackAllocator); + defer zon.deinit(main.stackAllocator); + + for(servers.items) |*item| { + const serverObject = ZonElement.initObject(main.stackAllocator); + serverObject.put("name", item.name); + serverObject.put("address", item.address); + zon.append(serverObject); + } + + main.files.cubyzDir().writeZon(serverListPath, zon) catch |err| { + std.log.err("Couldn't write server list to file: {s}", .{@errorName(err)}); + }; +} + +fn initServerElement(server: *const ServerInfo, idx: usize) *Selectable { + const nameWidth = width/5*2 - padding; + const addressWidth = width/5*3 - padding; + const element = HorizontalList.init(); + const panel = Selectable.init(.{0, 0}, .{0, 16 + padding*1.5}, .{.callback = &selectServer, .arg = idx}); + + element.add(Label.init(.{padding, 0}, nameWidth, server.name, .left)); + element.add(Label.init(.{padding, 0}, addressWidth, server.address, .left)); + element.finish(.{0, 0}, .center); + panel.setChild(element); + panel.finish(.center); + + return panel; +} + +fn refreshWindow() void { + refresh = true; + gui.closeWindowFromRef(&window); + gui.openWindowFromRef(&window); +} + +fn selectServer(serverIdx: usize) void { + if(selectedServerIdx) |idx| { + serverList.children.items[idx].selectable.deselect(); + } + + selectedServerIdx = @truncate(serverIdx); +} + +fn removeServer(_: usize) void { + var server = servers.orderedRemove(selectedServerIdx.?); + server.deinit(); + saveServerList(); + refreshWindow(); +} + +pub fn addServer(name: []const u8, address: []const u8) void { + const server = servers.addOne(); + server.* = .{ + .name = main.globalAllocator.dupe(u8, name), + .address = main.globalAllocator.dupe(u8, address) + }; + saveServerList(); + refreshWindow(); } pub fn onOpen() void { const list = VerticalList.init(.{padding, 16 + padding}, 300, 16); - list.add(Label.init(.{0, 0}, width, "Please send your IP to the host of the game and enter the host's IP below.", .center)); - // 255.255.255.255:?65536 (longest possible ip address) - ipAddressLabel = Label.init(.{0, 0}, width, " ", .center); - list.add(ipAddressLabel); - list.add(Button.initText(.{0, 0}, 100, "Copy IP", .{.callback = ©Ip})); - ipAddressEntry = TextInput.init(.{0, 0}, width, 32, settings.lastUsedIPAddress, .{.callback = &join}, .{}); - list.add(ipAddressEntry); - list.add(Button.initText(.{0, 0}, 100, "Join", .{.callback = &join})); + list.add(Label.init(.{0, 0}, 100, "**Multiplayer**", .center)); + if(!refresh) { + loadServerList(); + } + + if(servers.items.len == 0) { + list.add(Label.init(.{0, 0}, width/3*2, "#d0d0d0The list is empty :(\n\nAdd a new #ffffff__server__ #d0d0d0by clicking the *button* below!", .center)); + } + serverList = VerticalList.init(.{0, padding}, list.maxHeight/2, 0); + for(servers.items, 0..) |*server, i| { + serverList.add(initServerElement(server, i)); + } + serverList.finish(.left); + selectedServerIdx = null; + + const bottomPanel = HorizontalList.init(); + const buttonWidth = (width - padding*2)/3; + bottomPanel.add(Button.initText(.{0, 0}, buttonWidth, "Add server", gui.openWindowCallback("add_server"))); + joinButton = Button.initText(.{padding, 0}, buttonWidth, "Join", .{.callback = &join}); + joinButton.disabled = true; + bottomPanel.add(joinButton); + removeButton = Button.initText(.{padding, 0}, buttonWidth, "Remove", .{.callback = &removeServer}); + removeButton.disabled = true; + bottomPanel.add(removeButton); + bottomPanel.finish(.{0, 0}, .center); + + list.add(serverList); + list.add(bottomPanel); list.finish(.center); window.rootComponent = list.toComponent(); window.contentSize = window.rootComponent.?.pos() + window.rootComponent.?.size() + @as(Vec2f, @splat(padding)); gui.updateWindowPositions(); - thread = std.Thread.spawn(.{}, discoverIpAddressFromNewThread, .{}) catch |err| blk: { - std.log.err("Error spawning thread: {s}. Doing it in the current thread instead.", .{@errorName(err)}); - discoverIpAddress(); - break :blk null; - }; + refresh = false; } pub fn onClose() void { - if(thread) |_thread| { - _thread.join(); - thread = null; - } - if(connection) |_connection| { - _connection.deinit(); - connection = null; - } - if(ipAddress.len != 0) { - main.globalAllocator.free(ipAddress); - ipAddress = ""; + if(!refresh) { + if(connection) |_connection| { + _connection.deinit(); + connection = null; + } + if(servers.items.len != 0) { + for(0..servers.items.len) |i| servers.items[i].deinit(); + servers.clearAndFree(); + } } if(window.rootComponent) |*comp| { @@ -127,8 +225,6 @@ pub fn onClose() void { } pub fn update() void { - if(gotIpAddress.load(.acquire)) { - gotIpAddress.store(false, .monotonic); - ipAddressLabel.updateText(ipAddress); - } + joinButton.disabled = selectedServerIdx == null; + removeButton.disabled = joinButton.disabled; } From 80be1981e8e3ebdde64fcfd89a52f132f42fea87 Mon Sep 17 00:00:00 2001 From: bagggage Date: Thu, 16 Oct 2025 18:13:02 +0300 Subject: [PATCH 5/6] Implement 'World' and 'Local' tabs for the multiplayer window --- src/gui/windows/_windowlist.zig | 1 + src/gui/windows/add_server.zig | 3 +- src/gui/windows/join_directly.zig | 61 ++++++++ src/gui/windows/multiplayer.zig | 240 +++++++++++++++++++++++------- 4 files changed, 249 insertions(+), 56 deletions(-) create mode 100644 src/gui/windows/join_directly.zig diff --git a/src/gui/windows/_windowlist.zig b/src/gui/windows/_windowlist.zig index 93c6e8cf89..d9f8fcfaf9 100644 --- a/src/gui/windows/_windowlist.zig +++ b/src/gui/windows/_windowlist.zig @@ -20,6 +20,7 @@ pub const hotbar = @import("hotbar.zig"); pub const inventory = @import("inventory.zig"); pub const inventory_crafting = @import("inventory_crafting.zig"); pub const invite = @import("invite.zig"); +pub const join_directly = @import("join_directly.zig"); pub const main = @import("main.zig"); pub const manage_players = @import("manage_players.zig"); pub const multiplayer = @import("multiplayer.zig"); diff --git a/src/gui/windows/add_server.zig b/src/gui/windows/add_server.zig index c84df4c7c3..66302b576f 100644 --- a/src/gui/windows/add_server.zig +++ b/src/gui/windows/add_server.zig @@ -43,7 +43,6 @@ fn addServer(_: usize) void { const address = addressEntry.currentString.items; multiplayer.addServer(trimName, address); - gui.closeWindowFromRef(&window); } pub fn onOpen() void { @@ -54,7 +53,7 @@ pub fn onOpen() void { list.add(Label.init(.{0, 0}, width, "Address:", .left)); addressEntry = TextInput.init(.{0, 0}, width, 24, "", .{.callback = &addServer}, .{}); list.add(addressEntry); - addButton = Button.initText(.{0, 0}, 100, "Add", .{.callback = &addServer}); + addButton = Button.initText(.{0, 0}, width/3, "Add", .{.callback = &addServer}); addButton.disabled = true; list.add(addButton); list.finish(.center); diff --git a/src/gui/windows/join_directly.zig b/src/gui/windows/join_directly.zig new file mode 100644 index 0000000000..fe9e8efee9 --- /dev/null +++ b/src/gui/windows/join_directly.zig @@ -0,0 +1,61 @@ +const std = @import("std"); + +const main = @import("main"); +const Vec2f = main.vec.Vec2f; + +const gui = @import("../gui.zig"); +const GuiWindow = gui.GuiWindow; +const Button = @import("../components/Button.zig"); +const Label = @import("../components/Label.zig"); +const TextInput = @import("../components/TextInput.zig"); +const VerticalList = @import("../components/VerticalList.zig"); +const multiplayer = @import("multiplayer.zig"); + +const padding: f32 = 8; +const width: f32 = 300; + +var addressEntry: *TextInput = undefined; +var joinButton: *Button = undefined; + +pub var window = GuiWindow{ + .contentSize = Vec2f{128, 256}, +}; + +fn onEnterName(_: usize) void { + addressEntry.select(); +} + +fn isCorrectInput(address: []const u8) bool { + return address.len > 0 and std.mem.indexOfAny(u8, address, " \n\r\t<>!@#$%^&*(){}=+/*~,;\"\'\\") == null; +} + +fn join(_: usize) void { + const address = addressEntry.currentString.items; + multiplayer.joinServer(address); +} + +pub fn onOpen() void { + const list = VerticalList.init(.{padding, 16 + padding}, 380, padding); + list.add(Label.init(.{0, 0}, width, "Enter server address", .center)); + addressEntry = TextInput.init(.{0, 0}, width, 24, "", .{.callback = &join}, .{}); + list.add(addressEntry); + joinButton = Button.initText(.{0, 0}, width/3, "Join", .{.callback = &join}); + joinButton.disabled = true; + list.add(joinButton); + list.finish(.center); + window.rootComponent = list.toComponent(); + window.contentSize = window.rootComponent.?.pos() + window.rootComponent.?.size() + @as(Vec2f, @splat(padding)); + window.scale = 1; + gui.updateWindowPositions(); +} + +pub fn onClose() void { + if(window.rootComponent) |*comp| { + comp.deinit(); + } +} + +pub fn update() void { + const address = addressEntry.currentString.items; + joinButton.disabled = !isCorrectInput(address); +} diff --git a/src/gui/windows/multiplayer.zig b/src/gui/windows/multiplayer.zig index 193086f7ee..780cf62f3c 100644 --- a/src/gui/windows/multiplayer.zig +++ b/src/gui/windows/multiplayer.zig @@ -30,47 +30,70 @@ const ServerInfo = struct { } }; +const Tabs = enum(u8) { + WORLD, + LOCAL +}; + const serverListPath = "server_list.zig.zon"; -const width: f32 = 420; +const width: f32 = 490; const padding: f32 = 8; var servers: main.List(ServerInfo) = .init(main.globalAllocator); var serverList: *VerticalList = undefined; var selectedServerIdx: ?u32 = null; + +var connection: ?*ConnectionManager = null; +var ipAddress: []const u8 = ""; +var ipAddressLabel: *Label = undefined; +var ipAddressEntry: *TextInput = undefined; +var gotIpAddress: std.atomic.Value(bool) = .init(false); +var thread: ?std.Thread = null; + var joinButton: *Button = undefined; var removeButton: *Button = undefined; -var connection: ?*ConnectionManager = null; +var selectedTab: Tabs = .WORLD; var refresh: bool = false; -fn join(_: usize) void { - const server = &servers.items[selectedServerIdx.?]; - connection = if(connection == null) ConnectionManager.init(main.settings.defaultPort, true) catch |err| { +fn initConnection() void { + connection = ConnectionManager.init(main.settings.defaultPort, true) catch |err| { std.log.err("Could not open Connection: {s}", .{@errorName(err)}); + ipAddress = main.globalAllocator.dupe(u8, @errorName(err)); return; - } else connection; + }; +} - if(connection) |_connection| { - _connection.world = &main.game.testWorld; - main.game.world = &main.game.testWorld; - std.log.info("Connecting to server: {s}", .{server.address}); - main.game.testWorld.init(server.address, _connection) catch |err| { - const formattedError = std.fmt.allocPrint(main.stackAllocator.allocator, "Encountered error while opening world: {s}", .{@errorName(err)}) catch unreachable; - defer main.stackAllocator.free(formattedError); - std.log.err("{s}", .{formattedError}); - main.gui.windowlist.notification.raiseNotification(formattedError); - main.game.world = null; - _connection.world = null; - return; - }; - connection = null; - } else { - std.log.err("No connection found. Cannot connect.", .{}); - main.gui.windowlist.notification.raiseNotification("No connection found. Cannot connect."); - } - for(gui.openWindows.items) |openWindow| { - gui.closeWindowFromRef(openWindow); - } - gui.openHud(); +fn discoverIpAddress() void { + initConnection(); + ipAddress = std.fmt.allocPrint(main.globalAllocator.allocator, "{f}", .{connection.?.externalAddress}) catch unreachable; + gotIpAddress.store(true, .release); +} + +fn discoverIpAddressFromNewThread() void { + std.log.debug("thread started", .{}); + + main.initThreadLocals(); + defer main.deinitThreadLocals(); + + discoverIpAddress(); +} + +fn joinWorld(_: usize) void { + const server = &servers.items[selectedServerIdx.?]; + joinServer(server.address); +} + +fn joinLocal(_: usize) void { + const address = ipAddressEntry.currentString.items; + joinServer(address); + + main.globalAllocator.free(settings.lastUsedIPAddress); + settings.lastUsedIPAddress = main.globalAllocator.dupe(u8, address); + settings.save(); +} + +fn copyIp(_: usize) void { + main.Window.setClipboardString(ipAddress); } fn loadServerList() void { @@ -138,6 +161,66 @@ fn initServerElement(server: *const ServerInfo, idx: usize) *Selectable { return panel; } +fn initWorldTab(root: *VerticalList) void { + if(!refresh) { + loadServerList(); + } + + if(servers.items.len == 0) { + root.add(Label.init(.{0, 0}, width/3*2, "#d0d0d0The list is empty :(\n\nAdd a new #ffffff__server__ #d0d0d0by clicking the *button* below!", .center)); + } + serverList = VerticalList.init(.{0, 0}, root.maxHeight/2, 0); + for(servers.items, 0..) |*server, i| { + serverList.add(initServerElement(server, i)); + } + serverList.finish(.left); + selectedServerIdx = null; + + const bottomPanel = HorizontalList.init(); + const buttonWidth = (width - padding*3)/4; + bottomPanel.add(Button.initText(.{0, 0}, buttonWidth, "Add server", gui.openWindowCallback("add_server"))); + bottomPanel.add(Button.initText(.{padding, 0}, buttonWidth, "Join Directly", gui.openWindowCallback("join_directly"))); + joinButton = Button.initText(.{padding, 0}, buttonWidth, "Join", .{.callback = &joinWorld}); + joinButton.disabled = true; + bottomPanel.add(joinButton); + removeButton = Button.initText(.{padding, 0}, buttonWidth, "Remove", .{.callback = &removeServer}); + removeButton.disabled = true; + bottomPanel.add(removeButton); + bottomPanel.finish(.{0, 0}, .center); + + root.add(serverList); + root.add(bottomPanel); +} + +fn initLocalTab(root: *VerticalList) void { + root.add(Label.init(.{0, 0}, width, "Please send your IP to the host of the game and enter the host's IP below.", .center)); + // 255.255.255.255:?65536 (longest possible ip address) + + const ipBar = HorizontalList.init(); + const ipText = if(refresh and ipAddress.len != 0) ipAddress else " "; + ipAddressLabel = Label.init(.{padding/3, 0}, width/2.5 - padding/3, ipText, .left); + ipBar.add(ipAddressLabel); + ipBar.add(Button.initText(.{padding, 0}, 100, "Copy IP", .{.callback = ©Ip})); + ipBar.finish(.{0, 0}, .center); + + const inputBar = HorizontalList.init(); + ipAddressEntry = TextInput.init(.{0, 0}, width/2.5, 24, settings.lastUsedIPAddress, .{.callback = &joinLocal}, .{}); + inputBar.add(ipAddressEntry); + inputBar.add(Button.initText(.{padding, 0}, 100, "Join", .{.callback = &joinLocal})); + inputBar.finish(.{0, 0}, .center); + + root.add(ipBar); + root.add(inputBar); + + if(thread == null) { + thread = std.Thread.spawn(.{}, discoverIpAddressFromNewThread, .{}) catch |err| blk: { + std.log.err("Error spawning thread: {s}. Doing it in the current thread instead.", .{@errorName(err)}); + discoverIpAddress(); + break :blk null; + }; + } +} + fn refreshWindow() void { refresh = true; gui.closeWindowFromRef(&window); @@ -159,6 +242,11 @@ fn removeServer(_: usize) void { refreshWindow(); } +fn switchTab(tab: usize) void { + selectedTab = @enumFromInt(tab); + refreshWindow(); +} + pub fn addServer(name: []const u8, address: []const u8) void { const server = servers.addOne(); server.* = .{ @@ -169,36 +257,62 @@ pub fn addServer(name: []const u8, address: []const u8) void { refreshWindow(); } -pub fn onOpen() void { - const list = VerticalList.init(.{padding, 16 + padding}, 300, 16); - list.add(Label.init(.{0, 0}, 100, "**Multiplayer**", .center)); - if(!refresh) { - loadServerList(); +pub fn joinServer(address: []const u8) void { + if(thread) |_thread| { + _thread.join(); + thread = null; + } else if(connection == null) { + initConnection(); } - if(servers.items.len == 0) { - list.add(Label.init(.{0, 0}, width/3*2, "#d0d0d0The list is empty :(\n\nAdd a new #ffffff__server__ #d0d0d0by clicking the *button* below!", .center)); + if(ipAddress.len != 0) { + main.globalAllocator.free(ipAddress); + ipAddress = ""; } - serverList = VerticalList.init(.{0, padding}, list.maxHeight/2, 0); - for(servers.items, 0..) |*server, i| { - serverList.add(initServerElement(server, i)); + if(connection) |_connection| { + _connection.world = &main.game.testWorld; + main.game.world = &main.game.testWorld; + std.log.info("Connecting to server: {s}", .{address}); + main.game.testWorld.init(address, _connection) catch |err| { + const formattedError = std.fmt.allocPrint(main.stackAllocator.allocator, "Encountered error while opening world: {s}", .{@errorName(err)}) catch unreachable; + defer main.stackAllocator.free(formattedError); + std.log.err("{s}", .{formattedError}); + main.gui.windowlist.notification.raiseNotification(formattedError); + main.game.world = null; + _connection.world = null; + return; + }; + connection = null; + } else { + std.log.err("No connection found. Cannot connect.", .{}); + main.gui.windowlist.notification.raiseNotification("No connection found. Cannot connect."); } - serverList.finish(.left); - selectedServerIdx = null; - const bottomPanel = HorizontalList.init(); - const buttonWidth = (width - padding*2)/3; - bottomPanel.add(Button.initText(.{0, 0}, buttonWidth, "Add server", gui.openWindowCallback("add_server"))); - joinButton = Button.initText(.{padding, 0}, buttonWidth, "Join", .{.callback = &join}); - joinButton.disabled = true; - bottomPanel.add(joinButton); - removeButton = Button.initText(.{padding, 0}, buttonWidth, "Remove", .{.callback = &removeServer}); - removeButton.disabled = true; - bottomPanel.add(removeButton); - bottomPanel.finish(.{0, 0}, .center); + for(gui.openWindows.items) |openWindow| { + gui.closeWindowFromRef(openWindow); + } + gui.openHud(); +} + +pub fn onOpen() void { + const list = VerticalList.init(.{padding, 16 + padding}, 300, 16); + //list.add(Label.init(.{0, 0}, 100, "**Multiplayer**", .center)); + + const tabs = HorizontalList.init(); + const worldButton = Button.initText(.{0, 0}, width/2 + 4, "World", .{.callback = &switchTab, .arg = @intFromEnum(Tabs.WORLD)}); + const localButton = Button.initText(.{-4, 0}, width/2 + 4, "Local", .{.callback = &switchTab, .arg = @intFromEnum(Tabs.LOCAL)}); + worldButton.disabled = selectedTab == .WORLD; + localButton.disabled = selectedTab == .LOCAL; + tabs.add(worldButton); + tabs.add(localButton); + tabs.finish(.{0, 0}, .center); + list.add(tabs); + + switch(selectedTab) { + .WORLD => initWorldTab(list), + .LOCAL => initLocalTab(list) + } - list.add(serverList); - list.add(bottomPanel); list.finish(.center); window.rootComponent = list.toComponent(); window.contentSize = window.rootComponent.?.pos() + window.rootComponent.?.size() + @as(Vec2f, @splat(padding)); @@ -209,6 +323,14 @@ pub fn onOpen() void { pub fn onClose() void { if(!refresh) { + if(thread) |_thread| { + _thread.join(); + thread = null; + } + if(ipAddress.len != 0) { + main.globalAllocator.free(ipAddress); + ipAddress = ""; + } if(connection) |_connection| { _connection.deinit(); connection = null; @@ -225,6 +347,16 @@ pub fn onClose() void { } pub fn update() void { - joinButton.disabled = selectedServerIdx == null; - removeButton.disabled = joinButton.disabled; + switch (selectedTab) { + .LOCAL => { + if(gotIpAddress.load(.acquire)) { + gotIpAddress.store(false, .monotonic); + ipAddressLabel.updateText(ipAddress); + } + }, + .WORLD => { + joinButton.disabled = selectedServerIdx == null; + removeButton.disabled = joinButton.disabled; + } + } } From 9fd24d8c333638b5cc77fedc060e3b723aa994a0 Mon Sep 17 00:00:00 2001 From: bagggage Date: Thu, 16 Oct 2025 23:47:36 +0300 Subject: [PATCH 6/6] Implement windows for public multiplayer and local to play with friends --- src/game.zig | 33 ++++ src/gui/windows/_windowlist.zig | 1 + src/gui/windows/friends_multiplayer.zig | 167 ++++++++++++++++ src/gui/windows/join_directly.zig | 10 +- src/gui/windows/main.zig | 3 +- src/gui/windows/multiplayer.zig | 245 ++++-------------------- src/settings.zig | 2 + 7 files changed, 247 insertions(+), 214 deletions(-) create mode 100644 src/gui/windows/friends_multiplayer.zig diff --git a/src/game.zig b/src/game.zig index fe7720deba..7adba823c5 100644 --- a/src/game.zig +++ b/src/game.zig @@ -803,6 +803,39 @@ pub fn hyperSpeedToggle() void { Player.hyperSpeed.store(!Player.hyperSpeed.load(.monotonic), .monotonic); } +pub fn join(serverAddress: []const u8, _manager: ?*ConnectionManager) bool { + std.log.info("Connecting to server: {s}", .{serverAddress}); + + var formattedError: []u8 = ""; + defer if(formattedError.len != 0) main.stackAllocator.free(formattedError); + + const manager = if(_manager == null) ConnectionManager.init(main.settings.defaultPort, true) catch |err| { + formattedError = std.fmt.allocPrint(main.stackAllocator.allocator, "Could not initialize connection: {s}", .{@errorName(err)}) catch unreachable; + std.log.err("{s}", .{formattedError}); + main.gui.windowlist.notification.raiseNotification(formattedError); + return false; + } else _manager.?; + + world = &testWorld; + manager.world = &testWorld; + testWorld.init(serverAddress, manager) catch |err| { + world = null; + manager.world = null; + + formattedError = std.fmt.allocPrint(main.stackAllocator.allocator, "Encountered error while opening world: {s}", .{@errorName(err)}) catch unreachable; + std.log.err("{s}", .{formattedError}); + main.gui.windowlist.notification.raiseNotification(formattedError); + return false; + }; + + for(main.gui.openWindows.items) |window| { + main.gui.closeWindowFromRef(window); + } + main.gui.openHud(); + + return true; +} + pub fn update(deltaTime: f64) void { // MARK: update() physics.calculateProperties(); var acc = Vec3d{0, 0, 0}; diff --git a/src/gui/windows/_windowlist.zig b/src/gui/windows/_windowlist.zig index d9f8fcfaf9..afe92851b6 100644 --- a/src/gui/windows/_windowlist.zig +++ b/src/gui/windows/_windowlist.zig @@ -13,6 +13,7 @@ pub const delete_world_confirmation = @import("delete_world_confirmation.zig"); pub const download_controller_mappings = @import("download_controller_mappings.zig"); pub const energybar = @import("energybar.zig"); pub const error_prompt = @import("error_prompt.zig"); +pub const friends_multiplayer = @import("friends_multiplayer.zig"); pub const gpu_performance_measuring = @import("gpu_performance_measuring.zig"); pub const graphics = @import("graphics.zig"); pub const healthbar = @import("healthbar.zig"); diff --git a/src/gui/windows/friends_multiplayer.zig b/src/gui/windows/friends_multiplayer.zig new file mode 100644 index 0000000000..be4a47d2ad --- /dev/null +++ b/src/gui/windows/friends_multiplayer.zig @@ -0,0 +1,167 @@ +const std = @import("std"); + +const main = @import("main"); +const ConnectionManager = main.network.ConnectionManager; +const settings = main.settings; +const Vec2f = main.vec.Vec2f; + +const gui = @import("../gui.zig"); +const GuiWindow = gui.GuiWindow; +const Button = @import("../components/Button.zig"); +const CheckBox = @import("../components/CheckBox.zig"); +const HorizontalList = @import("../components/HorizontalList.zig"); +const Label = @import("../components/Label.zig"); +const TextInput = @import("../components/TextInput.zig"); +const VerticalList = @import("../components/VerticalList.zig"); + +pub var window = GuiWindow{ + .contentSize = Vec2f{128, 256}, +}; + +const width: f32 = 490; +const padding: f32 = 8; + +const maxIpLength = 24; // 255.255.255.255:?65536 (longest possible ip address) +const ipPlaceholder = &[_]u8{' '} ** maxIpLength; + +var connection: ?*ConnectionManager = null; +var ipAddress: []const u8 = ""; +var ipAddressLabel: *Label = undefined; +var ipAddressEntry: *TextInput = undefined; +var gotIpAddress: std.atomic.Value(bool) = .init(false); +var thread: ?std.Thread = null; + +var joinButton: *Button = undefined; + +fn discoverIpAddress() void { + connection = ConnectionManager.init(main.settings.defaultPort, true) catch |err| { + std.log.err("Could not initialize connection: {s}", .{@errorName(err)}); + ipAddress = main.globalAllocator.dupe(u8, @errorName(err)); + return; + }; + ipAddress = std.fmt.allocPrint(main.globalAllocator.allocator, "{f}", .{connection.?.externalAddress}) catch unreachable; + gotIpAddress.store(true, .release); +} + +fn discoverIpAddressFromNewThread() void { + main.initThreadLocals(); + defer main.deinitThreadLocals(); + + discoverIpAddress(); +} + +fn changeIpVisibility(hide: bool) void { + if(hide) { + // assume that IP address is always encoded as ASCII, + // so length of the address is equals to the number of utf-8 characters + ipAddressLabel.updateText(TextInput.obfuscatedStringBuffer[0 .. ipAddress.len*TextInput.obfuscationChar.len]); + ipAddressEntry.obfuscate(); + } else { + ipAddressLabel.updateText(ipAddress); + ipAddressEntry.deobfuscate(); + } + + settings.hideIpAddresses = hide; + settings.save(); +} + +fn join(_: usize) void { + if(thread) |_thread| { + _thread.join(); + thread = null; + } + if(ipAddress.len != 0) { + main.globalAllocator.free(ipAddress); + ipAddress = ""; + } + if(connection) |_connection| { + const address = ipAddressEntry.currentString.items; + connection = null; + + if(main.game.join(address, _connection)) { + main.globalAllocator.free(settings.lastUsedIPAddress); + settings.lastUsedIPAddress = main.globalAllocator.dupe(u8, address); + settings.save(); + } else { + connection = _connection; + } + } else { + std.log.err("No connection found. Cannot connect.", .{}); + main.gui.windowlist.notification.raiseNotification("No connection found. Cannot connect."); + } +} + +fn copyIp(_: usize) void { + main.Window.setClipboardString(ipAddress); +} + +pub fn onOpen() void { + const list = VerticalList.init(.{padding, 16 + padding}, 300, 16); + list.add(Label.init(.{0, 0}, width, "Please send your IP to the host of the game and enter the host's IP below.", .center)); + list.add(CheckBox.init(.{0, 0}, width/2 + padding + 100, "Hide IP addresses", settings.hideIpAddresses, &changeIpVisibility)); + const ipBar = HorizontalList.init(); + ipAddressLabel = Label.init(.{padding/3, 0}, width/2 - padding/3, ipPlaceholder, .left); + ipBar.add(ipAddressLabel); + ipBar.add(Button.initText(.{padding, 0}, 100, "Copy IP", .{.callback = ©Ip})); + ipBar.finish(.{0, 0}, .center); + + const inputBar = HorizontalList.init(); + ipAddressEntry = TextInput.init(.{0, 0}, width/2, 24, settings.lastUsedIPAddress, .{.callback = &join}, .{}); + inputBar.add(ipAddressEntry); + joinButton = Button.initText(.{padding, 0}, 100, "Join", .{.callback = &join}); + inputBar.add(joinButton); + inputBar.finish(.{0, 0}, .center); + + list.add(ipBar); + list.add(inputBar); + list.finish(.center); + + window.rootComponent = list.toComponent(); + window.contentSize = window.rootComponent.?.pos() + window.rootComponent.?.size() + @as(Vec2f, @splat(padding)); + gui.updateWindowPositions(); + + changeIpVisibility(settings.hideIpAddresses); + + if(thread == null) { + thread = std.Thread.spawn(.{}, discoverIpAddressFromNewThread, .{}) catch |err| blk: { + std.log.err("Error spawning thread: {s}. Doing it in the current thread instead.", .{@errorName(err)}); + discoverIpAddress(); + break :blk null; + }; + } +} + +pub fn onClose() void { + if(thread) |_thread| { + _thread.join(); + thread = null; + } + if(ipAddress.len != 0) { + main.globalAllocator.free(ipAddress); + ipAddress = ""; + } + if(connection) |_connection| { + _connection.deinit(); + connection = null; + } + if(window.rootComponent) |*comp| { + comp.deinit(); + } +} + +pub fn update() void { + if(gotIpAddress.load(.acquire)) { + gotIpAddress.store(false, .monotonic); + + if(settings.hideIpAddresses) { + // assume that IP address is always encoded as ASCII, + // so length of the address is equals to the number of utf-8 characters + ipAddressLabel.updateText(TextInput.obfuscatedStringBuffer[0 .. ipAddress.len*TextInput.obfuscationChar.len]); + } else { + ipAddressLabel.updateText(ipAddress); + } + } + + const input = ipAddressEntry.currentString.items; + joinButton.disabled = input.len == 0 or std.mem.indexOfAny(u8, input, " \n\r\t<>!@#$%^&*(){}=+/*~,;\"\'\\") != null; +} diff --git a/src/gui/windows/join_directly.zig b/src/gui/windows/join_directly.zig index fe9e8efee9..754d337395 100644 --- a/src/gui/windows/join_directly.zig +++ b/src/gui/windows/join_directly.zig @@ -25,13 +25,9 @@ fn onEnterName(_: usize) void { addressEntry.select(); } -fn isCorrectInput(address: []const u8) bool { - return address.len > 0 and std.mem.indexOfAny(u8, address, " \n\r\t<>!@#$%^&*(){}=+/*~,;\"\'\\") == null; -} - fn join(_: usize) void { const address = addressEntry.currentString.items; - multiplayer.joinServer(address); + _ = main.game.join(address, null); } pub fn onOpen() void { @@ -43,9 +39,11 @@ pub fn onOpen() void { joinButton.disabled = true; list.add(joinButton); list.finish(.center); + window.rootComponent = list.toComponent(); window.contentSize = window.rootComponent.?.pos() + window.rootComponent.?.size() + @as(Vec2f, @splat(padding)); window.scale = 1; + gui.updateWindowPositions(); } @@ -57,5 +55,5 @@ pub fn onClose() void { pub fn update() void { const address = addressEntry.currentString.items; - joinButton.disabled = !isCorrectInput(address); + joinButton.disabled = address.len == 0 or std.mem.indexOfAny(u8, address, " \n\r\t<>!@#$%^&*(){}=+/*~,;\"\'\\") != null; } diff --git a/src/gui/windows/main.zig b/src/gui/windows/main.zig index 39b7750259..aab3adc137 100644 --- a/src/gui/windows/main.zig +++ b/src/gui/windows/main.zig @@ -22,7 +22,8 @@ fn exitGame(_: usize) void { pub fn onOpen() void { const list = VerticalList.init(.{padding, 16 + padding}, 300, 16); list.add(Button.initText(.{0, 0}, 128, "Singleplayer", gui.openWindowCallback("save_selection"))); - list.add(Button.initText(.{0, 0}, 128, "Multiplayer", gui.openWindowCallback("multiplayer"))); + list.add(Button.initText(.{0, 0}, 128, "Join a Server", gui.openWindowCallback("multiplayer"))); + list.add(Button.initText(.{0, 0}, 128, "Play with Friends", gui.openWindowCallback("friends_multiplayer"))); list.add(Button.initText(.{0, 0}, 128, "Settings", gui.openWindowCallback("settings"))); list.add(Button.initText(.{0, 0}, 128, "Touch Grass", .{.callback = &exitGame})); list.finish(.center); diff --git a/src/gui/windows/multiplayer.zig b/src/gui/windows/multiplayer.zig index 780cf62f3c..9865019bb9 100644 --- a/src/gui/windows/multiplayer.zig +++ b/src/gui/windows/multiplayer.zig @@ -1,19 +1,15 @@ const std = @import("std"); const main = @import("main"); -const ConnectionManager = main.network.ConnectionManager; -const settings = main.settings; const Vec2f = main.vec.Vec2f; const ZonElement = @import("../../zon.zig").ZonElement; const gui = @import("../gui.zig"); -const GuiComponent = gui.GuiComponent; const GuiWindow = gui.GuiWindow; const Button = @import("../components/Button.zig"); const HorizontalList = @import("../components/HorizontalList.zig"); const Selectable = @import("../components/Selectable.zig"); const Label = @import("../components/Label.zig"); -const TextInput = @import("../components/TextInput.zig"); const VerticalList = @import("../components/VerticalList.zig"); pub var window = GuiWindow{ @@ -30,11 +26,6 @@ const ServerInfo = struct { } }; -const Tabs = enum(u8) { - WORLD, - LOCAL -}; - const serverListPath = "server_list.zig.zon"; const width: f32 = 490; const padding: f32 = 8; @@ -42,60 +33,10 @@ const padding: f32 = 8; var servers: main.List(ServerInfo) = .init(main.globalAllocator); var serverList: *VerticalList = undefined; var selectedServerIdx: ?u32 = null; - -var connection: ?*ConnectionManager = null; -var ipAddress: []const u8 = ""; -var ipAddressLabel: *Label = undefined; -var ipAddressEntry: *TextInput = undefined; -var gotIpAddress: std.atomic.Value(bool) = .init(false); -var thread: ?std.Thread = null; - var joinButton: *Button = undefined; var removeButton: *Button = undefined; -var selectedTab: Tabs = .WORLD; var refresh: bool = false; -fn initConnection() void { - connection = ConnectionManager.init(main.settings.defaultPort, true) catch |err| { - std.log.err("Could not open Connection: {s}", .{@errorName(err)}); - ipAddress = main.globalAllocator.dupe(u8, @errorName(err)); - return; - }; -} - -fn discoverIpAddress() void { - initConnection(); - ipAddress = std.fmt.allocPrint(main.globalAllocator.allocator, "{f}", .{connection.?.externalAddress}) catch unreachable; - gotIpAddress.store(true, .release); -} - -fn discoverIpAddressFromNewThread() void { - std.log.debug("thread started", .{}); - - main.initThreadLocals(); - defer main.deinitThreadLocals(); - - discoverIpAddress(); -} - -fn joinWorld(_: usize) void { - const server = &servers.items[selectedServerIdx.?]; - joinServer(server.address); -} - -fn joinLocal(_: usize) void { - const address = ipAddressEntry.currentString.items; - joinServer(address); - - main.globalAllocator.free(settings.lastUsedIPAddress); - settings.lastUsedIPAddress = main.globalAllocator.dupe(u8, address); - settings.save(); -} - -fn copyIp(_: usize) void { - main.Window.setClipboardString(ipAddress); -} - fn loadServerList() void { servers.clearRetainingCapacity(); const zon: ZonElement = main.files.cubyzDir().readToZon(main.stackAllocator, serverListPath) catch |err| blk: { @@ -123,10 +64,7 @@ fn loadServerList() void { std.log.err("Invalid entry in server list file: {}", .{i}); continue; } - servers.append(.{ - .name = main.globalAllocator.dupe(u8, name.?.stringOwned), - .address = main.globalAllocator.dupe(u8, address.?.stringOwned) - }); + servers.append(.{.name = main.globalAllocator.dupe(u8, name.?.stringOwned), .address = main.globalAllocator.dupe(u8, address.?.stringOwned)}); } } @@ -161,77 +99,21 @@ fn initServerElement(server: *const ServerInfo, idx: usize) *Selectable { return panel; } -fn initWorldTab(root: *VerticalList) void { - if(!refresh) { - loadServerList(); - } - - if(servers.items.len == 0) { - root.add(Label.init(.{0, 0}, width/3*2, "#d0d0d0The list is empty :(\n\nAdd a new #ffffff__server__ #d0d0d0by clicking the *button* below!", .center)); - } - serverList = VerticalList.init(.{0, 0}, root.maxHeight/2, 0); - for(servers.items, 0..) |*server, i| { - serverList.add(initServerElement(server, i)); - } - serverList.finish(.left); - selectedServerIdx = null; - - const bottomPanel = HorizontalList.init(); - const buttonWidth = (width - padding*3)/4; - bottomPanel.add(Button.initText(.{0, 0}, buttonWidth, "Add server", gui.openWindowCallback("add_server"))); - bottomPanel.add(Button.initText(.{padding, 0}, buttonWidth, "Join Directly", gui.openWindowCallback("join_directly"))); - joinButton = Button.initText(.{padding, 0}, buttonWidth, "Join", .{.callback = &joinWorld}); - joinButton.disabled = true; - bottomPanel.add(joinButton); - removeButton = Button.initText(.{padding, 0}, buttonWidth, "Remove", .{.callback = &removeServer}); - removeButton.disabled = true; - bottomPanel.add(removeButton); - bottomPanel.finish(.{0, 0}, .center); - - root.add(serverList); - root.add(bottomPanel); -} - -fn initLocalTab(root: *VerticalList) void { - root.add(Label.init(.{0, 0}, width, "Please send your IP to the host of the game and enter the host's IP below.", .center)); - // 255.255.255.255:?65536 (longest possible ip address) - - const ipBar = HorizontalList.init(); - const ipText = if(refresh and ipAddress.len != 0) ipAddress else " "; - ipAddressLabel = Label.init(.{padding/3, 0}, width/2.5 - padding/3, ipText, .left); - ipBar.add(ipAddressLabel); - ipBar.add(Button.initText(.{padding, 0}, 100, "Copy IP", .{.callback = ©Ip})); - ipBar.finish(.{0, 0}, .center); - - const inputBar = HorizontalList.init(); - ipAddressEntry = TextInput.init(.{0, 0}, width/2.5, 24, settings.lastUsedIPAddress, .{.callback = &joinLocal}, .{}); - inputBar.add(ipAddressEntry); - inputBar.add(Button.initText(.{padding, 0}, 100, "Join", .{.callback = &joinLocal})); - inputBar.finish(.{0, 0}, .center); - - root.add(ipBar); - root.add(inputBar); - - if(thread == null) { - thread = std.Thread.spawn(.{}, discoverIpAddressFromNewThread, .{}) catch |err| blk: { - std.log.err("Error spawning thread: {s}. Doing it in the current thread instead.", .{@errorName(err)}); - discoverIpAddress(); - break :blk null; - }; - } -} - fn refreshWindow() void { refresh = true; gui.closeWindowFromRef(&window); gui.openWindowFromRef(&window); } +fn join(_: usize) void { + const serverAddress = servers.items[selectedServerIdx.?].address; + _ = main.game.join(serverAddress, null); +} + fn selectServer(serverIdx: usize) void { if(selectedServerIdx) |idx| { serverList.children.items[idx].selectable.deselect(); } - selectedServerIdx = @truncate(serverIdx); } @@ -242,76 +124,44 @@ fn removeServer(_: usize) void { refreshWindow(); } -fn switchTab(tab: usize) void { - selectedTab = @enumFromInt(tab); - refreshWindow(); -} - pub fn addServer(name: []const u8, address: []const u8) void { const server = servers.addOne(); - server.* = .{ - .name = main.globalAllocator.dupe(u8, name), - .address = main.globalAllocator.dupe(u8, address) - }; + server.* = .{.name = main.globalAllocator.dupe(u8, name), .address = main.globalAllocator.dupe(u8, address)}; saveServerList(); refreshWindow(); } -pub fn joinServer(address: []const u8) void { - if(thread) |_thread| { - _thread.join(); - thread = null; - } else if(connection == null) { - initConnection(); - } +pub fn onOpen() void { + const list = VerticalList.init(.{padding, 16 + padding}, 300, 16); + list.add(Label.init(.{0, 0}, width/3, "**Multiplayer**", .center)); - if(ipAddress.len != 0) { - main.globalAllocator.free(ipAddress); - ipAddress = ""; + if(!refresh) { + loadServerList(); } - if(connection) |_connection| { - _connection.world = &main.game.testWorld; - main.game.world = &main.game.testWorld; - std.log.info("Connecting to server: {s}", .{address}); - main.game.testWorld.init(address, _connection) catch |err| { - const formattedError = std.fmt.allocPrint(main.stackAllocator.allocator, "Encountered error while opening world: {s}", .{@errorName(err)}) catch unreachable; - defer main.stackAllocator.free(formattedError); - std.log.err("{s}", .{formattedError}); - main.gui.windowlist.notification.raiseNotification(formattedError); - main.game.world = null; - _connection.world = null; - return; - }; - connection = null; - } else { - std.log.err("No connection found. Cannot connect.", .{}); - main.gui.windowlist.notification.raiseNotification("No connection found. Cannot connect."); + if(servers.items.len == 0) { + list.add(Label.init(.{0, 0}, width/3*2, "#d0d0d0The list is empty :(\n\nAdd a new #ffffff__server__ #d0d0d0by clicking the *button* below!", .center)); } - - for(gui.openWindows.items) |openWindow| { - gui.closeWindowFromRef(openWindow); + serverList = VerticalList.init(.{0, 0}, list.maxHeight/2, 0); + for(servers.items, 0..) |*server, i| { + serverList.add(initServerElement(server, i)); } - gui.openHud(); -} + serverList.finish(.left); + selectedServerIdx = null; -pub fn onOpen() void { - const list = VerticalList.init(.{padding, 16 + padding}, 300, 16); - //list.add(Label.init(.{0, 0}, 100, "**Multiplayer**", .center)); - - const tabs = HorizontalList.init(); - const worldButton = Button.initText(.{0, 0}, width/2 + 4, "World", .{.callback = &switchTab, .arg = @intFromEnum(Tabs.WORLD)}); - const localButton = Button.initText(.{-4, 0}, width/2 + 4, "Local", .{.callback = &switchTab, .arg = @intFromEnum(Tabs.LOCAL)}); - worldButton.disabled = selectedTab == .WORLD; - localButton.disabled = selectedTab == .LOCAL; - tabs.add(worldButton); - tabs.add(localButton); - tabs.finish(.{0, 0}, .center); - list.add(tabs); - - switch(selectedTab) { - .WORLD => initWorldTab(list), - .LOCAL => initLocalTab(list) - } + const bottomPanel = HorizontalList.init(); + const buttonWidth = (width - padding*3)/4; + bottomPanel.add(Button.initText(.{0, 0}, buttonWidth, "Add a Server", gui.openWindowCallback("add_server"))); + bottomPanel.add(Button.initText(.{padding, 0}, buttonWidth, "Join Directly", gui.openWindowCallback("join_directly"))); + joinButton = Button.initText(.{padding, 0}, buttonWidth, "Join", .{.callback = &join}); + joinButton.disabled = true; + bottomPanel.add(joinButton); + removeButton = Button.initText(.{padding, 0}, buttonWidth, "Remove", .{.callback = &removeServer}); + removeButton.disabled = true; + bottomPanel.add(removeButton); + bottomPanel.finish(.{0, 0}, .center); + + list.add(serverList); + list.add(bottomPanel); list.finish(.center); window.rootComponent = list.toComponent(); @@ -323,22 +173,13 @@ pub fn onOpen() void { pub fn onClose() void { if(!refresh) { - if(thread) |_thread| { - _thread.join(); - thread = null; - } - if(ipAddress.len != 0) { - main.globalAllocator.free(ipAddress); - ipAddress = ""; - } - if(connection) |_connection| { - _connection.deinit(); - connection = null; - } if(servers.items.len != 0) { for(0..servers.items.len) |i| servers.items[i].deinit(); servers.clearAndFree(); } + + gui.closeWindow("join_directly"); + gui.closeWindow("add_server"); } if(window.rootComponent) |*comp| { @@ -347,16 +188,6 @@ pub fn onClose() void { } pub fn update() void { - switch (selectedTab) { - .LOCAL => { - if(gotIpAddress.load(.acquire)) { - gotIpAddress.store(false, .monotonic); - ipAddressLabel.updateText(ipAddress); - } - }, - .WORLD => { - joinButton.disabled = selectedServerIdx == null; - removeButton.disabled = joinButton.disabled; - } - } + joinButton.disabled = selectedServerIdx == null; + removeButton.disabled = joinButton.disabled; } diff --git a/src/settings.zig b/src/settings.zig index acb1e605bc..9398b7f61a 100644 --- a/src/settings.zig +++ b/src/settings.zig @@ -46,6 +46,8 @@ pub var playerName: []const u8 = ""; pub var lastUsedIPAddress: []const u8 = ""; +pub var hideIpAddresses: bool = true; + pub var guiScale: ?f32 = null; pub var musicVolume: f32 = 1;