diff --git a/.luacheckrc b/.luacheckrc index c8dfabd..e71c179 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -38,6 +38,10 @@ globals = { "MysteriousManScreen", "DiscussionWindow", "EndWindow", + "PlayerNameWindow", + "TextInput", + "CodeGenerator", + "CreditsWindow", "mset", "mget", "btnp", diff --git a/impostor.inc b/impostor.inc index 4f7d075..ddefee6 100644 --- a/impostor.inc +++ b/impostor.inc @@ -6,6 +6,7 @@ init/init.context.lua system/system.util.lua system/system.print.lua system/system.input.lua +system/system.textinput.lua system/system.mouse.lua system/system.asciiart.lua system/system.rle.lua @@ -16,6 +17,7 @@ logic/logic.timer.lua logic/logic.trigger.lua logic/logic.minigame.lua logic/logic.glitch.lua +logic/logic.codegenerator.lua logic/logic.discussion.lua system/system.ui.lua audio/audio.manager.lua @@ -79,6 +81,8 @@ window/window.minigame.rhythm.lua window/window.minigame.ddr.lua window/window.discussion.lua window/window.continued.lua +window/window.credits.lua +window/window.player_name.lua window/window.game.lua system/system.main.lua meta/meta.assets.lua diff --git a/inc/logic/logic.codegenerator.lua b/inc/logic/logic.codegenerator.lua new file mode 100644 index 0000000..50190cf --- /dev/null +++ b/inc/logic/logic.codegenerator.lua @@ -0,0 +1,60 @@ +--- @section CodeGenerator + +CodeGenerator = {} + +local SALT = 27471 +local BASE36 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +local NAME_LEN = 3 + +-- Per-position offsets derived from SALT so each character slot +-- maps to a different region of the 2-char base-36 space. +local SALTS = { + SALT % 36, + math.floor(SALT / 36) % 36, + math.floor(SALT / 1296) % 36, +} + +--- Encodes a number (0–935) as exactly 2 base-36 characters. +--- @within CodeGenerator +function CodeGenerator.encode_pair(n) + return BASE36:sub(math.floor(n / 36) + 1, math.floor(n / 36) + 1) + .. BASE36:sub(n % 36 + 1, n % 36 + 1) +end + +--- Decodes 2 base-36 characters back to a number. +--- @within CodeGenerator +function CodeGenerator.decode_pair(s) + local d1 = BASE36:find(s:sub(1, 1), 1, true) - 1 + local d2 = BASE36:find(s:sub(2, 2), 1, true) - 1 + return d1 * 36 + d2 +end + +--- Encrypts a player name into a code twice its length. +--- Each input character (A-Z, value 0-25) is encoded as +--- c + SALTS[i] * 26, producing 2 base-36 output characters. +--- @within CodeGenerator +--- @param text string NAME_LEN-character uppercase player name. +--- @return string Encrypted code (2 * NAME_LEN base-36 characters). +function CodeGenerator.encrypt(text) + local result = "" + for i = 1, NAME_LEN do + local c = math.max(0, (string.byte(text, i) or 65) - 65) + result = result .. CodeGenerator.encode_pair(c + SALTS[i] * 26) + end + return result +end + +--- Decrypts a personal code back to the original player name. +--- @within CodeGenerator +--- @param encrypted_text string The code to decrypt (2 * NAME_LEN chars). +--- @return string Original player name, or "???" if the code is invalid. +function CodeGenerator.decrypt(encrypted_text) + local t = encrypted_text:upper() + if #t ~= NAME_LEN * 2 then return "???" end + local result = "" + for i = 1, NAME_LEN do + local pair = CodeGenerator.decode_pair(t:sub((i - 1) * 2 + 1, i * 2)) + result = result .. string.char(pair % 26 + 65) + end + return result +end diff --git a/inc/system/system.input.lua b/inc/system/system.input.lua index 5f0cc15..6869f19 100644 --- a/inc/system/system.input.lua +++ b/inc/system/system.input.lua @@ -30,3 +30,9 @@ function Input.back() return btnp(INPUT_KEY_B) or keyp(INPUT_KEY_BACKSPACE) end --- Checks if Enter is pressed. --- @within Input function Input.enter() return keyp(INPUT_KEY_ENTER) end +--- Checks if Up is pressed or held (with repeat). +--- @within Input +function Input.up_repeat() return btnp(INPUT_KEY_UP, 20, 4) end +--- Checks if Down is pressed or held (with repeat). +--- @within Input +function Input.down_repeat() return btnp(INPUT_KEY_DOWN, 20, 4) end diff --git a/inc/system/system.textinput.lua b/inc/system/system.textinput.lua new file mode 100644 index 0000000..3584d18 --- /dev/null +++ b/inc/system/system.textinput.lua @@ -0,0 +1,81 @@ +--- @section TextInput + +TextInput = {} + +local LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +local _pos = {} +local _cursor = 1 +local _max_len = 3 + +--- Initialises a new text input session. +--- @within TextInput +--- @param max_len number Maximum character count (default 3). +function TextInput.init(max_len) + _max_len = max_len or 3 + _pos = {} + for i = 1, _max_len do _pos[i] = 1 end + _cursor = 1 +end + +--- Advances to the next letter at the cursor position (wraps Z→A). +--- @within TextInput +function TextInput.next_letter() + _pos[_cursor] = (_pos[_cursor] % #LETTERS) + 1 +end + +--- Goes back to the previous letter at the cursor position (wraps A→Z). +--- @within TextInput +function TextInput.prev_letter() + _pos[_cursor] = ((_pos[_cursor] - 2) % #LETTERS) + 1 +end + +--- Confirms the current letter and advances the cursor to the next position. +--- When called on the last position the cursor moves into the done state. +--- @within TextInput +function TextInput.select_letter() + if _cursor <= _max_len then _cursor = _cursor + 1 end +end + +--- Moves the cursor one position to the right (stops at last position). +--- @within TextInput +function TextInput.next_position() + if _cursor < _max_len then _cursor = _cursor + 1 end +end + +--- Moves the cursor one position to the left (stops at first position). +--- Also steps back out of the done state. +--- @within TextInput +function TextInput.prev_position() + if _cursor > 1 then _cursor = _cursor - 1 end +end + +--- Returns the assembled name string. +--- @within TextInput +--- @return string +function TextInput.get_name() + local s = "" + for i = 1, _max_len do s = s .. LETTERS:sub(_pos[i], _pos[i]) end + return s +end + +--- Returns the current 1-based cursor position. +--- @within TextInput +--- @return number +function TextInput.get_position() + return _cursor +end + +--- Returns the letter at the given 1-based position. +--- @within TextInput +--- @param i number +--- @return string +function TextInput.get_letter(i) + return LETTERS:sub(_pos[i], _pos[i]) +end + +--- Returns true when all positions have been confirmed. +--- @within TextInput +--- @return boolean +function TextInput.is_done() + return _cursor > _max_len +end diff --git a/inc/window/window.end.lua b/inc/window/window.end.lua index 291b054..63f5e4e 100644 --- a/inc/window/window.end.lua +++ b/inc/window/window.end.lua @@ -30,9 +30,15 @@ function EndWindow.draw() Print.text(yes_text, centerX - 40, y, yes_color) Print.text(no_text, centerX + 10, y, no_color) elseif Context._end.state == "ending" then - Print.text_center("Game over -- good ending.", Config.screen.width / 2, 50, Config.colors.light_blue) - Print.text_center("Congratulations!", Config.screen.width / 2, 70, Config.colors.white) - Print.text_center("Press Z to return to menu", Config.screen.width / 2, 110, Config.colors.light_grey) + local cx = Config.screen.width / 2 + local name = Context.player_name or "AAA" + local code = CodeGenerator.encrypt(name) + Print.text_center("Game over -- good ending.", cx, 40, Config.colors.light_blue) + Print.text_center("Congrats " .. name .. "!", cx, 54, Config.colors.white) + Print.text_center("Your personal code:", cx, 72, Config.colors.light_grey) + Print.text_center(code, cx, 84, Config.colors.white, false, 3) + Print.text_center("Write it down!", cx, 112, Config.colors.item) + Print.text_center("Press Z to return to menu", cx, 126, Config.colors.dark_grey) end end diff --git a/inc/window/window.menu.lua b/inc/window/window.menu.lua index ad33971..8d159f9 100644 --- a/inc/window/window.menu.lua +++ b/inc/window/window.menu.lua @@ -102,17 +102,13 @@ function MenuWindow.update() end end ---- Starts a new game from the menu. +--- Opens player name entry then starts a new game. --- @within MenuWindow function MenuWindow.new_game() - Context.new_game() -end - ---- Loads a game from the menu. ---- @within MenuWindow -function MenuWindow.load_game() - Context.load_game() - GameWindow.set_state("game") + PlayerNameWindow.init(function() + Context.new_game() + end) + Window.set_current("player_name") end --- Saves the current game from the menu. @@ -139,6 +135,21 @@ function MenuWindow.controls() Window.set_current("controls") end +--- Opens the player name entry screen (test mode shortcut). +--- @within MenuWindow +function MenuWindow.player_name() + PlayerNameWindow.init() + Window.set_current("player_name") +end + +--- Opens the credits screen. +--- @within MenuWindow +function MenuWindow.credits() + CreditsWindow.init() + Window.set_current("credits") +end + + --- Opens the audio test menu. --- @within MenuWindow function MenuWindow.audio_test() @@ -153,6 +164,14 @@ function MenuWindow.continued() GameWindow.set_state("continued") end +--- Opens the end screen for testing. +--- @within MenuWindow +function MenuWindow.end_screen() + Context._end.state = "ending" + Context._end.selection = 1 + GameWindow.set_state("end") +end + --- Opens the DDR minigame test. --- @within MenuWindow function MenuWindow.ddr_test() @@ -171,13 +190,15 @@ function MenuWindow.refresh_menu_items() end table.insert(_menu_items, {label = "New Game", decision = MenuWindow.new_game}) - table.insert(_menu_items, {label = "Load Game", decision = MenuWindow.load_game}) table.insert(_menu_items, {label = "Controls", decision = MenuWindow.controls}) + table.insert(_menu_items, {label = "Credits", decision = MenuWindow.credits}) if Context.test_mode then table.insert(_menu_items, {label = "Audio Test", decision = MenuWindow.audio_test}) table.insert(_menu_items, {label = "To Be Continued...", decision = MenuWindow.continued}) table.insert(_menu_items, {label = "DDR Test", decision = MenuWindow.ddr_test}) + table.insert(_menu_items, {label = "End Screen", decision = MenuWindow.end_screen}) + table.insert(_menu_items, {label = "Player Name", decision = MenuWindow.player_name}) end table.insert(_menu_items, {label = "Exit", decision = MenuWindow.exit}) diff --git a/inc/window/window.player_name.lua b/inc/window/window.player_name.lua new file mode 100644 index 0000000..4748ee1 --- /dev/null +++ b/inc/window/window.player_name.lua @@ -0,0 +1,115 @@ +--- @section PlayerNameWindow + +local _frame = 0 +local _on_confirm = nil +local MAX_LEN = 3 +local BOX_W = 24 +local BOX_H = 24 +local BOX_GAP = 12 +local BOX_Y = 50 +local WARN_Y = 104 + +local function box_start_x() + return math.floor((Config.screen.width - (MAX_LEN * BOX_W + (MAX_LEN - 1) * BOX_GAP)) / 2) +end + +local function box_x(i) + return box_start_x() + (i - 1) * (BOX_W + BOX_GAP) +end + +--- Initialises the player name window. +--- @within PlayerNameWindow +--- @param on_confirm function Called with the entered name when the player saves. +function PlayerNameWindow.init(on_confirm) + _frame = 0 + _on_confirm = on_confirm + TextInput.init(MAX_LEN) +end + +local function draw_boxes() + local cursor = TextInput.get_position() + local blink = math.floor(_frame / 18) % 2 == 0 + + for i = 1, MAX_LEN do + local x = box_x(i) + local is_cur = (i == cursor) + local done = TextInput.is_done() + + local fill = (is_cur and not done) and Config.colors.blue or Config.colors.black + local border = (is_cur and not done) and Config.colors.white + or done and Config.colors.light_blue + or Config.colors.dark_grey + + rect (x, BOX_Y, BOX_W, BOX_H, fill) + rectb(x, BOX_Y, BOX_W, BOX_H, border) + + local show = not (is_cur and blink and not done) + if show then + local ch = TextInput.get_letter(i) + local cw = print(ch, 0, -100, 0, false, 2) + local cx = x + math.floor((BOX_W - cw) / 2) + local cy = BOX_Y + math.floor((BOX_H - 11) / 2) + local col = (is_cur and not done) and Config.colors.white or Config.colors.light_grey + print(ch, cx, cy, col, false, 2) + end + end + + -- caret arrow below active box + if not TextInput.is_done() then + local cx = box_x(cursor) + math.floor(BOX_W / 2) + local ay = BOX_Y + BOX_H + 4 + line(cx - 4, ay, cx, ay + 4, Config.colors.white) + line(cx + 4, ay, cx, ay + 4, Config.colors.white) + end +end + +--- Draws the player name window. +--- @within PlayerNameWindow +function PlayerNameWindow.draw() + cls(Config.colors.black) + + Print.text_center("Player Name", Config.screen.width / 2, 14, Config.colors.white, false, 2) + + draw_boxes() + + if TextInput.is_done() then + Print.text_center("Z: save name B: edit", Config.screen.width / 2, BOX_Y + BOX_H + 12, Config.colors.light_blue) + else + Print.text_center("Up/Dn: letter Lft/Rgt: move Z: ok", Config.screen.width / 2, BOX_Y + BOX_H + 12, Config.colors.dark_grey) + end + + -- Warning section + rect(0, WARN_Y, Config.screen.width, Config.screen.height - WARN_Y, Config.colors.blue) + rectb(0, WARN_Y, Config.screen.width, Config.screen.height - WARN_Y, Config.colors.light_blue) + Print.text_center("Remember your name!", Config.screen.width / 2, WARN_Y + 8, Config.colors.white) + Print.text_center("You will need it to load the game.", Config.screen.width / 2, WARN_Y + 20, Config.colors.light_grey) +end + +--- Updates player name window logic. +--- @within PlayerNameWindow +function PlayerNameWindow.update() + _frame = _frame + 1 + + if TextInput.is_done() then + if Input.select() then + Context.player_name = TextInput.get_name() + if _on_confirm then _on_confirm() else Window.set_current("menu") end + elseif Input.back() then + TextInput.prev_position() + end + return + end + + if Input.up_repeat() then TextInput.next_letter() end + if Input.down_repeat() then TextInput.prev_letter() end + if Input.right() then TextInput.next_position() end + if Input.select() then TextInput.select_letter() end + + if Input.left() or Input.back() then + if TextInput.get_position() > 1 then + TextInput.prev_position() + else + Window.set_current("menu") + end + end +end diff --git a/inc/window/window.register.lua b/inc/window/window.register.lua index a3388ac..afc6fd6 100644 --- a/inc/window/window.register.lua +++ b/inc/window/window.register.lua @@ -39,3 +39,9 @@ Window.register("discussion", DiscussionWindow) ContinuedWindow = {} Window.register("continued", ContinuedWindow) + +CreditsWindow = {} +Window.register("credits", CreditsWindow) + +PlayerNameWindow = {} +Window.register("player_name", PlayerNameWindow)