single game fix

This commit is contained in:
Lett Osprey 2026-04-13 12:17:50 +02:00
parent 09bf741997
commit f3020b7cb0
6 changed files with 195 additions and 128 deletions

View file

@ -51,7 +51,7 @@ fn layout(header: String, ohno: option.Option(String), body: List(Element(Msg)))
fn view_room_list(items: List(Room)) -> Element(Msg) { fn view_room_list(items: List(Room)) -> Element(Msg) {
layout("Select room to play in", None, case items { layout("Select room to play in", None, case items {
[] -> [html.text("No items in your list yet.")] [] -> [html.text("No rooms exist, nowhere to play! (ohno!)")]
_ -> { _ -> {
list.index_map(items, fn(item, index) { list.index_map(items, fn(item, index) {
room_cell(index, item, SelectedRoom) room_cell(index, item, SelectedRoom)
@ -93,7 +93,6 @@ fn view_live_or_single() -> Element(Msg) {
]) ])
} }
fn input_cell( fn input_cell(
header: String, header: String,
password: Bool, password: Bool,
@ -101,7 +100,7 @@ fn input_cell(
) -> Element(Msg) { ) -> Element(Msg) {
html.div([class("participant-login")], [ html.div([class("participant-login")], [
html.div([class("participant-name")], [ html.div([class("participant-name")], [
html.text("" <> header), html.p([], [html.text("" <> header)]),
html.div([], [ html.div([], [
html.input([ html.input([
attribute.type_(case password { attribute.type_(case password {

View file

@ -26,9 +26,9 @@ body {
.terminal-glow { .terminal-glow {
position: relative; position: relative;
text-shadow: text-shadow:
0 0 5px rgba(0, 255, 0, 0.8), 0 0 5px rgba(0, 255, 0, 0.8),
0 0 10px rgba(0, 255, 0, 0.5), 0 0 10px rgba(0, 255, 0, 0.5),
0 0 20px rgba(0, 255, 0, 0.3); 0 0 20px rgba(0, 255, 0, 0.3);
animation: flicker 0.15s infinite alternate; animation: flicker 0.15s infinite alternate;
} }
@ -40,11 +40,11 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: repeating-linear-gradient( background: repeating-linear-gradient(
0deg, 0deg,
rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px, rgba(0, 0, 0, 0.15) 1px,
transparent 1px, transparent 1px,
transparent 2px transparent 2px
); );
pointer-events: none; pointer-events: none;
z-index: 1000; z-index: 1000;
@ -261,24 +261,64 @@ body {
} }
} }
input { .terminal-input-wrap {
background-color: #000000; display: flex;
align-items: center;
gap: 0.4ch;
}
.terminal-input-wrap::before {
content: "$>";
color: #00ff00; color: #00ff00;
width: 20cap; font-weight: bold;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.8);
flex-shrink: 0;
}
input {
background: transparent;
color: #00ff00;
font-family: "Courier New", "Courier", monospace;
font-size: inherit;
border: none;
border-bottom: 2px solid #00aa00;
outline: none;
caret-color: #00ff00;
width: 20ch;
padding: 0 0 2px 0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.6);
}
button {
background: transparent;
color: #00ff00;
font-family: "Courier New", "Courier", monospace;
font-size: inherit;
border: none;
border-bottom: 2px solid #00aa00;
outline: none;
caret-color: #00ff00;
width: 20ch;
padding: 0 0 2px 0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.6);
} }
input:focus { input:focus {
background-color: #000000; border-bottom-color: #00ff00;
width: 20cap; box-shadow: 0 2px 8px rgba(0, 255, 0, 0.3);
border-color: #00ff00; }
outline: none; button:hover {
border-bottom-color: #00ff00;
box-shadow: 0 2px 8px rgba(0, 255, 0, 0.3);
} }
button {
/*button {
background-color: #000000; background-color: #000000;
color: #00ff00; color: #00ff00;
border: 3px solid #73ad21; border: 3px solid #73ad21;
} }*/
button:focus { button:focus {
border: 3px solid #534d01; border: 3px solid #534d01;

View file

@ -1,4 +1,4 @@
import components.{click_cell, click_cell_pair} import components.{click_cell_pair}
import gleam/bit_array import gleam/bit_array
import gleam/crypto import gleam/crypto
import gleam/dynamic/decode import gleam/dynamic/decode
@ -146,7 +146,7 @@ fn view(model: Model) -> Element(Msg) {
html.div([class("terminal-status")], [ html.div([class("terminal-status")], [
html.span([class("status-blink")], [html.text("")]), html.span([class("status-blink")], [html.text("")]),
html.text(" SYSTEM READY"), html.text(" SYSTEM READY"),
html.span([class("ml-8")], [ html.div([class("ml-8")], [
case model.state { case model.state {
Initial -> html.text("STATUS: Please select player") Initial -> html.text("STATUS: Please select player")
ReceiveName(_) -> html.text("STATUS: Please enter your name") ReceiveName(_) -> html.text("STATUS: Please enter your name")
@ -163,19 +163,18 @@ fn view(model: Model) -> Element(Msg) {
html.text("[ACTIVE TRANSMISSIONS]"), html.text("[ACTIVE TRANSMISSIONS]"),
]), ]),
]), ]),
html.div([class("participants-grid")], [ html.div([class("participants-grid")], [
case model.state { case model.state {
Initial -> Initial ->
case model.players { case model.players {
[] -> input_new_player() [] -> shared.input_new_player(ReceiveName)
_ -> view_players(model.players) _ -> shared.view_players(model.players, PickedPlayer)
} }
PickQuestion -> view_questions(model.answers) PickQuestion -> view_questions(model.answers)
ReceiveName(_) -> input_new_player() ReceiveName(_) -> shared.input_new_player(ReceiveName)
AcceptPlayer(Some(player)) -> { AcceptPlayer(Some(player)) -> {
let #(_, player_name) = player let #(_, player_name) = player
shared.confirm_cell( shared.confirm_cells(
Some("Join as this player: " <> player_name <> "?"), Some("Join as this player: " <> player_name <> "?"),
player, player,
AcceptPlayer, AcceptPlayer,
@ -189,26 +188,6 @@ fn view(model: Model) -> Element(Msg) {
]) ])
} }
fn view_players(players: List(#(String, String))) {
html.div([], [
html.div(
[],
list.append(
list.index_map(players, fn(item, index) {
click_cell_pair(Some(int.to_string(index)), Some(item), PickedPlayer)
}),
[click_cell_pair(Some("ENTER NEW PLAYER"), None, PickedPlayer)],
),
),
])
}
fn input_new_player() {
html.div([class("participant-box")], [
input_cell("Enter player name:", ReceiveName),
])
}
fn input_new_answer(question: #(String, String)) { fn input_new_answer(question: #(String, String)) {
let #(question_id, question_text) = question let #(question_id, question_text) = question
html.div([class("participant-box")], [ html.div([class("participant-box")], [
@ -220,17 +199,25 @@ fn input_new_answer(question: #(String, String)) {
} }
fn view_questions(answers: List(#(String, #(String, String)))) { fn view_questions(answers: List(#(String, #(String, String)))) {
html.div( html.div([], [
[class("singles-grid")], html.div(
list.map(answers, fn(content) { [class("singles-grid")],
let #(number, #(question, answer)) = content list.map(answers, fn(content) {
click_cell_pair( let #(number, #(question, answer)) = content
Some(number <> " " <> answer), click_cell_pair(
Some(#(number, question)), Some(number <> " " <> answer),
PickedQuestion, Some(#(number, question)),
) True,
}), PickedQuestion,
) )
}),
),
html.div([], [
html.text(
"[Your answers are saved automatically, when you are done answering, simply close the window]",
),
]),
])
} }
fn content_cell(answer: #(Int, #(String, String))) -> Element(Msg) { fn content_cell(answer: #(Int, #(String, String))) -> Element(Msg) {

View file

@ -12,7 +12,7 @@ import lustre/element/html
import lustre/server_component import lustre/server_component
import shared/message.{type NotifyClient, type NotifyServer, type User, User} import shared/message.{type NotifyClient, type NotifyServer, type User, User}
import web/components/shared.{ import web/components/shared.{
step_prompt, view_input, view_named_input, view_yes_no, input_new_player, step_prompt, view_named_input, view_players,
} }
pub fn component() -> lustre.App(message.ClientsServer, Model, Msg) { pub fn component() -> lustre.App(message.ClientsServer, Model, Msg) {
@ -29,6 +29,7 @@ type State {
pub opaque type Model { pub opaque type Model {
Model( Model(
state: State, state: State,
players: List(#(String, String)),
lobby: #(String, List(User)), lobby: #(String, List(User)),
registry: GroupRegistry(NotifyClient), registry: GroupRegistry(NotifyClient),
handler: Started(Subject(NotifyServer)), handler: Started(Subject(NotifyServer)),
@ -38,7 +39,14 @@ pub opaque type Model {
fn init(handlers: message.ClientsServer) -> #(Model, Effect(Msg)) { fn init(handlers: message.ClientsServer) -> #(Model, Effect(Msg)) {
let #(registry, handler) = handlers let #(registry, handler) = handlers
let model = Model(AskName, #("", []), registry, handler) let model =
Model(
AskName,
actor.call(handler.data, 1000, message.FetchPlayers),
#("", []),
registry,
handler,
)
#(model, subscribe(registry, SharedMessage)) #(model, subscribe(registry, SharedMessage))
} }
@ -58,8 +66,8 @@ fn subscribe(
pub opaque type Msg { pub opaque type Msg {
SharedMessage(message: NotifyClient) SharedMessage(message: NotifyClient)
ReceiveName(message: String) ReceiveName(Option(String))
AcceptName(accept: Option(String)) AcceptName(Option(#(String, String)))
GiveAnswer(name: String, answer: String) GiveAnswer(name: String, answer: String)
} }
@ -67,9 +75,13 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
let handler = model.handler let handler = model.handler
case msg { case msg {
ReceiveName(name) -> #(Model(..model, state: NameOk(name)), effect.none()) ReceiveName(Some(name)) -> #(
Model(..model, state: NameOk(name)),
effect.none(),
)
AcceptName(Some(name)) -> { AcceptName(Some(name)) -> {
actor.send(handler.data, message.GiveName(name:)) let #(_, name) = name
actor.send(handler.data, message.GiveName(name))
#(Model(..model, state: WaitForQuiz(name)), effect.none()) #(Model(..model, state: WaitForQuiz(name)), effect.none())
} }
AcceptName(None) -> #(Model(..model, state: AskName), effect.none()) AcceptName(None) -> #(Model(..model, state: AskName), effect.none())
@ -81,13 +93,15 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
handle_server_message(model, shared_msg), handle_server_message(model, shared_msg),
effect.none(), effect.none(),
) )
_ -> #(model, effect.none())
} }
} }
fn handle_server_message(model: Model, notify_client) { fn handle_server_message(model: Model, notify_client) {
case notify_client { case notify_client {
message.Lobby(question, lobby) -> Model(..model, lobby: #(question, lobby)) message.Lobby(question, lobby) -> Model(..model, lobby: #(question, lobby))
message.Exit -> Model(AskName, #("", []), model.registry, model.handler) message.Exit ->
Model(AskName, model.players, #("", []), model.registry, model.handler)
message.Answer -> message.Answer ->
case model.state { case model.state {
// We are currently waiting for next quiz question, ok to switch to answer mode // We are currently waiting for next quiz question, ok to switch to answer mode
@ -117,20 +131,21 @@ fn handle_server_message(model: Model, notify_client) {
fn view(model: Model) -> Element(Msg) { fn view(model: Model) -> Element(Msg) {
let #(question, lobby) = model.lobby let #(question, lobby) = model.lobby
element.fragment([ element.fragment([
html.div([attribute.class("terminal-prompt")], [ html.div([attribute.class("terminal-prompt")], [
case model.state { case model.state {
AskName -> AskName ->
step_prompt( case model.players {
"Hello stranger. To join the quiz, I need to know your name", [] -> input_new_player(ReceiveName)
fn() { view_input(ReceiveName) }, _ -> view_players(model.players, AcceptName)
) }
NameOk(name) -> NameOk(name) -> {
step_prompt( shared.confirm_cells(
"Your name is " <> name <> "? Are you absolutely sure???", Some("Join as this player: " <> name <> "?"),
fn() { view_yes_no(name, AcceptName) }, #("", name),
AcceptName,
) )
}
Answer(name) -> Answer(name) ->
step_prompt( step_prompt(
"The Quiz Lead will now ask the question, and you may answer.", "The Quiz Lead will now ask the question, and you may answer.",

View file

@ -1,4 +1,7 @@
import components.{click_cell_pair}
import gleam/dynamic/decode import gleam/dynamic/decode
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some} import gleam/option.{type Option, None, Some}
import lustre/attribute import lustre/attribute
import lustre/element.{type Element} import lustre/element.{type Element}
@ -49,35 +52,6 @@ pub fn view_input(on_submit handle_keydown: fn(String) -> msg) -> Element(msg) {
) )
} }
pub fn view_yes_no(
accepted: String,
on_submit handle_button: fn(Option(String)) -> msg,
) -> Element(msg) {
html.div([], [
html.button([event.on_click(handle_button(Some(accepted)))], [
html.text(" <Yes> "),
]),
html.text(" - "),
html.button([event.on_click(handle_button(None))], [html.text(" <No> ")]),
])
}
fn key_down(
success: fn(String) -> decode.Decoder(msg),
fail: fn() -> decode.Decoder(msg),
) {
event.on("keydown", {
use key <- decode.field("key", decode.string)
use value <- decode.subfield(["target", "value"], decode.string)
case key {
"Enter" if value != "" -> success(value)
_ -> fail()
}
})
|> server_component.include(["key", "target.value"])
}
fn prompt_input(key, on_keydown) { fn prompt_input(key, on_keydown) {
keyed.div([], [ keyed.div([], [
#(key <> "header", html.text("$>")), #(key <> "header", html.text("$>")),
@ -103,35 +77,83 @@ pub fn step_prompt(text: String, fetch: fn() -> Element(a)) {
]) ])
} }
//fn xiew_player_list(items: List(String)) -> Element(Msg) { pub fn confirm_cells(
// layout("Select or enter your player", None, case items {
// [] -> [html.text("No items in your list yet.")]
// _ -> {
// list.append(
// list.index_map(items, fn(item, index) {
// click_cell(index, item, SelectedPlayer)
// }),
// [input_cell("[#NEW PLAYER]", False, KeyPin)],
// )
// }
// })
//
pub fn confirm_cell(
title: Option(String), title: Option(String),
accepted: #(String, String), accepted: #(String, String),
on_submit handle_button: fn(Option(#(String, String))) -> msg, on_submit handle_button: fn(Option(#(String, String))) -> msg,
) -> Element(msg) { ) -> Element(msg) {
html.div([attribute.class("participant-login")], [ html.div([], [
html.div([attribute.class("participant-name")], [ html.div([attribute.class("participant-box")], [
case title { html.div([attribute.class("participant-name")], [
Some(title) -> html.div([], [html.text(title)]) case title {
None -> element.none() Some(title) -> html.div([], [html.text(title)])
}, _ -> element.none()
html.button([event.on_click(handle_button(Some(accepted)))], [ },
html.text(" <Yes> "),
]), ]),
html.text(" - "), ]),
html.button([event.on_click(handle_button(None))], [html.text(" <No> ")]), click_cell_pair(Some("Yes"), Some(accepted), False, handle_button),
click_cell_pair(Some("No"), None, False, handle_button),
])
}
pub fn view_players(
players: List(#(String, String)),
handler: fn(Option(#(String, String))) -> msg,
) {
html.div([], [
html.div(
[],
list.append(
list.index_map(players, fn(item, index) {
click_cell_pair(Some(int.to_string(index)), Some(item), True, handler)
}),
[click_cell_pair(Some("ENTER NEW PLAYER"), None, True, handler)],
),
),
])
}
pub fn input_new_player(handler: fn(Option(String)) -> msg) {
html.div([attribute.class("participant-box")], [
input_cell("Enter player name:", handler),
])
}
pub fn input_cell(
text: String,
on_submit handle_keydown: fn(Option(String)) -> msg,
) -> Element(msg) {
html.div([attribute.class("singles-grid")], [
html.div([], [html.text(text)]),
keyed.div([], [
#("inputheader", html.text("$>")),
#(
"input",
html.input([
attribute.type_("text"),
key_down(
fn(a: String) { decode.success(handle_keydown(Some(a))) },
fn() { decode.failure(handle_keydown(None), "") },
),
attribute.autofocus(True),
]),
),
]), ]),
]) ])
} }
fn key_down(
success: fn(String) -> decode.Decoder(msg),
fail: fn() -> decode.Decoder(msg),
) {
event.on("keydown", {
use key <- decode.field("key", decode.string)
use value <- decode.subfield(["target", "value"], decode.string)
case key {
"Enter" if value != "" -> success(value)
_ -> fail()
}
})
|> server_component.include(["key", "target.value"])
}

View file

@ -27,6 +27,7 @@ pub fn click_cell(
pub fn click_cell_pair( pub fn click_cell_pair(
tag: Option(String), tag: Option(String),
pair: Option(#(String, String)), pair: Option(#(String, String)),
display_value: Bool,
on_click: fn(Option(#(String, String))) -> msg, on_click: fn(Option(#(String, String))) -> msg,
) -> Element(msg) { ) -> Element(msg) {
let value = case pair { let value = case pair {
@ -47,7 +48,10 @@ pub fn click_cell_pair(
}, },
), ),
]), ]),
html.text(value), case display_value {
True -> html.text(value)
False -> element.none()
},
]), ]),
]) ])
} }