summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLizzy Fleckenstein <lizzy@vlhl.dev>2026-06-03 01:15:29 +0200
committerLizzy Fleckenstein <lizzy@vlhl.dev>2026-06-03 01:15:29 +0200
commitf08683a3775989e749237cd001a8eaf3193d1684 (patch)
treefbda564309b9f0f7c66c0bd68a2e8c5f08ff27f3
parentcc5b2f31a7abe46147284de869368a0a2c4bcff4 (diff)
downloadr6p-f08683a3775989e749237cd001a8eaf3193d1684.tar.xz
add main menu
-rw-r--r--client.lua13
-rw-r--r--conf.lua3
-rw-r--r--distribute.lua48
-rw-r--r--main.lua154
-rw-r--r--main_menu.lua48
-rw-r--r--ui.lua412
6 files changed, 675 insertions, 3 deletions
diff --git a/client.lua b/client.lua
index 43ddda0..b300d2b 100644
--- a/client.lua
+++ b/client.lua
@@ -8,7 +8,7 @@ local client = {}
local function create_client(secret)
local clt = {}
- clt.host = enet.host_create() -- enet.host_create("10.75.98.51:58901")
+ clt.host = enet.host_create()
clt.secret = secret
return clt
end
@@ -20,7 +20,11 @@ local function connect(clt, addr)
end
function client.join(invite, match_addr)
- local invite_dec = base64.decode(invite)
+ local decode_succ, invite_dec = pcall(base64.decode, invite)
+ if not decode_succ then
+ return nil, "invalid_invite"
+ end
+
local game_id = invite_dec:sub(1, common.gameid_len)
local secret = invite_dec:sub(common.gameid_len+1)
@@ -93,7 +97,7 @@ end
function client.status(clt)
if clt.status == "wait_match" and clt.match_req+3 < socket.gettime() then
clt.status = "timeout_match"
- elseif clt.status == "wait_server" and clt.match_req+5 < socket.gettime() then
+ elseif clt.status == "wait_server" and clt.server_req+5 < socket.gettime() then
clt.status = "timeout_server"
end
@@ -113,6 +117,9 @@ function client.status(clt)
end
function client.close(clt)
+ if clt.match then clt.match:disconnect() end
+ if clt.server then clt.server:disconnect() end
+ clt.host:service()
clt.host:destroy()
end
diff --git a/conf.lua b/conf.lua
new file mode 100644
index 0000000..d094913
--- /dev/null
+++ b/conf.lua
@@ -0,0 +1,3 @@
+function love.conf(t)
+ t.window.resizable = true
+end
diff --git a/distribute.lua b/distribute.lua
new file mode 100644
index 0000000..8c0b76c
--- /dev/null
+++ b/distribute.lua
@@ -0,0 +1,48 @@
+-- min, weight, receive
+
+local function eliminate(budget, total_weight, elems)
+ if budget < 0 then
+ return
+ end
+
+ if total_weight == 0 then
+ return 0
+ end
+
+ local unit = budget / total_weight
+ for i, e in ipairs(elems) do
+ local allocated = unit * e.weight
+ if e.min > allocated then
+ e.receive = e.min
+ table.remove(elems, i)
+ budget = budget - e.min
+ total_weight = total_weight - e.weight
+ return eliminate(budget, total_weight, elems)
+ end
+ end
+ return unit
+end
+
+local function distribute(budget, elems)
+ local total_weight = 0
+ local remaining = {}
+ for _, v in pairs(elems) do
+ total_weight = total_weight + v.weight
+ table.insert(remaining, v)
+ end
+
+ local unit = eliminate(budget, total_weight, remaining)
+ if not unit then
+ for _, e in ipairs(remaining) do
+ e.receive = e.min
+ end
+ return false
+ end
+
+ for _, e in ipairs(remaining) do
+ e.receive = unit * e.weight
+ end
+ return true
+end
+
+return distribute
diff --git a/main.lua b/main.lua
new file mode 100644
index 0000000..238b20b
--- /dev/null
+++ b/main.lua
@@ -0,0 +1,154 @@
+function dump(x, idt)
+ local p = {}
+ local idt = idt or 0
+ for k, v in pairs(x) do
+ local s
+ if type(v) == "table" then
+ s = dump(v, idt+1)
+ elseif type(v) == "string" then
+ s = ("\"%s\""):format(v)
+ else
+ s = tostring(v)
+ end
+ table.insert(p, ("%s%s = %s"):format((" "):rep(4*(idt+1)), k, s))
+ end
+ return ("{\n%s\n%s}"):format(table.concat(p, ", \n"), (" "):rep(4*idt))
+end
+
+local client = require("client")
+local server = require("server")
+local main_menu = require("main_menu")
+local ui = require("ui")
+local color = ui.color
+
+local mm
+local clt
+local active_ui
+local loading_text, loading_ui
+local error_text, error_ui
+local connected = false
+local last_status
+
+local function show_error(msg)
+ error_text.text:set(msg)
+ active_ui = error_ui
+end
+
+local function join_game(invite)
+ local c, err = client.join(invite)
+ if err then
+ show_error("Invalid invite")
+ else
+ clt = c
+ active_ui = loading_ui
+ end
+end
+
+local function disconnect(msg)
+ client.close(clt)
+ clt = nil
+ connected = false
+ if msg then
+ show_error(msg)
+ else
+ active_ui = mm
+ end
+end
+
+local function loading_status(status)
+ loading_text.text:set(status)
+end
+
+local function update_client()
+ client.update(clt)
+
+ local status = client.status(clt)
+ if status == "timeout_match" then
+ disconnect("Failed to connect to match server (match server down?)")
+ elseif status == "fail_match" then
+ disconnect("Game not found (invalid invite?)")
+ elseif status == "timeout_server" then
+ disconnect("Failed to connect to server (NAT punch failure?)")
+ elseif status == "fail_server" then
+ disconnect("Incorrect secret (invalid invite?)")
+ elseif status == "disco" then
+ disconnect("Lost connection to server (Server shut down?)")
+ elseif status ~= last_status then
+ if status == "active" then
+ loading_status("Connected to server!")
+ connected = true
+ elseif status == "wait_match" then
+ loading_status("Waiting for match server...")
+ elseif status == "wait_server" then
+ loading_status("Waiting for server...")
+ end
+ end
+
+ last_status = status
+end
+
+function love.load()
+ love.graphics.setBackgroundColor(color(0xffffff))
+ love.window.setTitle("RAINBOW SIX: PANOPTICON")
+
+ mm = main_menu.create({
+ join_game = join_game,
+ })
+
+ local loading_box = main_menu.box(
+ "Loading...",
+ ui.button("Cancel", function()
+ disconnect()
+ end)
+ )
+ loading_text = loading_box.box_title
+ loading_ui = ui.new(ui.center_x(ui.center_y(loading_box)))
+
+ local error_box = main_menu.box(
+ "Error",
+ ui.button("Back", function()
+ active_ui = mm
+ end)
+ )
+ error_text = error_box.box_title
+ error_ui = ui.new(ui.center_x(ui.center_y(error_box)))
+
+ active_ui = mm
+end
+
+function love.mousepressed(x, y, button)
+ if active_ui then
+ ui.mousepressed(active_ui, x, y, button)
+ end
+end
+
+function love.mousereleased(x, y, button)
+ if active_ui then
+ ui.mousereleased(active_ui, x, y, button)
+ end
+end
+
+function love.textinput(text)
+ if active_ui then
+ ui.textinput(active_ui, text)
+ end
+end
+
+function love.keypressed(key)
+ if active_ui then
+ ui.keypressed(active_ui, key)
+ end
+end
+
+function love.update()
+ if clt then
+ update_client()
+ end
+end
+
+function love.draw()
+ if active_ui then
+ ui.update(active_ui)
+ ui.render(active_ui)
+ end
+end
diff --git a/main_menu.lua b/main_menu.lua
new file mode 100644
index 0000000..73ba7b1
--- /dev/null
+++ b/main_menu.lua
@@ -0,0 +1,48 @@
+local ui = require("ui")
+local color = ui.color
+
+local function menubox(title, elem)
+ local box_title = ui.text(title, 30, color(0x000000))
+ return { box_title = box_title, fill = color(0xa0a0a0), ui.pad_x(20, ui.stack_y(
+ ui.pad_y(15, ui.center_x(box_title)),
+ elem,
+ ui.pad_y(20)
+ )) }
+end
+
+local function mm_create(actions)
+ local host_game = menubox("Host Game", ui.pad_x(10,
+ ui.flex(1, ui.button("Create Save")),
+ ui.flex(1, ui.button("Load Save"))
+ ))
+
+ local invite_code = ui.input("Invite Code")
+ local join_game = menubox("Join Game", ui.pad_x(10,
+ ui.flex(3, invite_code),
+ ui.flex(1, ui.button("Join", function()
+ if invite_code.input_value ~= "" then
+ actions.join_game(invite_code.input_value)
+ end
+ end))
+ ))
+
+ local edit_map = menubox("Edit Map", ui.button("Open Editor"))
+
+ return ui.new(ui.stack_y(
+ ui.pad_y(50, ui.center_x(ui.text("RAINBOW SIX: PANOPTICON", 50, color(0x000000)))),
+ ui.stack_x(
+ ui.flex(1),
+ ui.flex(4, ui.pad_y(20,
+ host_game,
+ join_game,
+ edit_map
+ )),
+ ui.flex(1)
+ )
+ ))
+end
+
+return {
+ box = menubox,
+ create = mm_create,
+}
diff --git a/ui.lua b/ui.lua
new file mode 100644
index 0000000..81f453c
--- /dev/null
+++ b/ui.lua
@@ -0,0 +1,412 @@
+local bit32 = require("bit32")
+local utf8 = require("utf8")
+local distribute = require("distribute")
+
+local function ui_new(root)
+ return { targets = {}, root = root }
+end
+
+local function compute(x, ...)
+ if type(x) == "function" then
+ return x(...)
+ end
+ return x
+end
+
+local function access(t, k)
+ if t ~= nil then
+ return t[k]
+ end
+end
+
+local function align(kind, pos, needed, avail)
+ if not kind then
+ return pos, avail
+ elseif kind == "start" then
+ return pos, needed
+ elseif kind == "center" then
+ return pos+(avail-needed)/2, needed
+ elseif kind == "end" then
+ return pos+avail-needed, needed
+ end
+end
+
+local function add_computed(node, attr, k, v)
+ node.computed = node.computed or {}
+ node.computed[attr] = node.computed[attr] or {}
+ node.computed[attr][k] = v
+end
+
+local function node_compute_min_size(node, dir)
+ local size = access(compute(node.size, node), dir)
+ add_computed(node, "fixed_size", dir, size)
+
+ if not size then
+ size = 0
+ for _, child in ipairs(node) do
+ local child_size = node_compute_min_size(child, dir)
+ if node.stack == dir then
+ size = size + child_size
+ else
+ size = math.max(size, child_size)
+ end
+ end
+ end
+
+ add_computed(node, "min_size", dir, size)
+ return size
+end
+
+local function node_compute_layout(node, dir, pos, size)
+ size = node.computed.fixed_size[dir] or size
+ local align_as = access(node.align, dir)
+
+ add_computed(node, "pos", dir, pos)
+ add_computed(node, "size", dir, size)
+
+ if node.stack ~= dir then
+ for _, child in ipairs(node) do
+ node_compute_layout(child, dir, align(align_as, pos, child.computed.min_size[dir], size))
+ end
+ return
+ end
+
+ local min_size_noflex = 0
+ local flex_elems = {}
+ local has_flex
+ for i, child in ipairs(node) do
+ if child.flex then
+ has_flex = true
+ flex_elems[i] = {
+ min = child.computed.min_size[dir],
+ weight = child.flex,
+ }
+ else
+ min_size_noflex = min_size_noflex + child.computed.min_size[dir]
+ end
+ end
+
+ distribute(size-min_size_noflex, flex_elems)
+
+ if not has_flex then
+ pos = align(align_as, pos, min_size_noflex, size)
+ end
+
+ for i, child in ipairs(node) do
+ local child_size = access(flex_elems[i], "receive") or child.computed.min_size[dir]
+ node_compute_layout(child, dir, pos, child_size)
+ pos = pos + child_size
+ end
+end
+
+local function tree_compute_layout(tree, x, y, width, height)
+ node_compute_min_size(tree, "x")
+ node_compute_min_size(tree, "y")
+ node_compute_layout(tree, "x", x, width)
+ node_compute_layout(tree, "y", y, height)
+end
+
+-- render
+
+local function update_flags(node, targets, flags)
+ local new = {}
+ for k, v in pairs(targets) do
+ new[k] = node == v or (flags[k] and not (node.events and node.events[k]))
+ end
+ return new
+end
+
+local function node_render(node, targets, flags)
+ flags = update_flags(node, targets, flags)
+
+ local pos, size = node.computed.pos, node.computed.size
+ if node.fill then
+ love.graphics.setColor(compute(node.fill, node, flags))
+ love.graphics.rectangle("fill", pos.x, pos.y, size.x, size.y)
+ end
+ if node.line then
+ love.graphics.setColor(compute(node.line, node, flags))
+ love.graphics.rectangle("line", pos.x, pos.y, size.x, size.y)
+ end
+ if node.text then
+ love.graphics.setColor(compute(node.color, node, flags))
+ love.graphics.draw(node.text, pos.x, pos.y)
+ end
+
+ for _, child in ipairs(node) do
+ node_render(child, targets, flags)
+ end
+end
+
+local function ui_render(ui)
+ node_render(ui.root, ui.targets, {})
+end
+
+-- events
+
+local function node_get_target(node, event, x, y)
+ -- todo: maybe reverse iteration?
+ for _, child in ipairs(node) do
+ local hov = node_get_target(child, event, x, y)
+ if hov then
+ return hov
+ end
+ end
+
+ if not node.events or not node.events[event] then
+ return nil
+ end
+
+ local pos, size = node.computed.pos, node.computed.size
+ if x >= pos.x and y >= pos.y and x < pos.x + size.x and y < pos.y + size.y then
+ return node
+ end
+end
+
+local function ui_get_target(ui, event)
+ return node_get_target(ui.root, event, love.mouse.getPosition())
+end
+
+local function fire_event(node, event, ...)
+ if node and node.events and node.events[event] then
+ if type(node.events[event]) == "function" then
+ node.events[event](node, ...)
+ end
+ return true
+ end
+ return false
+end
+
+local function ui_set_target(ui, event, elem)
+ local old = ui.targets[event]
+ if old ~= elem then
+ fire_event(old, event.."_end")
+ fire_event(elem, event)
+ end
+ ui.targets[event] = elem
+end
+
+local function ui_update_target(ui, event)
+ ui_set_target(ui, event, ui_get_target(ui, event))
+end
+
+local function ui_mousepressed(ui, x, y, button)
+ if button ~= 1 then
+ return false
+ end
+
+ ui_update_target(ui, "press")
+ ui_update_target(ui, "focus")
+
+ local focus = ui.targets.focus
+ if focus and focus.events.input then
+ local pos, size = focus.computed.pos, focus.computed.size
+ love.keyboard.setTextInput(true, pos.x, pos.y, size.x, size.y)
+ else
+ love.keyboard.setTextInput(false)
+ end
+
+ if ui.targets.press or ui.targets.focus then
+ return true
+ end
+
+ return false
+end
+
+local function ui_mousereleased(ui, x, y, button)
+ if button ~= 1 then
+ return false
+ end
+
+ local press = ui_get_target(ui, "press", love.mouse.getPosition())
+ if press == ui.targets.press then
+ fire_event(press, "click")
+ end
+ ui_set_target(ui, "press", nil)
+
+ if press then
+ return true
+ end
+ return false
+end
+
+local function ui_textinput(ui, text)
+ return fire_event(ui.targets.focus, "input", text)
+end
+
+local function ui_keypressed(ui, key)
+ if key == "backspace" then
+ return fire_event(ui.targets.focus, "input", "\b")
+ elseif key == "v" and (love.keyboard.isDown("rctrl") or love.keyboard.isDown("lctrl")) then
+ return fire_event(ui.targets.focus, "input", love.system.getClipboardText())
+ end
+
+ return false
+end
+
+-- update
+
+local function ui_update(ui)
+ tree_compute_layout(ui.root, 0, 0, love.graphics:getDimensions())
+ ui_update_target(ui, "hover")
+end
+
+-- presets
+
+local function color(hex)
+ return { love.math.colorFromBytes(
+ bit32.band(bit32.rshift(hex, 16), 0xff),
+ bit32.band(bit32.rshift(hex, 8), 0xff),
+ bit32.band(bit32.rshift(hex, 0), 0xff)
+ ) }
+end
+
+local function ui_flex(n, ...)
+ return { flex = n, ... }
+end
+
+local function pad(dir, size, ...)
+ local p = { size = { [dir] = size } }
+ local nodes = { ... }
+ if #nodes > 1 then
+ local x = { stack = dir }
+ for i, n in ipairs(nodes) do
+ table.insert(x, n)
+ if i < #nodes then
+ table.insert(x, p)
+ end
+ end
+ return x
+ elseif #nodes == 1 then
+ return { stack = dir, p, ui_flex(1, nodes[1]), p }
+ end
+ return p
+end
+
+local function ui_pad_x(...)
+ return pad("x", ...)
+end
+
+local function ui_pad_y(...)
+ return pad("y", ...)
+end
+
+local function center_x(...)
+ return { align = { x = "center" }, ... }
+end
+
+local function center_y(...)
+ return { align = { y = "center" }, ... }
+end
+
+local function stack_x(...)
+ return { stack = "x", ... }
+end
+
+local function stack_y(...)
+ return { stack = "y", ... }
+end
+
+local function ui_text(str, size, col)
+ return {
+ color = col,
+ text = love.graphics.newText(love.graphics.newFont(size), str),
+ size = function(self) return { x = self.text:getWidth(), y = self.text:getHeight() } end,
+ }
+end
+
+local function ui_button(label, click)
+ return {
+ fill = function(self, f)
+ return f.hover and color(0x8080a0) or color(0x808080)
+ end,
+ line = color(0x505050),
+ events = { hover = true, press = true, click = click, },
+ ui_pad_y(5, center_x(ui_pad_x(10,
+ ui_text(label, 25, color(0x000000))
+ )))
+ }
+end
+
+local function ui_input(placeholder)
+ local function is_placeholder(self, focus)
+ return self.input_value == "" and not focus
+ end
+ local function update_text(self, focus)
+ self.input_text:set(is_placeholder(self, focus) and self.input_placeholder or self.input_value)
+ self.input_interact_time = love.timer.getTime()
+ end
+
+ local text = ui_text(placeholder, 25, function(self, f)
+ return is_placeholder(self.input_parent, f.focus) and color(0xb0b0b0) or color(0x000000)
+ end)
+
+ local marker = {
+ size = { x = 1, y = text.text:getHeight() },
+ fill = function(self, f)
+ return (f.focus and
+ math.floor(love.timer.getTime() - self.input_parent.input_interact_time) % 2 == 0)
+ and color(0x000000) or color(0xffffff)
+ end
+ }
+
+ -- TODO: cursor
+ -- TODO: overflow as scroll
+ -- TODO: paste icon (optional)
+
+ local elem = {
+ fill = color(0xffffff),
+ line = function(self, f)
+ return f.focus and color(0x5050ff) or color(0x505050)
+ end,
+ events = {
+ focus = function(self)
+ update_text(self, true)
+ end,
+ focus_end = function(self)
+ update_text(self, false)
+ end,
+ input = function(self, text)
+ if text == "\b" then
+ local byteoffset = utf8.offset(self.input_value, -1)
+ if byteoffset then
+ self.input_value = string.sub(self.input_value, 1, byteoffset - 1)
+ end
+ else
+ self.input_value = self.input_value .. text
+ end
+ update_text(self, true)
+ end,
+ },
+ input_value = "",
+ input_text = text.text,
+ input_placeholder = placeholder,
+ ui_pad_y(5, ui_pad_x(10, stack_x(text, marker)))
+ }
+
+ text.input_parent = elem
+ marker.input_parent = elem
+
+ return elem
+end
+
+return {
+ new = ui_new,
+ render = ui_render,
+ update = ui_update,
+ keypressed = ui_keypressed,
+ textinput = ui_textinput,
+ mousepressed = ui_mousepressed,
+ mousereleased = ui_mousereleased,
+ color = color,
+ pad_x = ui_pad_x,
+ pad_y = ui_pad_y,
+ center_x = center_x,
+ center_y = center_y,
+ stack_x = stack_x,
+ stack_y = stack_y,
+ flex = ui_flex,
+ text = ui_text,
+ button = ui_button,
+ input = ui_input,
+}