diff --git a/assets/styles.css b/assets/styles.css index 8526452..fdbf5a2 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -150,6 +150,135 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible } .table-empty-state h2 { margin: 10px 0 8px; font: 700 28px var(--font-display); } .table-empty-state p { margin: 0; color: var(--muted); font-size: 14px; } +.dice-chat-panel { min-width: 0; } +.dice-chat-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 18px; +} +.dice-chat-heading .section-heading { margin-bottom: 0; } +.dice-chat-heading > button { flex: 0 0 auto; min-height: 40px; } +.dice-window-layer { + position: fixed; + z-index: 100; + inset: 0; + padding: 24px; + display: grid; + align-items: end; + justify-items: end; + pointer-events: none; +} +.dice-window { + width: min(520px, 100%); + max-height: calc(100vh - 48px); + padding: 24px; + overflow: auto; + pointer-events: auto; + border: 1px solid var(--line-strong); + background: var(--surface); + box-shadow: 0 28px 90px rgba(0, 0, 0, .65); +} +.dice-window-heading { + margin-bottom: 20px; + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} +.dice-window-heading h2 { margin: 5px 0 0; font: 800 27px var(--font-display); } +.dice-window-heading > button { + width: 38px; + height: 38px; + border: 1px solid var(--line); + color: var(--muted); + background: transparent; + font-size: 24px; +} +.dice-window-heading > button:hover { color: var(--ink); border-color: var(--line-strong); } +.dice-window-controls { + display: grid; + grid-template-columns: 100px minmax(0, 1fr); + gap: 10px; + margin-bottom: 14px; +} +.dice-window .dice-roll-button { width: 100%; } +.dice-window-result { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--line); +} +.dice-window-result > span { + display: block; + margin-bottom: 8px; + color: var(--faint); + font: 9px var(--font-mono); + letter-spacing: .07em; + text-transform: uppercase; +} +.dice-window-result .dice-roll { background: var(--surface-raised); } +.roll-mode-group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 12px; +} +.roll-mode-group button { + min-height: 38px; + border: 1px solid var(--line-strong); + color: var(--muted); + background: var(--surface-soft); + font-size: 12px; +} +.roll-mode-group button.selected { + color: #111; + border-color: var(--accent); + background: var(--accent); +} +.dice-controls { + display: grid; + grid-template-columns: 90px minmax(0, 1fr) 110px; + align-items: end; + gap: 8px; +} +.dice-controls .field > span { font-size: 11px; } +.dice-roll-button { min-height: 44px; } +.dice-history { + max-height: 360px; + margin-top: 14px; + overflow: auto; + display: grid; + gap: 7px; +} +.dice-empty { margin: 14px 0 0; color: var(--faint); font-size: 12px; } +.dice-roll { + padding: 11px 12px; + border: 1px solid var(--line); + background: #151815; +} +.dice-roll-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.dice-roll-heading strong { font-size: 13px; } +.dice-roll-heading span { color: var(--accent); font: 10px var(--font-mono); } +.dice-roll-note { margin: 5px 0 0; color: var(--muted); font-size: 12px; } +.dice-roll-result { + margin-top: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto 44px; + align-items: center; + gap: 8px; + font: 12px var(--font-mono); +} +.dice-roll-result > span:nth-child(2) { color: var(--muted); } +.dice-roll-result strong { + color: var(--accent); + text-align: right; + font: 800 22px var(--font-display); +} .workspace { width: min(1480px, 94vw); margin: 0 auto; @@ -277,6 +406,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .inspector-ability span { color: var(--accent); font: 8px var(--font-mono); } .inspector-ability strong { margin: 3px 0; font: 700 20px var(--font-display); } .inspector-ability small { color: var(--muted); font: 8px var(--font-mono); } +.inspector-ability-rolls { display: flex; justify-content: center; gap: 7px; } +.inspector-ability-rolls .roll-modifier { font: 8px var(--font-mono); } .inspector-skill-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px; } .inspector-skill { min-width: 0; padding: 6px 7px; display: grid; grid-template-columns: 7px minmax(0,1fr) 25px 22px; align-items: center; gap: 5px; color: var(--muted); border: 1px solid var(--line); font-size: 9px; } .inspector-skill.proficient { color: var(--ink); border-color: rgba(201,255,82,.22); } @@ -284,6 +415,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .inspector-skill.proficient .skill-dot { border-color: var(--accent); background: var(--accent); } .inspector-skill small { color: var(--faint); font: 7px var(--font-mono); } .inspector-skill strong { text-align: right; font: 9px var(--font-mono); } +.inspector-skill .roll-modifier { justify-self: end; font: 9px var(--font-mono); } .inspector-choice, .inspector-equipment { padding: 9px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--line); background: #151815; } .inspector-choice span { color: var(--muted); font-size: 9px; } .inspector-choice strong { text-align: right; font-size: 9px; } @@ -421,6 +553,28 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .score-control button:disabled { opacity: .3; cursor: not-allowed; } .score-control strong { text-align: center; font: 700 27px var(--font-display); } .modifier { margin-top: 8px; min-width: 38px; padding: 4px 7px; text-align: center; color: #dce0dc; background: #111411; font: 500 11px var(--font-mono); } +.roll-modifier { + padding: 0; + border: 0; + border-bottom: 1px solid rgba(201, 255, 82, .35); + color: var(--ink); + background: transparent; + cursor: pointer; + font: inherit; +} +.roll-modifier:hover, .roll-modifier:focus-visible { + color: var(--accent); + border-bottom-color: var(--accent); + outline: none; +} +.ability-card .roll-modifier { + min-height: 28px; + border: 1px solid var(--line); +} +.stat-roll-modifier { + margin: 4px 0; + font: 800 28px var(--font-display); +} .skills-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; } .skill-row { min-width: 0; height: 38px; padding: 0 10px; display: grid; grid-template-columns: 18px minmax(0,1fr) 30px 28px; align-items: center; gap: 7px; border: 1px solid var(--line); color: var(--muted); background: var(--surface-raised); cursor: pointer; } @@ -509,10 +663,12 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .proficiency { width: 6px; height: 6px; border: 1px solid var(--faint); border-radius: 50%; } .proficiency.on { border-color: var(--accent); background: var(--accent); box-shadow: 0 0 5px rgba(201,255,82,.4); } .saving-throws strong { text-align: right; font: 500 10px var(--font-mono); } +.saving-throws .roll-modifier { justify-self: end; font: 500 10px var(--font-mono); } .quick-stats { display: grid; grid-template-columns: repeat(3, 1fr); padding: 0; } .quick-stats > div { padding: 17px 8px; text-align: center; border-right: 1px solid var(--line); } .quick-stats > div:last-child { border: 0; } .quick-stats span { display: block; color: var(--faint); font: 500 7px var(--font-mono); letter-spacing: .06em; } +.quick-stats .roll-modifier { margin-top: 5px; font: 700 13px var(--font-display); } .quick-stats strong { display: block; margin-top: 5px; font: 700 13px var(--font-display); } .source-card { margin: 15px; padding: 12px; display: grid; grid-template-columns: 35px 1fr 24px; align-items: center; gap: 10px; border: 1px solid rgba(201,255,82,.18); background: var(--accent-soft); } .source-icon { width: 34px; height: 34px; display: grid; place-items: center; color: #111; background: var(--accent); font: 700 10px var(--font-mono); } @@ -574,8 +730,18 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .topbar { grid-template-columns: 1fr 1fr; } .data-status { display: none; } } +@media (max-width: 600px) { + .dice-window-layer { padding: 12px; } + .dice-window { + max-height: calc(100vh - 24px); + padding: 20px; + } + .dice-window-controls { grid-template-columns: 1fr; } + .dice-chat-heading { align-items: stretch; flex-direction: column; } +} @media (max-width: 800px) { .topbar { height: 70px; padding: 0 18px; } + .dice-controls { grid-template-columns: 1fr; } .data-status div { display: none; } .workspace { width: min(94vw, 680px); grid-template-columns: minmax(0, 1fr); padding-top: 18px; } .builder, .panel, .sheet, .progression-panel { min-width: 0; max-width: 100%; } diff --git a/gleam.toml b/gleam.toml index 8f052ef..364c792 100644 --- a/gleam.toml +++ b/gleam.toml @@ -19,6 +19,7 @@ gleam_json = ">= 3.1.0 and < 4.0.0" gleam_http = ">= 4.0.0 and < 5.0.0" lustre = ">= 5.7.0 and < 6.0.0" rsvp = ">= 2.0.0 and < 3.0.0" +prng = ">= 6.0.0 and < 7.0.0" varasto = ">= 4.0.0 and < 5.0.0" [dev_dependencies] diff --git a/manifest.toml b/manifest.toml index a8142b5..b79f532 100644 --- a/manifest.toml +++ b/manifest.toml @@ -41,6 +41,7 @@ packages = [ { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, { name = "polly", version = "3.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" }, + { name = "prng", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "prng", source = "hex", outer_checksum = "7FE3879E13150248BD370469205ED07B9404DD90670FEBB8CD0E7643707C420E" }, { name = "rsvp", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "A38F2DB9657E84A584278DB50FD903693FF370C22C191753B773546FEB2465F0" }, { name = "simplifile", version = "2.5.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "6C72DCCDF25C38A5931740B30E823969F33106831FD1637719B5EDBCA30027A4" }, { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, @@ -56,5 +57,6 @@ gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.9.0 and < 2.0.0" } lustre = { version = ">= 5.7.0 and < 6.0.0" } lustre_dev_tools = { version = ">= 2.3.0 and < 3.0.0" } +prng = { version = ">= 6.0.0 and < 7.0.0" } rsvp = { version = ">= 2.0.0 and < 3.0.0" } varasto = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/src/sheets.gleam b/src/sheets.gleam index 9646d5d..61ae1ff 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -2,6 +2,7 @@ import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list +import gleam/result import gleam/string import lustre import lustre/attribute @@ -9,6 +10,7 @@ import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html import lustre/event +import prng/random import rsvp import sheets/browser import sheets/data.{ @@ -18,10 +20,10 @@ import sheets/data.{ FixedOptions, SkillOptions, SkillOrToolOptions, ToolOptions, } import sheets/peer.{ - type Snapshot, type SnapshotAbilities, type SnapshotChoice, type SnapshotFeat, - type SnapshotFeature, type SnapshotSkill, type SnapshotSpell, Snapshot, - SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, - SnapshotFeature, SnapshotSkill, SnapshotSpell, + type DiceRoll, type Snapshot, type SnapshotAbilities, type SnapshotChoice, + type SnapshotFeat, type SnapshotFeature, type SnapshotSkill, + type SnapshotSpell, DiceRoll, Snapshot, SnapshotAbilities, SnapshotChoice, + SnapshotEquipment, SnapshotFeat, SnapshotFeature, SnapshotSkill, SnapshotSpell, } import varasto @@ -63,6 +65,12 @@ pub type PageTab { LiveTablePage } +pub type RollMode { + NormalRoll + AdvantageRoll + DisadvantageRoll +} + pub type FeatureSelection { FeatureSelection(id: String, value: String) } @@ -169,6 +177,12 @@ pub type Model { connection: Connection, remote_sheets: List(Snapshot), inspected_owner: String, + dice_roll_mode: RollMode, + dice_modifier: String, + dice_note: String, + dice_rolls: List(DiceRoll), + dice_seed: random.Seed, + dice_open: Bool, ) } @@ -196,6 +210,12 @@ pub type Message { UserChangedFeatureChoice(String, String) UserSelectedAbilitiesTab(AbilitiesTab) UserSelectedPage(PageTab) + UserSelectedRollMode(RollMode) + UserChangedDiceModifier(String) + UserChangedDiceNote(String) + UserRolledDice + UserOpenedDiceRoll(String, Int) + UserClosedDiceRoll UserChangedSpecies(String) UserChangedBackground(String) UserChangedAbility(Ability, Int) @@ -262,6 +282,10 @@ pub fn main() -> Nil { } fn init(_flags: Nil) -> #(Model, Effect(Message)) { + let dice_seed = + browser.now_iso() + |> timestamp_seed + |> random.new_seed let character_id = browser.new_id() let character_created_at = browser.now_iso() #( @@ -312,6 +336,12 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { connection: Disconnected, remote_sheets: [], inspected_owner: "", + dice_roll_mode: NormalRoll, + dice_modifier: "0", + dice_note: "", + dice_rolls: [], + dice_seed:, + dice_open: False, ), effect.batch([ load_player_character(), @@ -677,21 +707,30 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { let previous = feature_selection_value(model.feature_selections, id) let feature_selections = upsert_feature_selection(model.feature_selections, id, value) - let skills = case feature_choice_is_skill(id) { + let manages_skills = + feature_choice_is_skill(id) + || id == "origin:species:khoravar-proficiency" + let skills = case manages_skills { False -> model.skills - True -> { - let without_previous = case previous { - "" -> model.skills - previous -> - list.filter(model.skills, fn(skill) { skill != previous }) - } - case value == "" || list.contains(without_previous, value) { - True -> without_previous - False -> [value, ..without_previous] - } - } + True -> + model.skills + |> remove_proficiency_value(previous) + |> add_skill_value(value) + } + let manages_tools = + string.contains(id, ":tool") + || id == "origin:species:khoravar-proficiency" + let other_proficiencies = case manages_tools { + False -> model.other_proficiencies + True -> + model.other_proficiencies + |> remove_non_skill_proficiency(previous) + |> add_non_skill_proficiency(value) } - update_and_sync(model, Model(..model, feature_selections:, skills:)) + update_and_sync( + model, + Model(..model, feature_selections:, skills:, other_proficiencies:), + ) } UserSelectedAbilitiesTab(tab) -> #( Model(..model, active_abilities_tab: tab), @@ -701,16 +740,41 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model(..model, active_page: page), effect.none(), ) + UserOpenedDiceRoll(label, modifier) -> #( + Model( + ..model, + dice_open: True, + dice_note: label, + dice_modifier: int.to_string(modifier), + ), + effect.none(), + ) + UserClosedDiceRoll -> #(Model(..model, dice_open: False), effect.none()) + UserSelectedRollMode(mode) -> #( + Model(..model, dice_roll_mode: mode), + effect.none(), + ) + UserChangedDiceModifier(value) -> #( + Model(..model, dice_modifier: value), + effect.none(), + ) + UserChangedDiceNote(value) -> #( + Model(..model, dice_note: value), + effect.none(), + ) + UserRolledDice -> roll_d20(model) UserChangedSpecies(name) -> { let updated = Model( ..model, species_name: name, origin_details: Error("Loading origin features"), - skills: remove_selected_origin_skills( - model.skills, - model.feature_selections, - ), + skills: model.skills + |> remove_selected_origin_skills(model.feature_selections) + |> remove_granted_origin_skills(model.origin_details), + other_proficiencies: model.other_proficiencies + |> remove_selected_origin_tools(model.feature_selections) + |> remove_granted_origin_tools(model.origin_details), feature_selections: remove_origin_selections(model.feature_selections), ) let #(updated, persistence) = update_and_sync(model, updated) @@ -728,10 +792,12 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ..model, background_name: name, origin_details: Error("Loading origin features"), - skills: remove_selected_origin_skills( - model.skills, - model.feature_selections, - ), + skills: model.skills + |> remove_selected_origin_skills(model.feature_selections) + |> remove_granted_origin_skills(model.origin_details), + other_proficiencies: model.other_proficiencies + |> remove_selected_origin_tools(model.feature_selections) + |> remove_granted_origin_tools(model.origin_details), feature_selections: remove_origin_selections(model.feature_selections), ) let #(updated, persistence) = update_and_sync(model, updated) @@ -904,10 +970,36 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ) } } - DataReturnedOrigins(result) -> #( - Model(..model, origin_details: result), - effect.none(), - ) + DataReturnedOrigins(result) -> + case result { + Error(reason) -> #( + Model(..model, origin_details: Error(reason)), + effect.none(), + ) + Ok(details) -> { + let skills = + list.fold(details.granted_skills, model.skills, fn(skills, skill) { + add_unique(skills, skill) + }) + let other_proficiencies = + list.fold( + details.granted_tools, + model.other_proficiencies, + fn(proficiencies, proficiency) { + add_unique(proficiencies, proficiency) + }, + ) + update_and_sync( + model, + Model( + ..model, + origin_details: Ok(details), + skills:, + other_proficiencies:, + ), + ) + } + } DataReturnedFeats(result) -> #( Model(..model, feat_catalog: case result { Ok(feats) -> feats @@ -1126,9 +1218,9 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { PeerSheetReceived(payload) -> case peer.parse_snapshot(payload) { Ok(snapshot) -> { - let inspected_owner = case model.inspected_owner { - "" -> snapshot.owner - owner -> owner + let inspected_owner = case model.connection, model.inspected_owner { + Connected(role: GameMaster, ..), "" -> snapshot.owner + _, owner -> owner } #( Model( @@ -1139,7 +1231,17 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { effect.none(), ) } - Error(_) -> #(model, effect.none()) + Error(_) -> + case peer.parse_dice_roll(payload) { + Ok(roll) -> #( + Model( + ..model, + dice_rolls: upsert_dice_roll(model.dice_rolls, roll), + ), + effect.none(), + ) + Error(_) -> #(model, effect.none()) + } } PeerCountChanged(payload) -> case int.parse(payload), model.connection { @@ -1568,12 +1670,97 @@ fn upsert_snapshot( ] } +fn timestamp_seed(timestamp: String) -> Int { + timestamp + |> string.slice(at_index: 17, length: 6) + |> string.replace(".", "") + |> int.parse + |> result.unwrap(1) +} + +fn roll_d20(model: Model) -> #(Model, Effect(Message)) { + let generator = random.int(1, 20) + let #(first, next_seed) = random.step(generator, model.dice_seed) + let #(second, next_seed) = random.step(generator, next_seed) + let modifier = + model.dice_modifier + |> int.parse + |> result.unwrap(0) + |> bounded_modifier + let kept = case model.dice_roll_mode { + NormalRoll -> first + AdvantageRoll -> + case first >= second { + True -> first + False -> second + } + DisadvantageRoll -> + case first <= second { + True -> first + False -> second + } + } + let roll = + DiceRoll( + id: browser.new_id(), + author: peer_display_name(model, "Player"), + mode: roll_mode_name(model.dice_roll_mode), + first:, + second: case model.dice_roll_mode { + NormalRoll -> 0 + AdvantageRoll -> second + DisadvantageRoll -> second + }, + kept:, + modifier:, + total: kept + modifier, + note: string.trim(model.dice_note), + ) + let updated = + Model( + ..model, + dice_seed: next_seed, + dice_note: "", + dice_rolls: upsert_dice_roll(model.dice_rolls, roll), + ) + let broadcast = case model.connection { + Connected(role: _, ..) -> peer.broadcast_dice(roll) + _ -> effect.none() + } + #(updated, broadcast) +} + +fn bounded_modifier(modifier: Int) -> Int { + case modifier < -99, modifier > 99 { + True, _ -> -99 + False, True -> 99 + False, False -> modifier + } +} + +fn roll_mode_name(mode: RollMode) -> String { + case mode { + NormalRoll -> "Normal" + AdvantageRoll -> "Advantage" + DisadvantageRoll -> "Disadvantage" + } +} + +fn upsert_dice_roll( + rolls: List(DiceRoll), + incoming: DiceRoll, +) -> List(DiceRoll) { + [incoming, ..list.filter(rolls, fn(roll) { roll.id != incoming.id })] + |> list.take(50) +} + fn view(model: Model) -> Element(Message) { let workspace = case model.connection { Connected(role: GameMaster, ..) -> html.main([attribute.class("workspace master-workspace")], [ html.div([attribute.class("builder master-console")], [ view_collaboration(model), + view_dice_chat(model), ]), view_inspected_character(model), ]) @@ -1590,6 +1777,10 @@ fn view(model: Model) -> Element(Message) { _ -> view_page_navigation(model) }, workspace, + case model.dice_open { + True -> view_dice_window(model) + False -> html.text("") + }, view_footer(), ]) } @@ -1652,6 +1843,7 @@ fn view_table_workspace(model: Model) -> Element(Message) { html.div([attribute.class("builder table-console")], [ view_collaboration(model), ]), + view_dice_chat(model), case model.inspected_owner { "" -> view_all_companion_sheets(model) _ -> view_inspected_character(model) @@ -2052,6 +2244,178 @@ fn view_collaboration(model: Model) -> Element(Message) { ]) } +fn view_dice_chat(model: Model) -> Element(Message) { + html.section([attribute.class("panel dice-chat-panel")], [ + html.div([attribute.class("dice-chat-heading")], [ + section_heading("D20", "Roll chat", "Shared checks, saves, and attacks"), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserOpenedDiceRoll("", 0)), + ], + [html.text("Open dice roller")], + ), + ]), + case model.dice_rolls { + [] -> + html.p([attribute.class("dice-empty")], [ + html.text("Rolls from everyone at the table will appear here."), + ]) + rolls -> + html.div( + [attribute.class("dice-history"), attribute.aria_live("polite")], + list.map(rolls, view_dice_roll), + ) + }, + ]) +} + +fn view_dice_window(model: Model) -> Element(Message) { + html.div([attribute.class("dice-window-layer")], [ + html.section( + [ + attribute.class("dice-window"), + attribute.role("dialog"), + attribute.aria_label("Dice roller"), + ], + [ + html.div([attribute.class("dice-window-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("D20")]), + html.h2([], [html.text("Dice roller")]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Close dice roller"), + event.on_click(UserClosedDiceRoll), + ], + [html.text("×")], + ), + ]), + html.div( + [ + attribute.class("roll-mode-group"), + attribute.aria_label("Roll mode"), + ], + [ + roll_mode_button("Normal", NormalRoll, model.dice_roll_mode), + roll_mode_button("Advantage", AdvantageRoll, model.dice_roll_mode), + roll_mode_button( + "Disadvantage", + DisadvantageRoll, + model.dice_roll_mode, + ), + ], + ), + html.div([attribute.class("dice-window-controls")], [ + field( + "Modifier", + html.input([ + attribute.class("text-input dice-modifier"), + attribute.type_("number"), + attribute.min("-99"), + attribute.max("99"), + attribute.value(model.dice_modifier), + event.on_input(UserChangedDiceModifier), + ]), + ), + field( + "What is this roll for?", + html.input([ + attribute.class("text-input"), + attribute.placeholder("Perception, attack, saving throw…"), + attribute.value(model.dice_note), + event.on_input(UserChangedDiceNote), + ]), + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button dice-roll-button"), + event.on_click(UserRolledDice), + ], + [ + html.text( + "Roll d20 · " + <> roll_mode_name(model.dice_roll_mode) + <> case int.parse(model.dice_modifier) { + Ok(0) -> "" + Ok(modifier) -> " " <> signed(bounded_modifier(modifier)) + Error(_) -> "" + }, + ), + ], + ), + case model.dice_rolls { + [latest, ..] -> + html.div([attribute.class("dice-window-result")], [ + html.span([], [html.text("Latest table roll")]), + view_dice_roll(latest), + ]) + [] -> html.text("") + }, + ], + ), + ]) +} + +fn roll_mode_button( + label: String, + mode: RollMode, + selected_mode: RollMode, +) -> Element(Message) { + let selected = mode == selected_mode + html.button( + [ + attribute.type_("button"), + attribute.classes([#("selected", selected)]), + attribute.aria_pressed(case selected { + True -> "true" + False -> "false" + }), + event.on_click(UserSelectedRollMode(mode)), + ], + [html.text(label)], + ) +} + +fn view_dice_roll(roll: DiceRoll) -> Element(Message) { + html.article([attribute.class("dice-roll")], [ + html.div([attribute.class("dice-roll-heading")], [ + html.strong([], [html.text(roll.author)]), + html.span([], [html.text(roll.mode)]), + ]), + case roll.note { + "" -> html.text("") + note -> html.p([attribute.class("dice-roll-note")], [html.text(note)]) + }, + html.div([attribute.class("dice-roll-result")], [ + html.span([], [ + html.text(case roll.second { + 0 -> "d20 " <> int.to_string(roll.first) + second -> + "d20 [" + <> int.to_string(roll.first) + <> ", " + <> int.to_string(second) + <> "] → " + <> int.to_string(roll.kept) + }), + ]), + html.span([], [ + html.text(case roll.modifier { + 0 -> "" + modifier -> signed(modifier) + }), + ]), + html.strong([], [html.text(int.to_string(roll.total))]), + ]), + ]) +} + fn view_connect_form(model: Model, error: String) -> Element(Message) { html.div([], [ html.div([attribute.class("peer-name-row")], [ @@ -2267,7 +2631,12 @@ fn view_snapshot(snapshot: Snapshot) -> Element(Message) { int.to_string(snapshot.hit_points), "Maximum health", ), - stat_block("INITIATIVE", signed(snapshot.initiative), "Dexterity"), + rollable_stat_block( + "INITIATIVE", + snapshot.initiative, + "Dexterity", + display_name <> " initiative", + ), stat_block("SPEED", int.to_string(snapshot.speed) <> " ft", "Movement"), ]), html.div([attribute.class("inspector-quick-stats")], [ @@ -2332,6 +2701,7 @@ fn snapshot_section( } fn view_snapshot_abilities(snapshot: Snapshot) -> Element(Message) { + let display_name = snapshot_display_name(snapshot) let abilities = [ #("Strength", "STR", snapshot.abilities.strength, Strength), #("Dexterity", "DEX", snapshot.abilities.dexterity, Dexterity), @@ -2354,9 +2724,28 @@ fn view_snapshot_abilities(snapshot: Snapshot) -> Element(Message) { html.div([attribute.class("inspector-ability")], [ html.span([], [html.text(abbreviation)]), html.strong([], [html.text(int.to_string(score))]), - html.small([], [ - html.text( - signed(modifier(score)) <> " · Save " <> signed(saving_throw), + html.div([attribute.class("inspector-ability-rolls")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("roll-modifier"), + event.on_click(UserOpenedDiceRoll( + display_name <> " " <> name <> " check", + modifier(score), + )), + ], + [html.text(signed(modifier(score)) <> " check")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("roll-modifier"), + event.on_click(UserOpenedDiceRoll( + display_name <> " " <> name <> " save", + saving_throw, + )), + ], + [html.text(signed(saving_throw) <> " save")], ), ]), html.span([attribute.class("sr-only")], [html.text(name)]), @@ -2367,6 +2756,7 @@ fn view_snapshot_abilities(snapshot: Snapshot) -> Element(Message) { } fn view_snapshot_skills(snapshot: Snapshot) -> Element(Message) { + let display_name = snapshot_display_name(snapshot) snapshot_section("SKILLS", list.length(snapshot.skills), [ html.div( [attribute.class("inspector-skill-grid")], @@ -2382,7 +2772,20 @@ fn view_snapshot_skills(snapshot: Snapshot) -> Element(Message) { html.span([attribute.class("skill-dot")], []), html.span([], [html.text(skill.name)]), html.small([], [html.text(skill.ability)]), - html.strong([], [html.text(signed(skill.score))]), + html.button( + [ + attribute.type_("button"), + attribute.class("roll-modifier"), + attribute.aria_label( + "Roll " <> display_name <> " " <> skill.name, + ), + event.on_click(UserOpenedDiceRoll( + display_name <> " " <> skill.name, + skill.score, + )), + ], + [html.text(signed(skill.score))], + ), ], ) }), @@ -2737,9 +3140,18 @@ fn view_ability_scores(model: Model) -> Element(Message) { [html.text("+")], ), ]), - html.span([attribute.class("modifier")], [ - html.text(signed(modifier)), - ]), + html.button( + [ + attribute.type_("button"), + attribute.class("modifier roll-modifier"), + attribute.aria_label("Roll " <> ability_name(ability) <> " check"), + event.on_click(UserOpenedDiceRoll( + ability_name(ability) <> " check", + modifier, + )), + ], + [html.text(signed(modifier))], + ), ]) }), ), @@ -2952,16 +3364,19 @@ fn origin_skill_is_selected( skill: String, ) -> Bool { list.any(selections, fn(selection) { - string.contains(selection.id, ":skill-choice:") && selection.value == skill + string.starts_with(selection.id, "origin:") + && is_skill_name(selection.value) + && selection.value == skill }) } -fn feature_skill_is_selected( - selections: List(FeatureSelection), - skill: String, -) -> Bool { - class_skill_is_selected(selections, skill) - || origin_skill_is_selected(selections, skill) +fn feature_skill_is_selected(model: Model, skill: String) -> Bool { + class_skill_is_selected(model.feature_selections, skill) + || origin_skill_is_selected(model.feature_selections, skill) + || case model.origin_details { + Ok(details) -> list.contains(details.granted_skills, skill) + Error(_) -> False + } } fn class_skill_is_selected( @@ -2988,6 +3403,97 @@ fn remove_selected_origin_skills( list.filter(skills, fn(skill) { !origin_skill_is_selected(selections, skill) }) } +fn is_skill_name(value: String) -> Bool { + list.any(skill_list(), fn(skill) { skill.name == value }) +} + +fn add_unique(values: List(String), value: String) -> List(String) { + case value == "" || list.contains(values, value) { + True -> values + False -> [value, ..values] + } +} + +fn remove_proficiency_value( + values: List(String), + value: String, +) -> List(String) { + case is_skill_name(value) { + True -> list.filter(values, fn(item) { item != value }) + False -> values + } +} + +fn add_skill_value(values: List(String), value: String) -> List(String) { + case is_skill_name(value) { + True -> add_unique(values, value) + False -> values + } +} + +fn remove_non_skill_proficiency( + values: List(String), + value: String, +) -> List(String) { + case value != "" && !is_skill_name(value) { + True -> list.filter(values, fn(item) { item != value }) + False -> values + } +} + +fn add_non_skill_proficiency( + values: List(String), + value: String, +) -> List(String) { + case value != "" && !is_skill_name(value) { + True -> add_unique(values, value) + False -> values + } +} + +fn remove_selected_origin_tools( + proficiencies: List(String), + selections: List(FeatureSelection), +) -> List(String) { + list.filter(proficiencies, fn(proficiency) { + !list.any(selections, fn(selection) { + string.starts_with(selection.id, "origin:") + && !is_skill_name(selection.value) + && selection.value == proficiency + && { + string.contains(selection.id, ":tool") + || selection.id == "origin:species:khoravar-proficiency" + } + }) + }) +} + +fn remove_granted_origin_skills( + skills: List(String), + details: Result(OriginDetails, String), +) -> List(String) { + case details { + Error(_) -> skills + Ok(details) -> + list.filter(skills, fn(skill) { + !list.contains(details.granted_skills, skill) + }) + } +} + +fn remove_granted_origin_tools( + proficiencies: List(String), + details: Result(OriginDetails, String), +) -> List(String) { + case details { + Error(_) -> proficiencies + Ok(details) -> + list.filter(proficiencies, fn(proficiency) { + !list.contains(details.granted_tools, proficiency) + }) + } +} + fn view_class_feature(feature: ClassFeature) -> Element(Message) { html.article([attribute.class("class-feature")], [ html.div([attribute.class("feature-level")], [ @@ -3023,8 +3529,7 @@ fn view_skill_proficiencies(model: Model) -> Element(Message) { [attribute.class("skills-grid")], list.map(skill_list(), fn(skill) { let selected = list.contains(model.skills, skill.name) - let locked = - feature_skill_is_selected(model.feature_selections, skill.name) + let locked = feature_skill_is_selected(model, skill.name) let total = modifier(effective_ability_score(model, skill.ability)) + case selected { @@ -3051,7 +3556,15 @@ fn view_skill_proficiencies(model: Model) -> Element(Message) { html.span([attribute.class("skill-ability")], [ html.text(ability_abbreviation(skill.ability)), ]), - html.strong([], [html.text(signed(total))]), + html.button( + [ + attribute.type_("button"), + attribute.class("roll-modifier"), + attribute.aria_label("Roll " <> skill.name), + event.on_click(UserOpenedDiceRoll(skill.name, total)), + ], + [html.text(signed(total))], + ), ], ) }), @@ -3127,10 +3640,11 @@ fn view_equipment(model: Model) -> Element(Message) { int.to_string(character_armor_class(model)), equipped_armor_name(model.equipment), ), - stat_block( + rollable_stat_block( "ATTACK", - signed(primary_attack_bonus(model)), + primary_attack_bonus(model), equipped_weapon_name(model.equipment), + "Attack", ), stat_block( "ITEMS", @@ -3673,7 +4187,7 @@ fn view_sheet(model: Model) -> Element(Message) { html.div([attribute.class("combat-grid")], [ stat_block("ARMOR CLASS", int.to_string(armor_class), "Unarmored"), stat_block("HIT POINTS", int.to_string(hit_points), "Average HP"), - stat_block("INITIATIVE", signed(initiative), "Dexterity"), + rollable_stat_block("INITIATIVE", initiative, "Dexterity", "Initiative"), stat_block( "PROF. BONUS", signed(proficiency_bonus(model.level)), @@ -3701,7 +4215,20 @@ fn view_sheet(model: Model) -> Element(Message) { [], ), html.span([], [html.text(ability_name(ability))]), - html.strong([], [html.text(signed(value))]), + html.button( + [ + attribute.type_("button"), + attribute.class("roll-modifier"), + attribute.aria_label( + "Roll " <> ability_name(ability) <> " saving throw", + ), + event.on_click(UserOpenedDiceRoll( + ability_name(ability) <> " saving throw", + value, + )), + ], + [html.text(signed(value))], + ), ]) }), ), @@ -4307,6 +4834,27 @@ fn stat_block(label: String, value: String, note: String) -> Element(Message) { ]) } +fn rollable_stat_block( + label: String, + value: Int, + note: String, + roll_label: String, +) -> Element(Message) { + html.div([attribute.class("stat-block")], [ + html.span([], [html.text(label)]), + html.button( + [ + attribute.type_("button"), + attribute.class("roll-modifier stat-roll-modifier"), + attribute.aria_label("Roll " <> roll_label), + event.on_click(UserOpenedDiceRoll(roll_label, value)), + ], + [html.text(signed(value))], + ), + html.small([], [html.text(note)]), + ]) +} + pub fn modifier(score: Int) -> Int { case score { score if score >= 20 -> 5 diff --git a/src/sheets/browser_ffi.mjs b/src/sheets/browser_ffi.mjs index 907e0c8..3a007e0 100644 --- a/src/sheets/browser_ffi.mjs +++ b/src/sheets/browser_ffi.mjs @@ -6,6 +6,7 @@ export function nowIso() { return new Date().toISOString(); } + export function downloadFile(filename, contents) { const blob = new Blob([contents], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam index dcdd991..815f237 100644 --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -86,6 +86,20 @@ pub type Snapshot { ) } +pub type DiceRoll { + DiceRoll( + id: String, + author: String, + mode: String, + first: Int, + second: Int, + kept: Int, + modifier: Int, + total: Int, + note: String, + ) +} + pub fn host( nickname: String, ready: fn(String) -> message, @@ -132,6 +146,10 @@ pub fn broadcast(snapshot: Snapshot) -> Effect(message) { effect.from(fn(_dispatch) { send_payload(snapshot_to_json(snapshot)) }) } +pub fn broadcast_dice(roll: DiceRoll) -> Effect(message) { + effect.from(fn(_dispatch) { send_payload(dice_roll_to_json(roll)) }) +} + pub fn leave() -> Effect(message) { effect.from(fn(_dispatch) { leave_session() }) } @@ -140,6 +158,50 @@ pub fn parse_snapshot(payload: String) -> Result(Snapshot, json.DecodeError) { json.parse(payload, snapshot_decoder()) } +pub fn parse_dice_roll(payload: String) -> Result(DiceRoll, json.DecodeError) { + json.parse(payload, dice_roll_decoder()) +} + +pub fn dice_roll_to_json(roll: DiceRoll) -> String { + json.object([ + #("type", json.string("dice")), + #("id", json.string(roll.id)), + #("author", json.string(roll.author)), + #("mode", json.string(roll.mode)), + #("first", json.int(roll.first)), + #("second", json.int(roll.second)), + #("kept", json.int(roll.kept)), + #("modifier", json.int(roll.modifier)), + #("total", json.int(roll.total)), + #("note", json.string(roll.note)), + ]) + |> json.to_string +} + +fn dice_roll_decoder() -> decode.Decoder(DiceRoll) { + use _kind <- decode.field("type", decode.string) + use id <- decode.field("id", decode.string) + use author <- decode.field("author", decode.string) + use mode <- decode.field("mode", decode.string) + use first <- decode.field("first", decode.int) + use second <- decode.field("second", decode.int) + use kept <- decode.field("kept", decode.int) + use modifier <- decode.field("modifier", decode.int) + use total <- decode.field("total", decode.int) + use note <- decode.optional_field("note", "", decode.string) + decode.success(DiceRoll( + id:, + author:, + mode:, + first:, + second:, + kept:, + modifier:, + total:, + note:, + )) +} + pub fn snapshot_to_json(snapshot: Snapshot) -> String { json.object([ #("type", json.string("sheet")),