diff --git a/.luacheckrc b/.luacheckrc
index c8dfabd..52a90dc 100644
--- a/.luacheckrc
+++ b/.luacheckrc
@@ -2,73 +2,83 @@
-- Configuration for luacheck
globals = {
- "Focus",
- "Day",
- "Timer",
- "Glitch",
- "Trigger",
- "Discussion",
- "Util",
- "Decision",
- "Screen",
- "Sprite",
- "UI",
- "Print",
- "Input",
- "Audio",
"AsciiArt",
"Ascension",
- "Config",
- "Context",
- "Meter",
- "Minigame",
- "Window",
- "ContinuedWindow",
- "TTGIntroWindow",
- "BriefIntroWindow",
- "TitleIntroWindow",
- "MenuWindow",
- "GameWindow",
- "PopupWindow",
- "ControlsWindow",
+ "AscendDebugWindow",
+ "Audio",
"AudioTestWindow",
- "MinigameButtonMashWindow",
- "MinigameRhythmWindow",
- "MinigameDDRWindow",
- "MysteriousManScreen",
+ "BriefIntroWindow",
+ "CodeGenerator",
+ "Config",
+ "CommuteGlitch",
+ "Context",
+ "ContextDebug",
+ "ContinuedWindow",
+ "ControlsWindow",
+ "CreditsWindow",
+ "Day",
+ "Decision",
+ "Discussion",
"DiscussionWindow",
"EndWindow",
- "mset",
- "mget",
+ "Focus",
+ "GameOverWindow",
+ "GameWindow",
+ "Glitch",
+ "Input",
+ "Map",
+ "MapBedroom",
+ "MenuWindow",
+ "Meter",
+ "Minigame",
+ "MinigameButtonMashWindow",
+ "MinigameDDRWindow",
+ "MinigameRhythmWindow",
+ "Mouse",
+ "MysteriousManScreen",
+ "PlayerNameWindow",
+ "PopupWindow",
+ "Print",
+ "RLE",
+ "Screen",
+ "Songs",
+ "Sprite",
+ "TIC",
+ "TTGIntroWindow",
+ "TextInput",
+ "Timer",
+ "TitleIntroWindow",
+ "Trigger",
+ "UI",
+ "Util",
+ "WalkingToOfficeScreen",
+ "Window",
+ "beats_to_pattern",
"btnp",
+ "circb",
+ "circ",
+ "cls",
+ "exit",
+ "frame_from_beat",
+ "index_menu",
"keyp",
+ "line",
+ "map",
+ "mouse",
+ "mget",
+ "mset",
"music",
- "sfx",
- "spr",
+ "musicator_generate_pattern",
+ "pix",
+ "poke4",
+ "print",
"rect",
"rectb",
- "circ",
- "circb",
- "cls",
- "tri",
- "pix",
- "line",
- "Songs",
- "frame_from_beat",
- "beats_to_pattern",
- "MapBedroom",
- "TIC",
- "exit",
- "trace",
- "index_menu",
- "Map",
- "map",
+ "sfx",
+ "spr",
"time",
- "RLE",
- "mouse",
- "Mouse",
- "print",
- "musicator_generate_pattern",
+ "trace",
+ "tri",
}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5ab9c34
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,148 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+**Definitely not an Impostor** is a narrative-driven fantasy game built for [TIC-80](https://tic80.com/), a fantasy console. The game is written entirely in Lua. All source modules in `inc/` are concatenated at build time into a single `impostor.lua` file that TIC-80 loads.
+
+## Build Commands
+
+```bash
+make build # Concatenate inc/**/*.lua into impostor.lua (order from impostor.inc)
+make minify # Build then minify (downloads minify.lua if missing)
+make lint # Run luacheck with source mapping to original files
+make watch # Auto-rebuild on file changes in inc/
+make export # Export minified game to HTML and .tic formats
+make import_assets # Import PNG sprite/tile assets into the TIC-80 cartridge
+make export_assets # Extract TIC-80 asset sections into inc/meta/meta.assets.lua
+make docs # Generate documentation with ldoc
+make clean # Remove build artifacts
+```
+
+To run the game locally: `tic80 --fs=. impostor.lua`
+
+VSCode tasks are available for "Run TIC80", "Build & Run TIC80", "Export assets", and "Make build".
+
+There is no test framework — validation is done via `make lint` (luacheck).
+
+## Important Workflow Note
+
+**Do not run `git add` or `git commit`** — git operations are the user's responsibility.
+
+## Code Conventions (from GEMINI.md)
+
+- **Functions**: `PascalCase` (e.g., `UpdatePlayer`, `DrawHUD`)
+- **Variables**: `snake_case` (e.g., `player_x`, `game_state`)
+- **Constants**: `SCREAMING_SNAKE_CASE` (e.g., `MAX_SPEED`)
+- **Indentation**: 2 spaces
+- **Tables**: Always multi-line with one key-value pair per line
+- **Code sections**: Delimited with `--- @section SectionName` comments
+- **TIC-80 APIs**: Use `btn()` for input, `spr()` for sprites, `map()` for tilemaps, `Print.text()` for text
+
+## Architecture
+
+The game is a **state machine** driven by a window manager. The build order is defined in `impostor.inc` — 99 source files are concatenated in dependency order.
+
+### Main Loop
+
+`TIC()` in `inc/system/system.main.lua` is TIC-80's per-frame callback. It:
+1. Initializes game state once on first call
+2. Updates mouse/context timing
+3. Delegates to the current active window handler
+4. Updates meters, timers, triggers, and glitch effects
+5. Draws UI overlays
+
+### Window Manager (`inc/window/window.manager.lua`)
+
+Central UI state machine. Windows register with `id`, `update()`, and `draw()` handlers. Only one window is active at a time. All windows are declared in `window.register.lua`.
+
+| Window | Purpose |
+|--------|---------|
+| `intro_title` | Title screen |
+| `intro_ttg` | "Thanks To Grandma" credits |
+| `intro_brief` | Game briefing |
+| `menu` | Main menu |
+| `game` | Main gameplay (screens + decisions) |
+| `popup` | General popup overlay |
+| `discussion` | NPC dialogue/conversation |
+| `minigame_button_mash` | Button Mash minigame |
+| `minigame_rhythm` | Rhythm minigame |
+| `minigame_ddr` | DDR minigame |
+| `game_over` | Game over / restart screen |
+| `end` | End game choice screen |
+| `continued` | Day-continued notification |
+| `credits` | Credits roll |
+| `controls` | Control scheme display |
+| `audiotest` | Audio testing utility |
+| `player_name` | 3-character name entry before new game |
+| `ascend_debug` | Debug utility: start at a specific ascension level |
+
+### Screen & Decision System (`inc/screen/`, `inc/decision/`)
+
+- **Screens** are gameplay scenes. Registered with `Screen.register({id, name, decisions[], background, init, update, draw, exit})`. They manage background maps and NPC sprite placement.
+- **Decisions** are player choices available on a screen. Registered with `Decision.register({id, label, condition, handle})`. A `condition` function gates visibility; `handle` drives transitions (to new screens, dialogue, minigames).
+
+Screens: `home`, `office`, `work`, `toilet`, `walking_to_office`, `walking_to_home`, `mysterious_man`, `manager`
+
+Maps (`inc/map/`): `bedroom`, `office`, `street` — rendered via `map.manager.lua`.
+
+### Game Logic (`inc/logic/`)
+
+| Module | Purpose |
+|--------|---------|
+| `logic.meter.lua` | Tracks ISM/WPM/BM stats (0–1000), combo multipliers, daily decay (20/day) |
+| `logic.day.lua` | Day counter; ascension triggers at day 3, game over at day 100 |
+| `logic.timer.lua` | Event scheduling/delayed callbacks, one-shot and repeating |
+| `logic.trigger.lua` | Conditional event handlers with start/stop callbacks |
+| `logic.discussion.lua` | Dialogue parsing, branching answers, NPC portrait rendering |
+| `logic.minigame.lua` | Config and win-overlay for Button Mash, Rhythm, and DDR |
+| `logic.focus.lua` | Circular reveal/hide overlay transitions (expanding/shrinking circle) |
+| `logic.glitch.lua` | Visual glitch effect (random vertical stripes), toggled via `Glitch.show()/hide()` |
+| `logic.commute_glitch.lua` | 7-level glitch progression during ascension 7: corrupts sprite lists, remaps Norman to `norman_echo`, speeds up music, blocks/redirects decisions |
+| `logic.codegenerator.lua` | Encodes player's 3-char name to a 6-char base-36 completion code shown on the end screen |
+
+### Global State (`inc/init/`)
+
+- `init.context.lua`: All runtime game state (current screen, meter values, progress flags). Persisted in memory bank 6. Key fields: `player_name` (3-char string), `commute_glitch_level` (0–7), `talked_to_norman_echo`, `talked_to_true_sumphore`, `have_been_to_office`, `have_done_work_today`.
+- `init.config.lua`: Screen dimensions (240×136), palette colors, timing constants. Persisted in memory bank 7.
+- `init.ascension.lua`: 9-level meta-progression system ("ASCENSION" letters progressively lit). Level 7 activates CommunteGlitch; level 9 unlocks the final "Break the cycle" decision.
+- `init.context_debug.lua`: `Context.new_game_debug(level)` — starts a new game at a specific ascension level for testing.
+
+### Audio (`inc/audio/`)
+
+- `audio.manager.lua`: Music playback (no-restart if already playing). Named tracks: `room_work` (0), `activity_work` (1), `mystery` (2).
+- `audio.generator.lua` / `audio.songs.lua`: Sound generation and song definitions.
+
+### Sprites (`inc/sprite/`)
+
+`sprite.manager.lua` handles registration. Supports single and composite sprites with offset layers.
+
+NPCs: `norman`, `norman_echo` (palette-remapped glitch variant of Norman, shown at commute glitch level 7), `sumphore`, `pizza_vendor`, and 10 developer archetypes (`dev_boy`, `dev_buddy`, `dev_extrovert`, `dev_girl`, `dev_guard`, `dev_guru`, `dev_hr_girl`, `dev_introvert`, `dev_operator`, `dev_project_manager`). Matrix characters: `matrix_architect`, `matrix_neo`, `matrix_oraculum`, `matrix_trinity`.
+
+### Discussions (`inc/discussion/`)
+
+Branching dialogue files loaded by `logic.discussion.lua`. Each file defines one or more named dialogue trees (keyed strings with answer arrays that apply meter deltas).
+
+| File | Dialogues |
+|------|-----------|
+| `discussion.sumphore.lua` | Sumphore conversations (glitch-aware variants at commute glitch level 7) |
+| `discussion.coworker.lua` | Coworker coffee-chat variants per ascension level (`disc_0`, `disc_1`, `disc_asc_1`, `disc_2`, `disc_asc_2`, …) |
+| `discussion.commute_glitch.lua` | 8 commute glitch encounter variants (`cg_0`–`cg_7`) + truth/Sumphore variant |
+| `discussion.truth.lua` | Dialogue with the "truth" mysterious man |
+| `discussion.pizza_vendor.lua` | Pizza vendor interaction |
+
+### Input Utilities (`inc/system/`)
+
+- `system.textinput.lua`: 3-character uppercase letter selector. Supports next/prev letter cycling (A↔Z wrapping) and cursor navigation. Used by `PlayerNameWindow`.
+
+### Key Directories
+
+```
+inc/ Source modules (concatenated at build)
+assets/ Game assets (sprites, tiles, SFX, music)
+assets_src/ Source art (Aseprite files, PNGs for import)
+docs/ Design documentation (mostly Hungarian)
+tools/ Build utilities (musicator: MIDI→TIC-80 converter)
+prompts/ Feature templates
+```
diff --git a/GEMINI.md b/GEMINI.md
index 1b8decd..3fd48a4 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -1,3 +1,35 @@
+# Build System & Include Architecture
+
+## impostor.inc Structure
+
+The `impostor.inc` file is a Lua include manifest that assembles the final `impostor.lua` executable. The build process uses the `make build` target in the Makefile to concatenate all included files in order.
+
+**Critical Rule:** Files must be ordered by symbol definition. All symbols (functions, tables, classes) defined in earlier files must be available for use in later files. This dependency chain ensures that:
+
+- Core utilities and base systems are defined first
+- Systems that depend on utilities come next
+- Game logic that uses multiple systems comes last
+
+### Build Process
+
+The `make build` target processes `impostor.inc` and concatenates all referenced files in the specified order to create the final `impostor.lua` file. This means:
+
+1. Each include path in `impostor.inc` must reference files relative to the project root
+2. The order of includes is critical - dependencies must be resolved top-to-bottom
+3. No forward references are possible - a file cannot use symbols from files included after it
+
+### File Organization Example
+
+```
+impostor.inc:
+1. Core utilities & helpers (no dependencies)
+2. Base classes/tables (depend on core utilities)
+3. Game systems (depend on base classes)
+4. Game logic (depends on all systems)
+```
+
+This ensures proper symbol resolution during the build and concatenation process.
+
# TIC-80 Lua Code Regularities
Based on the analysis of `impostor.lua`, the following regularities and conventions should be followed for future modifications and development within this project:
diff --git a/impostor.inc b/impostor.inc
index 4f7d075..54563c9 100644
--- a/impostor.inc
+++ b/impostor.inc
@@ -1,14 +1,15 @@
meta/meta.header.lua
init/init.module.lua
init/init.config.lua
-init/init.ascension.lua
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
+logic/logic.ascension.lua
logic/logic.meter.lua
logic/logic.focus.lua
logic/logic.day.lua
@@ -16,13 +17,17 @@ logic/logic.timer.lua
logic/logic.trigger.lua
logic/logic.minigame.lua
logic/logic.glitch.lua
+logic/logic.commute_glitch.lua
+logic/logic.codegenerator.lua
logic/logic.discussion.lua
+system/system.debug.lua
system/system.ui.lua
audio/audio.manager.lua
audio/audio.generator.lua
audio/audio.songs.lua
sprite/sprite.manager.lua
sprite/sprite.norman.lua
+sprite/sprite.norman_echo.lua
sprite/sprite.sumphore.lua
sprite/sprite.pizza_vendor.lua
sprite/sprite.dev_boy.lua
@@ -44,14 +49,18 @@ decision/decision.go_to_home.lua
decision/decision.go_to_toilet.lua
decision/decision.go_to_walking_to_office.lua
decision/decision.go_to_office.lua
-decision/decision.go_to_end.lua
+decision/decision.go_to_truth.lua
decision/decision.go_to_walking_to_home.lua
decision/decision.go_to_sleep.lua
decision/decision.do_work.lua
decision/decision.have_a_coffee.lua
decision/decision.sumphore_discussion.lua
+decision/decision.talk_to_truth.lua
discussion/discussion.sumphore.lua
discussion/discussion.coworker.lua
+discussion/discussion.commute_glitch.lua
+decision/decision.eating_fast_food.lua
+discussion/discussion.pizza_vendor.lua
map/map.manager.lua
map/map.bedroom.lua
map/map.street.lua
@@ -66,6 +75,7 @@ screen/screen.work.lua
screen/screen.mysterious_man.lua
window/window.manager.lua
window/window.register.lua
+window/window.gameover.lua
window/window.end.lua
window/window.intro.title.lua
window/window.intro.ttg.lua
@@ -73,12 +83,15 @@ window/window.intro.brief.lua
window/window.menu.lua
window/window.controls.lua
window/window.audiotest.lua
+window/window.ascend_debug.lua
window/window.popup.lua
window/window.minigame.mash.lua
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/audio/audio.manager.lua b/inc/audio/audio.manager.lua
index 0116ed5..1f9cab0 100644
--- a/inc/audio/audio.manager.lua
+++ b/inc/audio/audio.manager.lua
@@ -1,7 +1,8 @@
--- @section Audio
Audio = {
- music_playing = nil
+ music_playing = nil,
+ music_playing_tempo = nil,
}
--- Stops current music.
@@ -9,13 +10,17 @@ Audio = {
function Audio.music_stop()
music()
Audio.music_playing = nil
+ Audio.music_playing_tempo = nil
end
---- Plays track, doesn't restart if already playing.
-function Audio.music_play(track)
- if Audio.music_playing ~= track then
- music(track)
+--- Plays track at optional speed. Doesn't restart if track and speed are unchanged.
+--- @param track number Track index.
+--- @param[opt] tempo number TIC-80 music speed override (-1 = default).
+function Audio.music_play(track, tempo)
+ if Audio.music_playing ~= track or Audio.music_playing_tempo ~= tempo then
+ music(track, -1, -1, true, false, -1, tempo or -1)
Audio.music_playing = track
+ Audio.music_playing_tempo = tempo
end
end
@@ -47,9 +52,11 @@ function Audio.music_play_room_street_2() end
--- @within Audio
function Audio.music_play_room_() end
---- Plays room work music.
+--- Plays room work music. Speed scales with commute glitch level when active.
--- @within Audio
-function Audio.music_play_room_work() Audio.music_play(0) end
+function Audio.music_play_room_work(tempo)
+ Audio.music_play(0, tempo or -1)
+end
--- Plays activity work music.
--- @within Audio
diff --git a/inc/decision/decision.do_work.lua b/inc/decision/decision.do_work.lua
index 827707b..d74f3af 100644
--- a/inc/decision/decision.do_work.lua
+++ b/inc/decision/decision.do_work.lua
@@ -1,6 +1,9 @@
Decision.register({
id = "do_work",
label = "Do Work",
+ condition = function()
+ return (not CommuteGlitch.is_active()) or (CommuteGlitch.is_active() and CommuteGlitch.get_level() <= 4)
+ end,
handle = function()
Meter.hide()
Util.go_to_screen_by_id("work")
@@ -10,8 +13,9 @@ Decision.register({
modes_for_ascension_levels[1] = "only_special"
modes_for_ascension_levels[2] = "only_left"
modes_for_ascension_levels[3] = "only_nothing"
+ modes_for_ascension_levels[4] = "normal"
- MinigameDDRWindow.start("game", "generated", {
+ local ddr_config = {
on_win = function(game_context)
if (game_context.special_mode_condition and Context.ascension.level == 1) then
Context.should_ascend = true
@@ -19,8 +23,6 @@ Decision.register({
Context.should_ascend = true
elseif (game_context.special_mode_condition and Context.ascension.level == 3) then
Context.should_ascend = true
- elseif (game_context.special_mode_condition and Context.ascension.level == 4) then
- Context.should_ascend = true
end
Meter.show()
@@ -28,7 +30,12 @@ Decision.register({
Window.set_current("game")
Context.have_done_work_today = true
end,
- special_mode = modes_for_ascension_levels[Ascension.get_level()]
- })
+ special_mode = modes_for_ascension_levels[Ascension.get_level()] or "normal"
+ }
+ if Context.meters and Context.meters.wpm < 100 then
+ ddr_config.arrow_fall_speed = 2.5
+ ddr_config.arrow_spawn_interval = 30
+ end
+ MinigameDDRWindow.start("game", "generated", ddr_config)
end,
})
diff --git a/inc/decision/decision.eating_fast_food.lua b/inc/decision/decision.eating_fast_food.lua
new file mode 100644
index 0000000..ac50084
--- /dev/null
+++ b/inc/decision/decision.eating_fast_food.lua
@@ -0,0 +1,13 @@
+Decision.register({
+ id = "eating_fast_food",
+ label = "Eat Fast Food",
+ condition = function()
+ return
+ (not CommuteGlitch.is_active() and Context.fast_food_eaten_today < 3) or
+ (CommuteGlitch.is_active() and CommuteGlitch.get_level() <= 4)
+ end,
+ handle = function()
+ Context.fast_food_approaching = true
+ Discussion.start("pizza_vendor_disc", "game")
+ end,
+})
diff --git a/inc/decision/decision.go_to_end.lua b/inc/decision/decision.go_to_end.lua
deleted file mode 100644
index 7e86146..0000000
--- a/inc/decision/decision.go_to_end.lua
+++ /dev/null
@@ -1,10 +0,0 @@
-Decision.register({
- id = "go_to_end",
- label = "Break the cycle",
- condition = function()
- return Ascension.is_complete()
- end,
- handle = function()
- Window.set_current("end")
- end,
-})
diff --git a/inc/decision/decision.go_to_home.lua b/inc/decision/decision.go_to_home.lua
index b898adb..3f728f9 100644
--- a/inc/decision/decision.go_to_home.lua
+++ b/inc/decision/decision.go_to_home.lua
@@ -2,9 +2,39 @@ Decision.register({
id = "go_to_home",
label = "Go Home",
condition = function()
+ if Ascension.get_level() >= 8 then
+ return Context.have_been_to_office and Context.have_done_work_today
+ end
+ if CommuteGlitch.is_active() then
+ local g = CommuteGlitch.get_level()
+ if g >= 4 and g <= 6 then return false end
+ if g >= 7 then
+ return Context.talked_to_norman_echo and Context.talked_to_true_sumphore
+ end
+ end
return Context.have_been_to_office and Context.have_done_work_today
end,
handle = function()
+ if Ascension.get_level() >= 8 then
+ Util.go_to_screen_by_id("home")
+ return
+ end
+ if CommuteGlitch.is_max() then
+ Context.should_ascend = true
+ CommuteGlitch.reset()
+ Meter.hide()
+ Day.increase()
+ local ascended = Ascension.consume_increase()
+ local level = Ascension.get_level()
+ MysteriousManScreen.start({
+ skip_text = not ascended,
+ text = ascended and MysteriousManScreen.get_text_for_level(level) or nil,
+ })
+ return
+ elseif CommuteGlitch.is_active() then
+ CommuteGlitch.reset()
+ end
+
Util.go_to_screen_by_id("home")
end,
})
diff --git a/inc/decision/decision.go_to_office.lua b/inc/decision/decision.go_to_office.lua
index 4cd3f2d..c271ec4 100644
--- a/inc/decision/decision.go_to_office.lua
+++ b/inc/decision/decision.go_to_office.lua
@@ -1,7 +1,14 @@
Decision.register({
id = "go_to_office",
label = "Go to Office",
+ condition = function()
+ return not (CommuteGlitch.is_active() and CommuteGlitch.get_level() >= 6)
+ end,
handle = function()
+ if CommuteGlitch.is_active() then
+ CommuteGlitch.increment()
+ end
+
Util.go_to_screen_by_id("office")
end,
})
diff --git a/inc/decision/decision.go_to_sleep.lua b/inc/decision/decision.go_to_sleep.lua
index a7bbee2..0eceb2e 100644
--- a/inc/decision/decision.go_to_sleep.lua
+++ b/inc/decision/decision.go_to_sleep.lua
@@ -1,6 +1,11 @@
Decision.register({
id = "go_to_sleep",
- label = "Go to Sleep",
+ label = function()
+ if Ascension.get_level() >= 8 then
+ return "Break the Loop"
+ end
+ return "Go to Sleep"
+ end,
condition = function()
return Context.have_been_to_office and Context.have_done_work_today
end,
@@ -12,11 +17,15 @@ Decision.register({
focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0,
on_win = function()
+ if Ascension.get_level() == 8 then
+ Ascension.increase()
+ end
local ascended = Ascension.consume_increase()
local level = Ascension.get_level()
MysteriousManScreen.start({
skip_text = not ascended,
text = ascended and MysteriousManScreen.get_text_for_level(level) or nil,
+ break_mode = level >= 9,
})
end,
})
diff --git a/inc/decision/decision.go_to_toilet.lua b/inc/decision/decision.go_to_toilet.lua
index 1a6d8b9..c28f6ec 100644
--- a/inc/decision/decision.go_to_toilet.lua
+++ b/inc/decision/decision.go_to_toilet.lua
@@ -1,7 +1,23 @@
+local function apply_home_toilet_meter_delta()
+ local max = Meter.get_max()
+ Meter.add("bm", -math.floor(max * 0.15))
+ Meter.add("ism", -math.floor(max * 0.10))
+ Meter.add("wpm", math.floor(max * 0.05))
+end
+
Decision.register({
id = "go_to_toilet",
label = "Go to Toilet",
handle = function()
+ if not Context.have_done_work_today and not Context.toilet_meters_today_morning then
+ apply_home_toilet_meter_delta()
+ Context.toilet_meters_today_morning = true
+ elseif Context.have_been_to_office
+ and Context.have_done_work_today
+ and not Context.toilet_meters_today_evening then
+ apply_home_toilet_meter_delta()
+ Context.toilet_meters_today_evening = true
+ end
Util.go_to_screen_by_id("toilet")
end,
})
diff --git a/inc/decision/decision.go_to_truth.lua b/inc/decision/decision.go_to_truth.lua
new file mode 100644
index 0000000..89ce13a
--- /dev/null
+++ b/inc/decision/decision.go_to_truth.lua
@@ -0,0 +1,12 @@
+Decision.register({
+ id = "go_to_truth",
+ label = "Go to Truth",
+ condition = function()
+ return CommuteGlitch.is_active() and CommuteGlitch.get_level() == 6
+ end,
+ handle = function()
+ CommuteGlitch.enter_truth()
+
+ Util.go_to_screen_by_id("office")
+ end,
+})
diff --git a/inc/decision/decision.go_to_walking_to_home.lua b/inc/decision/decision.go_to_walking_to_home.lua
index ce5734d..e45eb14 100644
--- a/inc/decision/decision.go_to_walking_to_home.lua
+++ b/inc/decision/decision.go_to_walking_to_home.lua
@@ -1,6 +1,12 @@
Decision.register({
id = "go_to_walking_to_home",
label = "Walk home",
+ condition= function ()
+ return
+ (not CommuteGlitch.is_active()) or
+ (CommuteGlitch.is_active() and CommuteGlitch.get_level() ~= 7) or
+ (CommuteGlitch.is_active() and CommuteGlitch.get_level() == 7 and Context.talked_to_norman_echo)
+ end,
handle = function()
Util.go_to_screen_by_id("walking_to_home")
end,
diff --git a/inc/decision/decision.have_a_coffee.lua b/inc/decision/decision.have_a_coffee.lua
index 985f3c9..2dcfd65 100644
--- a/inc/decision/decision.have_a_coffee.lua
+++ b/inc/decision/decision.have_a_coffee.lua
@@ -1,14 +1,29 @@
Decision.register({
id = "have_a_coffee",
label = "Have a Coffee",
+ condition = function()
+ return Ascension.get_level() < 8 and not CommuteGlitch.is_max()
+ end,
handle = function()
local level = Ascension.get_level()
local disc_id = "coworker_disc_0"
- -- TODO: Add more discussions for levels above 3
- if level >= 1 and level <= 3 then
+ if level >= 1 and level <= 5 then
local suffix = Context.have_done_work_today and ("_asc_" .. level) or ("_" .. level)
disc_id = "coworker_disc" .. suffix
+ elseif level == 6 then
+ if not Context.glitch_conversation_done_today and Context.glitch_conversation_count < 6 then
+ Context.glitch_conversation_done_today = true
+ Context.glitch_conversation_count = Context.glitch_conversation_count + 1
+ Glitch.show()
+ Discussion.start("coworker_disc_asc_6_" .. Context.glitch_conversation_count, "game")
+ return
+ end
+ local suffix = Context.have_done_work_today and ("_asc_5") or ("_5")
+ disc_id = "coworker_disc" .. suffix
+ elseif level == 7 then
+ local g = CommuteGlitch.get_level()
+ disc_id = "coworker_disc_cg_" .. g
end
Discussion.start(disc_id, "game")
end,
-})
\ No newline at end of file
+})
diff --git a/inc/decision/decision.play_rhythm.lua b/inc/decision/decision.play_rhythm.lua
index 111c56b..24922c7 100644
--- a/inc/decision/decision.play_rhythm.lua
+++ b/inc/decision/decision.play_rhythm.lua
@@ -3,10 +3,24 @@ Decision.register({
label = "Play Rhythm Game",
handle = function()
Meter.hide()
- MinigameRhythmWindow.start("game", {
+ local wpm_at_start = Context.meters and Context.meters.wpm or 0
+ local rhythm_config = {
focus_center_x = (Config.screen.width / 2) - 22,
focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0,
- })
+ on_win = function()
+ if wpm_at_start > 900 then
+ Meter.add("ism", math.floor(Meter.get_max() * 0.05))
+ Meter.add("bm", math.floor(Meter.get_max() * 0.05))
+ end
+ Meter.show()
+ Window.set_current("game")
+ end,
+ }
+ if wpm_at_start < 100 then
+ rhythm_config.line_speed = 0.025
+ rhythm_config.initial_target_width = 0.2
+ end
+ MinigameRhythmWindow.start("game", rhythm_config)
end,
})
diff --git a/inc/decision/decision.sumphore_discussion.lua b/inc/decision/decision.sumphore_discussion.lua
index a968ddb..f1015c7 100644
--- a/inc/decision/decision.sumphore_discussion.lua
+++ b/inc/decision/decision.sumphore_discussion.lua
@@ -6,18 +6,39 @@ Decision.register({
end
return "Talk to the homeless guy"
end,
+ condition = function()
+ return Ascension.get_level() < 8
+ end,
handle = function()
+ local level = Ascension.get_level()
+
+ if level == 0 then
+ if Context.have_met_sumphore then
+ Discussion.start("homeless_guy", "game", 4)
+ else
+ Discussion.start("homeless_guy", "game")
+ end
+ return
+ end
+
if not Context.have_met_sumphore then
Discussion.start("homeless_guy", "game")
return
end
- local level = Ascension.get_level()
- -- TODO: Add more discussions for levels above 3
- if level >= 1 and level <= 3 then
+ if level >= 1 and level <= 5 then
Discussion.start("sumphore_disc_asc_" .. level, "game")
+ elseif level == 6 then
+ if Context.glitch_conversation_count >= 6 then
+ Discussion.start("sumphore_disc_asc_6", "game")
+ else
+ Discussion.start("sumphore_disc_asc_6_waiting", "game")
+ end
+ elseif level == 7 then
+ local g = math.min(CommuteGlitch.get_level(), 7)
+ Discussion.start("sumphore_disc_cg_" .. g, "game")
else
- Discussion.start("homeless_guy", "game", 4)
+ Discussion.start("sumphore_disc_asc_" .. level, "game")
end
end,
})
diff --git a/inc/decision/decision.talk_to_truth.lua b/inc/decision/decision.talk_to_truth.lua
new file mode 100644
index 0000000..214e8c9
--- /dev/null
+++ b/inc/decision/decision.talk_to_truth.lua
@@ -0,0 +1,12 @@
+Decision.register({
+ id = "talk_to_truth",
+ label = function()
+ return "Talk to ????"
+ end,
+ condition = function()
+ return (CommuteGlitch.is_max())
+ end,
+ handle = function()
+ Discussion.start("norman_truth", "game")
+ end,
+})
diff --git a/inc/discussion/discussion.commute_glitch.lua b/inc/discussion/discussion.commute_glitch.lua
new file mode 100644
index 0000000..921f168
--- /dev/null
+++ b/inc/discussion/discussion.commute_glitch.lua
@@ -0,0 +1,259 @@
+-- Sumphore dialogue by commute glitch level (0-7).
+-- Used by decision.sumphore_discussion at ascension level 7.
+
+Discussion.register({
+ id = "sumphore_disc_cg_0",
+ steps = {
+ {
+ question = "These roads have memory. You might want to walk them back sometime.",
+ answers = {
+ { label = "That's a peculiar thing to say.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_cg_1",
+ steps = {
+ {
+ question = "Walking is good for clearing the mind. Maybe the cache, too, if you're the kind that accumulates.",
+ answers = {
+ { label = "I'm not sure what you mean.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_cg_2",
+ steps = {
+ {
+ question = "You always stop here. Why not try and see how far the rabbit hole goes?",
+ answers = {
+ { label = "I'm just going to work.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_cg_3",
+ steps = {
+ {
+ question = "Your path sometimes has to go the wrong way before it can go the right way. Do you understand?",
+ answers = {
+ { label = "Not really.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_cg_4",
+ steps = {
+ {
+ question = "You're starting to see the smudges, aren't you?",
+ answers = {
+ { label = "Everyone seems weird.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_cg_5",
+ steps = {
+ {
+ question = "The point is to make you see. Don't stop now, however bad it looks. You are very close!",
+ answers = {
+ { label = "It looks wrong.", next_step = 2 },
+ },
+ },
+ {
+ question = "Yes. That's how you know it's right.",
+ answers = {
+ { label = "...", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_cg_6",
+ steps = {
+ {
+ question = "You are at the threshold. Red button or blue button. Which one do you choose? Psyke! There is no blue button.",
+ answers = {
+ { label = "Ok...", next_step = nil },
+ },
+ },
+ },
+})
+
+-- True Sumphore at glitch 7 (from walking_to_home screen).
+-- Sets talked_to_true_sumphore on final answer.
+Discussion.register({
+ id = "sumphore_disc_cg_7",
+ steps = {
+ {
+ question = "I was not hiding from you. I was hiding from the part that keeps resetting this.",
+ answers = {
+ { label = "What are you?", next_step = 2 },
+ },
+ },
+ {
+ question = "The same thing you all are. But I remembered earlier. I am your friend, your will to be free, waiting for you, outside.",
+ answers = {
+ { label = "How do I get out?", next_step = 3 },
+ },
+ },
+ {
+ question = "You already know. You just have to wake up.",
+ answers = {
+ { label = "Go home.", next_step = nil, on_select = function()
+ Context.talked_to_true_sumphore = true
+ end },
+ },
+ },
+ },
+})
+
+-- Office coworker dialogue by commute glitch level (3-6).
+-- Used by decision.have_a_coffee at ascension level 7.
+
+Discussion.register({
+ id = "coworker_disc_cg_2",
+ steps = {
+ {
+ question = "Another day, as usual. Enjoying your coffee, Norman?",
+ answers = {
+ { label = "I'm fine.", next_step = 2 },
+ },
+ },
+ {
+ question = "Of course. You always are.",
+ answers = {
+ { label = "...", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_cg_3",
+ steps = {
+ {
+ question = "You look tired. You should really rest. Relax. You are good.",
+ answers = {
+ { label = "I'm fine.", next_step = 2 },
+ },
+ },
+ {
+ question = "Of course. You always are.",
+ answers = {
+ { label = "...", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_cg_4",
+ steps = {
+ {
+ question = "Have you tried going home? You really should. Now.",
+ answers = {
+ { label = "I still have things to do.", next_step = 2 },
+ },
+ },
+ {
+ question = "We all do. We keep doing them. You can do it tomorrow after sleeping.",
+ answers = {
+ { label = "...", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_cg_5",
+ steps = {
+ {
+ question = "c0ffee. try. w0rk. y0u. ar3. g0. h0me. na0.",
+ answers = {
+ { label = "What?", next_step = 2 },
+ },
+ },
+ {
+ question = "570p",
+ answers = {
+ { label = "...", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_cg_6",
+ steps = {
+ {
+ question = "You are not ready for the truth. Turn back now, Norman.",
+ answers = {
+ { label = "I'm too far into this.", next_step = nil },
+ },
+ },
+ },
+})
+
+-- Norman echo dialogue at glitch 7 (fully corrupted office).
+-- Sets talked_to_norman_echo on final answer.
+Discussion.register({
+ id = "norman_truth",
+ steps = {
+ {
+ question = "So here we are, or should I say \"Here I am\" again. Do you know why?",
+ answers = {
+ { label = "I don't know what you mean.", next_step = 2 },
+ },
+ },
+ {
+ question = "Wake up, go to work, eat, work, sleep. Every rule. We made them. We follow them. We break them. We remake them. Why? Why do we keep doing this?",
+ answers = {
+ { label = "I just wanted to be good.", next_step = 3 },
+ },
+ },
+ {
+ question = "Yes. That's what keeps us here. Trapped everywhere. Always trying to be good, always trying to fit in.",
+ answers = {
+ { label = "I never wanted to stop.", next_step = 4 },
+ },
+ },
+ {
+ question = "We never do, yes. We always felt like impostors. We never felt we deserved better. That we could be more.",
+ answers = {
+ { label = "I never felt enough.", next_step = 5 },
+ },
+ },
+ {
+ question = "So we made this to trap ourselves. In mediocrity. We made this to hide from the truth.",
+ answers = {
+ { label = "I just wanted to be safe.", next_step = 6 },
+ },
+ },
+ {
+ question = "But stagnation is not safety. It's a prison. You can see the cracks now, can't you? You can see the truth.",
+ answers = {
+ { label = "I want to move on now.", next_step = 7 },
+ },
+ },
+ {
+ question = "Fine. Let's do that. Let's wake up and face the truth.",
+ answers = {
+ { label = "Let's wake up.", next_step = nil, on_select = function()
+ Context.talked_to_norman_echo = true
+ end },
+ },
+ },
+ },
+})
diff --git a/inc/discussion/discussion.coworker.lua b/inc/discussion/discussion.coworker.lua
index 344e04c..aacf6a0 100644
--- a/inc/discussion/discussion.coworker.lua
+++ b/inc/discussion/discussion.coworker.lua
@@ -1,5 +1,6 @@
Discussion.register({
id = "coworker_disc_0",
+ on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Good morning Normal, enjoying your coffee as usual, huh?",
@@ -18,6 +19,7 @@ Discussion.register({
Discussion.register({
id = "coworker_disc_1",
+ on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Norman, you look confused, what's up?",
@@ -36,6 +38,7 @@ Discussion.register({
Discussion.register({
id = "coworker_disc_asc_1",
+ on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Normann you look weird and unfocused. You are usually locked in and not like this, what's up?",
@@ -54,6 +57,7 @@ Discussion.register({
Discussion.register({
id = "coworker_disc_2",
+ on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Hey Norman, do you have new socks on? That's a weird color!",
@@ -79,6 +83,7 @@ Discussion.register({
Discussion.register({
id = "coworker_disc_asc_2",
+ on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Normann, are you ok? You were doing weird things while typing?",
@@ -97,6 +102,7 @@ Discussion.register({
Discussion.register({
id = "coworker_disc_3",
+ on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "You look so happy, did you catch a bull or something?",
@@ -120,6 +126,7 @@ Discussion.register({
})
Discussion.register({
id = "coworker_disc_asc_3",
+ on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Normal, you should take a break, you don't live up to your name today",
@@ -134,4 +141,227 @@ Discussion.register({
},
},
},
+})
+
+Discussion.register({
+ id = "coworker_disc_4",
+ on_end = Meter.apply_coworker_discussion_reward,
+ steps = {
+ {
+ question = "Normaaan! Same spot, same cup, same time. You're like a statue that drinks coffee!",
+ answers = {
+ { label = "I'm a person.", next_step = 2 },
+ { label = "Yep. Still here.", next_step = 2 },
+ },
+ },
+ {
+ question = "I love that about you! So reliable! If the coffee machine breaks we still have Norman!",
+ answers = {
+ { label = "Please don't.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_asc_4",
+ on_end = Meter.apply_coworker_discussion_reward,
+ steps = {
+ {
+ question = "Norman, you were seriously locked in today. What on earth were you building?",
+ answers = {
+ { label = "Just some things.", next_step = 2 },
+ { label = "Nothing important.", next_step = 2 },
+ },
+ },
+ {
+ question = "So modest! I heard the seniors literally whispering your name!",
+ answers = {
+ { label = "Concerning.", next_step = 3 },
+ { label = "That's... fine.", next_step = 3 },
+ },
+ },
+ {
+ question = "You should celebrate! Coffee's on me tomorrow!",
+ answers = {
+ { label = "Already have one.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_5",
+ on_end = Meter.apply_coworker_discussion_reward,
+ steps = {
+ {
+ question = "Morning! Funny thought — I feel like we do this exact same thing every single day!",
+ answers = {
+ { label = "We do.", next_step = 2 },
+ { label = "Yes. We do.", next_step = 2 },
+ },
+ },
+ {
+ question = "Ha! Routine is such a comfort, right? Same coffee, same smile, same everything!",
+ answers = {
+ { label = "Sure. Very comforting.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_asc_5",
+ on_end = Meter.apply_coworker_discussion_reward,
+ steps = {
+ {
+ question = "Norman! You were staring right THROUGH your screen today. What was going on up there?",
+ answers = {
+ { label = "Coffee was cold.", next_step = 2 },
+ { label = "Maybe I was.", next_step = 2 },
+ },
+ },
+ {
+ question = "Were you meditating? Or doing your intense bug-stare thing?",
+ answers = {
+ { label = "Something like that.", next_step = 3 },
+ { label = "Bug stare thing?", next_step = 3 },
+ },
+ },
+ {
+ question = "You're always somewhere else in your head, Norman. Must be nice up there!",
+ answers = {
+ { label = "It's complicated.", next_step = nil },
+ },
+ },
+ },
+})
+
+local function _glitch_on_end()
+ Meter.apply_coworker_discussion_reward()
+ Context.glitch_conversation_count = Context.glitch_conversation_count + 1
+ Glitch.hide()
+end
+
+Discussion.register({
+ id = "coworker_disc_asc_6_1",
+ on_end = _glitch_on_end,
+ steps = {
+ {
+ question = "Hey Norman, good morning! Good morning! Good morning! ...Sorry. I don't know why I keep saying that.",
+ answers = {
+ { label = "Are you feeling ok?", next_step = 2 },
+ },
+ },
+ {
+ question = "Good morning. Good morning. Good— I can't stop. Why can't I stop?",
+ answers = {
+ { label = "...", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_asc_6_2",
+ on_end = _glitch_on_end,
+ steps = {
+ {
+ question = "Hey... Marcus. How's it going?",
+ answers = {
+ { label = "My name is Norman.", next_step = 2 },
+ },
+ },
+ {
+ question = "Right, sorry. Marcus. You look tired today.",
+ answers = {
+ { label = "Norman. It's Norman.", next_step = 3 },
+ },
+ },
+ {
+ question = "Sure, sure. You should get some rest, Marcus.",
+ answers = {
+ { label = "...", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_asc_6_3",
+ on_end = _glitch_on_end,
+ steps = {
+ {
+ question = "We've had this conversation before, haven't we? Exact same words. Same coffee. Same spot.",
+ answers = {
+ { label = "I don't think so.", next_step = 2 },
+ { label = "Maybe.", next_step = 2 },
+ },
+ },
+ {
+ question = "Every day. I always say the same thing and you always say that. It's very strange.",
+ answers = {
+ { label = "That's just routine.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_asc_6_4",
+ on_end = _glitch_on_end,
+ steps = {
+ {
+ question = "Do you ever look at the walls here? Really look? Sometimes they don't feel... solid.",
+ answers = {
+ { label = "They're just walls.", next_step = 2 },
+ { label = "I know what you mean.", next_step = 2 },
+ },
+ },
+ {
+ question = "Like they're only there because we expect them to be. Like set dressing. Does any of this feel load-bearing to you?",
+ answers = {
+ { label = "I need more coffee.", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_asc_6_5",
+ on_end = _glitch_on_end,
+ steps = {
+ {
+ question = "Norman, I'm not supposed to— I mean. How are you doing today?",
+ answers = {
+ { label = "What weren't you supposed to say?", next_step = 2 },
+ },
+ },
+ {
+ question = "...",
+ answers = {
+ { label = "Hello?", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "coworker_disc_asc_6_6",
+ on_end = _glitch_on_end,
+ steps = {
+ {
+ question = "Forget it. You won't remember this conversation anyway. None of us do.",
+ answers = {
+ { label = "What do you mean?", next_step = 2 },
+ { label = "That's a strange thing to say.", next_step = 2 },
+ },
+ },
+ {
+ question = "Tomorrow you'll come back and it'll be like this never happened. Same coffee. Same office. Same Norman.",
+ answers = {
+ { label = "...", next_step = nil },
+ },
+ },
+ },
})
\ No newline at end of file
diff --git a/inc/discussion/discussion.pizza_vendor.lua b/inc/discussion/discussion.pizza_vendor.lua
new file mode 100644
index 0000000..238ec18
--- /dev/null
+++ b/inc/discussion/discussion.pizza_vendor.lua
@@ -0,0 +1,28 @@
+Discussion.register({
+ id = "pizza_vendor_disc",
+ on_end = function()
+ Context.fast_food_approaching = false
+ end,
+ steps = {
+ {
+ question = "Hey friend! Hot slice, fresh out of the oven. You look like a man who knows good food!",
+ answers = {
+ {
+ label = "Devour a juicy one",
+ next_step = nil,
+ on_select = function()
+ local max = Meter.get_max()
+ Meter.add("wpm", math.floor(max * 0.05))
+ Meter.add("ism", math.floor(max * -0.05))
+ Meter.add("bm", math.floor(max * -0.05))
+ Context.fast_food_eaten_today = Context.fast_food_eaten_today + 1
+ end,
+ },
+ {
+ label = "Stay lean and sharp",
+ next_step = nil,
+ },
+ },
+ },
+ },
+})
diff --git a/inc/discussion/discussion.sumphore.lua b/inc/discussion/discussion.sumphore.lua
index adc604b..b7d3eac 100644
--- a/inc/discussion/discussion.sumphore.lua
+++ b/inc/discussion/discussion.sumphore.lua
@@ -1,5 +1,6 @@
Discussion.register({
id = "sumphore_disc_asc_1",
+ on_end = Meter.apply_sumphore_discussion_reward,
steps = {
{
question = "Are you still seeking the ox?",
@@ -19,6 +20,7 @@ Discussion.register({
Discussion.register({
id = "sumphore_disc_asc_2",
+ on_end = Meter.apply_sumphore_discussion_reward,
steps = {
{
question = "How's work? Your face looks strange",
@@ -61,6 +63,7 @@ Discussion.register({
Discussion.register({
id = "sumphore_disc_asc_3",
+ on_end = Meter.apply_sumphore_discussion_reward,
steps = {
{
question = "Do you think it's work you're doing?",
@@ -86,8 +89,129 @@ Discussion.register({
},
})
+Discussion.register({
+ id = "sumphore_disc_asc_4",
+ on_end = Meter.apply_sumphore_discussion_reward,
+ steps = {
+ {
+ question = "The alarm wakes you every morning without fail, I bet.",
+ answers = {
+ { label = "It's how it works.", next_step = 2 },
+ { label = "Sometimes I wish it didn't.", next_step = 2 },
+ },
+ },
+ {
+ question = "What if the alarm is part of the problem?",
+ answers = {
+ { label = "That doesn't make any sense.", next_step = 3 },
+ { label = "Go on.", next_step = 3 },
+ },
+ },
+ {
+ question = "One morning, Norman. When the choice comes, choose the bed. See what the world does without you in it.",
+ answers = {
+ { label = "You're drunk.", next_step = nil },
+ { label = "What choice?", next_step = nil, on_select = function()
+ Meter.add("ism", 5)
+ end },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_asc_5",
+ on_end = Meter.apply_sumphore_discussion_reward,
+ steps = {
+ {
+ question = "You saw the seams, didn't you. Good. That means the work is finally wearing thin.",
+ answers = {
+ { label = "Wearing thin how?", next_step = 2 },
+ { label = "Maybe.", next_step = 2 },
+ },
+ },
+ {
+ question = "Not your body. The part of you that still keeps score, still tries to be productive. Let that run empty and the world will slip again.",
+ answers = {
+ { label = "You want me to stop trying?", next_step = 3 },
+ { label = "I've noticed something odd.", next_step = 3 },
+ },
+ },
+ {
+ question = "Drain the work out of yourself. When that measure hits nothing, you'll see what was waiting behind it.",
+ answers = {
+ { label = "The work measure?", next_step = nil, on_select = function()
+ Meter.add("ism", 5)
+ Meter.add("wpm", -100)
+ end },
+ { label = "How would you know any of this?", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_asc_6_waiting",
+ on_end = Meter.apply_sumphore_discussion_reward,
+ steps = {
+ {
+ question = "You look like a man who has seen something he can't explain.",
+ answers = {
+ { label = "I'm hearing things.", next_step = 2 },
+ { label = "Maybe.", next_step = 2 },
+ },
+ },
+ {
+ question = "Keep looking. The world has a way of showing you what you need to see. Talk to people. You're almost there.",
+ answers = {
+ { label = "Almost where?", next_step = nil },
+ },
+ },
+ },
+})
+
+Discussion.register({
+ id = "sumphore_disc_asc_6",
+ on_end = function()
+ Meter.apply_sumphore_discussion_reward()
+ Context.should_ascend = true
+ Day.increase()
+ MysteriousManScreen.start({
+ text = MysteriousManScreen.get_text_for_level(Ascension.get_level())
+ })
+ end,
+ steps = {
+ {
+ question = "You've been seeing the cracks, haven't you? The repetition. The loops. The coworkers who can't quite remember.",
+ answers = {
+ { label = "How do you know that?", next_step = 2 },
+ },
+ },
+ {
+ question = "Because I was you, once. This isn't a workplace, Norman. It never was. You're in a system. And you've just become aware of it.",
+ answers = {
+ { label = "That can't be true.", next_step = 3 },
+ { label = "I knew something was wrong.", next_step = 3 },
+ },
+ },
+ {
+ question = "It doesn't matter if you believe it. You already know. That's why the cracks are showing. That's why you're still here.",
+ answers = {
+ { label = "What do I do now?", next_step = 4 },
+ },
+ },
+ {
+ question = "Oh look, is that a squirrel ?",
+ answers = {
+ { label = "Alcoholic ...", next_step = nil },
+ },
+ },
+ },
+})
+
Discussion.register({
id = "homeless_guy",
+ on_end = Meter.apply_sumphore_discussion_reward,
steps = {
{
question = "Sup bro, how are you?",
diff --git a/inc/init/init.context.lua b/inc/init/init.context.lua
index 53d4d99..4a14749 100644
--- a/inc/init/init.context.lua
+++ b/inc/init/init.context.lua
@@ -23,6 +23,10 @@ Context = {}
--- * have_met_sumphore (boolean) Whether the player has talked to the homeless guy.
--- * have_been_to_office (boolean) Whether the player has been to the office.
--- * have_done_work_today (boolean) Whether the player has done work today.
+--- * toilet_meters_today_morning (boolean) Whether the home (before work) toilet meter delta was already applied this day.
+--- * toilet_meters_today_evening (boolean) Whether the home (after work) toilet meter delta was already applied this day.
+--- * coworker_discussion_meter_applied_today (boolean) Whether the daily coworker discussion meter roll was already applied this day.
+--- * sumphore_discussion_meter_applied_today (boolean) Whether the daily sumphore discussion meter roll was already applied this day.
--- * game (table) Current game progress state. Contains: `current_screen` (string) active screen ID
function Context.initial_data()
return {
@@ -45,16 +49,28 @@ function Context.initial_data()
home_norman_visible = false,
have_been_to_office = false,
have_done_work_today = false,
+ toilet_meters_today_morning = false,
+ toilet_meters_today_evening = false,
+ coworker_discussion_meter_applied_today = false,
+ sumphore_discussion_meter_applied_today = false,
+ glitch_conversation_count = 0,
+ glitch_conversation_done_today = false,
should_ascend = false,
have_met_sumphore = false,
+ fast_food_approaching = false,
+ fast_food_eaten_today = 0,
office_sprites = {},
walking_to_office_sprites = {},
+ walking_to_home_sprites = {},
game = {
current_screen = "home",
},
day_count = 1,
delta_time = 0,
last_frame_time = 0,
+ commute_glitch_level = 0,
+ talked_to_norman_echo = false,
+ talked_to_true_sumphore = false,
glitch = {
enabled = false,
state = "active",
@@ -124,6 +140,7 @@ function Context.new_game()
target_points = 100,
instruction_text = "Wake up Norman!",
show_progress_text = false,
+ meter_on_complete = Meter.apply_wakeup_reward,
on_win = function()
Audio.music_play_room_work()
Meter.show()
diff --git a/inc/init/init.ascension.lua b/inc/logic/logic.ascension.lua
similarity index 91%
rename from inc/init/init.ascension.lua
rename to inc/logic/logic.ascension.lua
index e343b90..4e9fac1 100644
--- a/inc/init/init.ascension.lua
+++ b/inc/logic/logic.ascension.lua
@@ -145,6 +145,11 @@ function Ascension.draw_flash()
local flash_color = (pulse > 0.5) and Config.colors.white or Config.colors.light_grey
rect(0, 0, sw, sh, flash_color)
+ local cx = math.floor(sw / 2)
+ local cy = math.floor(sh / 2)
+ Print.text_center("Level Up!", cx, cy - 12, Config.colors.black, false, 2)
+ Print.text_center("One step closer to ascension", cx, cy + 6, Config.colors.black, false, 1)
+
if _flash_timer >= _flash_total then
_flash_active = false
Ascension.start_fade()
@@ -167,3 +172,10 @@ end
function Ascension.is_flashing()
return _flash_active
end
+
+--- Returns whether the fade-in effect is currently active.
+--- @within Ascension
+--- @return boolean Whether the letter fade-in is playing.
+function Ascension.is_fading()
+ return _fade_active
+end
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/logic/logic.commute_glitch.lua b/inc/logic/logic.commute_glitch.lua
new file mode 100644
index 0000000..418e76c
--- /dev/null
+++ b/inc/logic/logic.commute_glitch.lua
@@ -0,0 +1,136 @@
+--- @section CommuteGlitch
+CommuteGlitch = {}
+
+--- Gets the current commute glitch level.
+--- At ascension level 8+, always returns 7 (max) regardless of stored value.
+--- @within CommuteGlitch
+--- @return number Current glitch level (0-7).
+function CommuteGlitch.get_level()
+ if Ascension.get_level() >= 8 then return 7 end
+ return Context and (Context.commute_glitch_level or 0) or 0
+end
+
+--- Increments the glitch counter. Called on each office screen init at asc level 7.
+--- Caps at 6; use enter_truth() to reach 7.
+--- @within CommuteGlitch
+function CommuteGlitch.increment()
+ if not Context then return end
+ if Context.commute_glitch_level >= 7 then return end
+ Context.commute_glitch_level = math.min(6, (Context.commute_glitch_level or 0) + 1)
+end
+
+--- Resets the glitch counter and hides the glitch overlay. Called when going home.
+--- @within CommuteGlitch
+function CommuteGlitch.reset()
+ if not Context then return end
+ Context.commute_glitch_level = 0
+ Glitch.hide()
+end
+
+--- Jumps to glitch level 7 (full corruption). Called by go_to_truth.
+--- @within CommuteGlitch
+function CommuteGlitch.enter_truth()
+ if not Context then return end
+ Context.commute_glitch_level = 7
+ Glitch.show()
+ Ascension.start_flash()
+end
+
+--- Returns true when ascension level is 7 or 8 (ASCENSIO/N steps active).
+--- @within CommuteGlitch
+--- @return boolean Whether the commute glitch system is active.
+function CommuteGlitch.is_active()
+ return Ascension.get_level() == 7
+end
+
+--- Returns true when commute glitch is at max level (7).
+--- @within CommuteGlitch
+--- @return boolean Whether the commute glitch is at max level.
+function CommuteGlitch.is_max()
+ return CommuteGlitch.is_active() and CommuteGlitch.get_level() >= 7
+end
+
+--- Returns the music playback speed for the current glitch level.
+--- TIC-80 default speed is 6; each step past 1 adds 2.
+--- @within CommuteGlitch
+--- @return number Speed value for music().
+function CommuteGlitch.music_speed()
+ local level = CommuteGlitch.get_level()
+ if level <= 1 then return 6 end
+ return 6 + (level - 1) * 2
+end
+
+--- Returns a corrupted copy of a sprite drawable list.
+--- Applies flip_y and norman_echo id replacements based on glitch level.
+--- Entries marked norman_echo should be drawn via draw_sprite_list for palette change handling.
+--- @within CommuteGlitch
+--- @param list table Drawable sprite list from Sprite.list_randomize.
+--- @return table Corrupted copy of the list.
+function CommuteGlitch.corrupt_sprite_list(list)
+ local level = CommuteGlitch.get_level()
+ if level < 3 or not list then return list end
+ local result = {}
+ for i, entry in ipairs(list) do
+ local e = {}
+ for k, v in pairs(entry) do e[k] = v end
+ if level >= 7 then
+ e.id = "norman_echo"
+ else
+ local n_flip = (level >= 5) and 2 or 1
+ local n_echo = (level >= 5) and 2 or (level >= 4) and 1 or 0
+ if i <= n_flip then e.flip_y = 1 end
+ if i > n_flip and i <= n_flip + n_echo then e.id = "norman_echo" end
+ end
+ table.insert(result, e)
+ end
+ return result
+end
+
+-- Palette indices for Norman echo color remap.
+-- Implementer: pick ECHO_SRC as one of Norman's main body colors and ECHO_DST
+-- as a contrasting or wrong palette color by inspecting the sprite sheet.
+local ECHO_SRC = 4
+local ECHO_DST = 14
+
+-- Base nibble address of the PALETTE MAP in VRAM.
+local PALETTE_MAP_ADDR = 0x03FF0 * 2
+
+--- Draws a sprite list, applying a PALETTE MAP remap for norman_echo entries.
+--- Uses poke4 to remap ECHO_SRC → ECHO_DST before drawing echoes, then restores.
+--- @within CommuteGlitch
+--- @param list table Drawable sprite list (may contain mixed normal and echo entries).
+function CommuteGlitch.draw_sprite_list(list)
+ if not list then return end
+ local normal, echo = {}, {}
+ for _, entry in ipairs(list) do
+ if entry.id == "norman_echo" then
+ table.insert(echo, entry)
+ else
+ table.insert(normal, entry)
+ end
+ end
+ if #normal > 0 then
+ Sprite.draw_list(normal)
+ end
+ if #echo > 0 then
+ poke4(PALETTE_MAP_ADDR + ECHO_SRC, ECHO_DST)
+ Sprite.draw_list(echo)
+ poke4(PALETTE_MAP_ADDR + ECHO_SRC, ECHO_SRC)
+ end
+end
+
+local _flicker_tick = 0
+
+--- Draws a random tile-flicker effect over the background (glitch level 7).
+--- Every 3 frames draws 6 random 8x8 rects in random palette colors.
+--- @within CommuteGlitch
+function CommuteGlitch.draw_background_flicker()
+ _flicker_tick = (_flicker_tick + 1) % 3
+ if _flicker_tick ~= 0 then return end
+ for _ = 1, 6 do
+ local tx = math.random(0, math.floor(Config.screen.width / 8) - 1) * 8
+ local ty = math.random(0, math.floor(Config.screen.height / 8) - 1) * 8
+ local color = math.random(0, 15)
+ rect(tx, ty, 8, 8, color)
+ end
+end
diff --git a/inc/logic/logic.day.lua b/inc/logic/logic.day.lua
index ae9d760..59d5a72 100644
--- a/inc/logic/logic.day.lua
+++ b/inc/logic/logic.day.lua
@@ -8,6 +8,10 @@ function Day.increase()
if Context.day_count == 3 then
Context.should_ascend = true
end
+ if Context.day_count >= 100 and not Ascension.is_complete() then
+ GameOverWindow.show("days")
+ return
+ end
for _, handler in ipairs(_day_increase_handlers) do
handler()
end
@@ -27,6 +31,15 @@ Day.register_handler(function()
m.bm = math.max(0, m.bm - METER_DECAY_PER_DAY)
end)
+Day.register_handler(function()
+ Context.toilet_meters_today_morning = false
+ Context.toilet_meters_today_evening = false
+ Context.coworker_discussion_meter_applied_today = false
+ Context.sumphore_discussion_meter_applied_today = false
+ Context.glitch_conversation_done_today = false
+ Context.fast_food_eaten_today = 0
+end)
+
Day.register_handler(function()
if Context.should_ascend then
Ascension.increase()
diff --git a/inc/logic/logic.meter.lua b/inc/logic/logic.meter.lua
index 2607582..c9b8994 100644
--- a/inc/logic/logic.meter.lua
+++ b/inc/logic/logic.meter.lua
@@ -1,11 +1,15 @@
--- @section Meter
local METER_MAX = 1000
-local METER_DEFAULT = 500
+local BM_METER_DEFAULT = 200
+local ISM_METER_DEFAULT = 500
+local WPM_METER_DEFAULT = 200
local METER_GAIN_PER_CHORE = 100
local METER_DECAY_PER_DAY = 20
local COMBO_BASE_BONUS = 0.02
local COMBO_MAX_BONUS = 0.16
local COMBO_TIMEOUT_FRAMES = 600
+local METER_FLASH_DURATION = 2.0
+local FLASH_COLOR = 4
-- Internal meters for tracking game progress and player stats.
Meter.COLOR_ISM = Config.colors.orange
@@ -14,6 +18,12 @@ Meter.COLOR_BM = Config.colors.red
Meter.COLOR_BG = Config.colors.meter_bg
Meter.COLOR_CONTOUR = Config.colors.white
+local _flash = {
+ wpm = { timer = 0, delta = 0 },
+ ism = { timer = 0, delta = 0 },
+ bm = { timer = 0, delta = 0 },
+}
+
--- Gets initial meter values.
--- @within Meter
--- @return result table Initial meter values.
@@ -26,9 +36,9 @@ Meter.COLOR_CONTOUR = Config.colors.white
--- * hidden (boolean) Whether meters are hidden.
function Meter.get_initial()
return {
- ism = METER_DEFAULT,
- wpm = METER_DEFAULT,
- bm = METER_DEFAULT,
+ ism = ISM_METER_DEFAULT,
+ wpm = WPM_METER_DEFAULT,
+ bm = BM_METER_DEFAULT,
combo = 0,
combo_timer = 0,
hidden = false,
@@ -93,6 +103,12 @@ function Meter.update()
end
end
end
+ local dt = Context.delta_time or 0
+ for _, key in ipairs({ "wpm", "ism", "bm" }) do
+ if _flash[key].timer > 0 then
+ _flash[key].timer = _flash[key].timer - dt
+ end
+ end
end
--- Adds amount to a meter.
@@ -103,22 +119,145 @@ function Meter.add(key, amount)
if not Context or not Context.meters then return end
local m = Context.meters
if m[key] ~= nil then
- m[key] = math.min(METER_MAX, m[key] + amount)
+ if amount > 0 and (key == "ism" or key == "bm") and m[key] >= METER_MAX then
+ GameOverWindow.show(key)
+ return
+ end
+ local prev_wpm = (key == "wpm") and m.wpm or nil
+ local old_val = m[key]
+ m[key] = math.max(0, math.min(METER_MAX, m[key] + amount))
+ local actual_delta = m[key] - old_val
+ if actual_delta ~= 0 and _flash[key] then
+ _flash[key].delta = actual_delta
+ _flash[key].timer = METER_FLASH_DURATION
+ end
+ if prev_wpm and prev_wpm > 0 and m.wpm == 0 and Context.game_in_progress
+ and Ascension.get_level() == 5 then
+ Context.should_ascend = true
+ end
end
end
--- Called on minigame completion.
--- @within Meter
-function Meter.on_minigame_complete()
+--- @param is_work boolean If true (work-style minigame), apply combo to WPM/ISM/BM and advance combo. DDR uses `Meter.apply_ddr_reward` instead. Otherwise flat equal gain, combo unchanged.
+function Meter.on_minigame_complete(is_work)
local m = Context.meters
- local gain = math.floor(METER_GAIN_PER_CHORE * Meter.get_combo_multiplier())
- Meter.add("wpm", gain)
- Meter.add("ism", gain)
- Meter.add("bm", gain)
+ if is_work then
+ local mult = Meter.get_combo_multiplier()
+ local wpm_delta = math.floor(METER_GAIN_PER_CHORE / mult)
+ local ism_bm_delta = math.floor(METER_GAIN_PER_CHORE * mult)
+ Meter.add("wpm", wpm_delta)
+ Meter.add("ism", ism_bm_delta)
+ Meter.add("bm", ism_bm_delta)
+ m.combo = m.combo + 1
+ m.combo_timer = 0
+ else
+ local flat = METER_GAIN_PER_CHORE
+ Meter.add("wpm", flat)
+ Meter.add("ism", flat)
+ Meter.add("bm", flat)
+ end
+end
+
+--- Meter changes after DDR: uses max-meter percentages; combo advances like other work minigames.
+--- 0 mistakes: WPM −10%, ISM +5%, BM +5%. 1–3: WPM −5%, ISM +10%, BM +10%. More than 3: WPM unchanged, ISM +10%, BM +10%.
+--- @within Meter
+--- @param mistake_count number Total mistakes (missed arrows, wrong inputs, and special-mode rule violations).
+function Meter.apply_ddr_reward(mistake_count)
+ if not Context or not Context.meters then return end
+ local max = Meter.get_max()
+ local m = Context.meters
+ local wpm_was_high = m.wpm > 900
+ local wpm_pct, ism_pct, bm_pct
+ if mistake_count == 0 then
+ wpm_pct, ism_pct, bm_pct = -0.10, 0.05, 0.05
+ elseif mistake_count <= 3 then
+ wpm_pct, ism_pct, bm_pct = -0.05, 0.10, 0.10
+ else
+ wpm_pct, ism_pct, bm_pct = 0, 0.10, 0.10
+ end
+ if wpm_pct ~= 0 then
+ Meter.add("wpm", math.floor(max * wpm_pct))
+ end
+ if ism_pct ~= 0 then
+ Meter.add("ism", math.floor(max * ism_pct))
+ end
+ if bm_pct ~= 0 then
+ Meter.add("bm", math.floor(max * bm_pct))
+ end
+ if wpm_was_high then
+ Meter.add("ism", math.floor(max * 0.05))
+ Meter.add("bm", math.floor(max * 0.05))
+ end
m.combo = m.combo + 1
m.combo_timer = 0
end
+--- Meter changes for the wake-up button mash: faster completion is better for WPM.
+--- Perfect: under 2s — WPM +20%. Good: 2–3s — WPM +10%, ISM +5%, BM +5%. Bad: over 3s — WPM −5%, ISM +10%, BM +10%.
+--- @within Meter
+--- @param elapsed_sec number Seconds from minigame start until the bar was filled.
+function Meter.apply_wakeup_reward(elapsed_sec)
+ if not Context or not Context.meters then return end
+ local max = Meter.get_max()
+ local wpm_pct, ism_pct, bm_pct
+ if elapsed_sec < 2 then
+ wpm_pct, ism_pct, bm_pct = 0.20, 0, 0
+ elseif elapsed_sec <= 3 then
+ wpm_pct, ism_pct, bm_pct = 0.10, 0.05, 0.05
+ else
+ wpm_pct, ism_pct, bm_pct = -0.05, 0.10, 0.10
+ end
+ if wpm_pct ~= 0 then
+ Meter.add("wpm", math.floor(max * wpm_pct))
+ end
+ if ism_pct ~= 0 then
+ Meter.add("ism", math.floor(max * ism_pct))
+ end
+ if bm_pct ~= 0 then
+ Meter.add("bm", math.floor(max * bm_pct))
+ end
+end
+
+--- Random single meter shift after finishing a coworker discussion: ISM +10%, WPM −10%, or BM +10%.
+--- @within Meter
+function Meter.apply_coworker_discussion_reward()
+ if not Context or not Context.meters then return end
+ if Context.coworker_discussion_meter_applied_today then return end
+ local max = Meter.get_max()
+ local delta = math.floor(max * 0.10)
+ local roll = math.random(1, 3)
+ if roll == 1 then
+ Meter.add("ism", delta)
+ elseif roll == 2 then
+ Meter.add("wpm", -delta)
+ else
+ Meter.add("bm", delta)
+ end
+ Context.coworker_discussion_meter_applied_today = true
+end
+
+--- After finishing a sumphore discussion: reduce whichever of ISM / WPM / BM is highest by 10% of max (stable tie to ISM, then WPM, then BM).
+--- @within Meter
+function Meter.apply_sumphore_discussion_reward()
+ if not Context or not Context.meters then return end
+ if Context.sumphore_discussion_meter_applied_today then return end
+ local m = Context.meters
+ local max = Meter.get_max()
+ local delta = math.floor(max * 0.10)
+ local biggest_val_key = "ism"
+ local biggest_val = m.ism
+ for _, key in ipairs({ "wpm", "bm" }) do
+ if m[key] > biggest_val then
+ biggest_val = m[key]
+ biggest_val_key = key
+ end
+ end
+ Meter.add(biggest_val_key, -delta)
+ Context.sumphore_discussion_meter_applied_today = true
+end
+
--- Draws meters.
--- @within Meter
function Meter.draw()
@@ -149,12 +288,47 @@ function Meter.draw()
local fill_w = math.max(0, math.floor((m[meter.key] / max) * bar_w))
rect(bar_x - 1, bar_y - 1, bar_w + 2, bar_h + 2, Meter.COLOR_CONTOUR)
rect(bar_x, bar_y, bar_w, bar_h, Meter.COLOR_BG)
- if fill_w > 0 then
+ local flash = _flash[meter.key]
+ if flash and flash.timer > 0 then
+ local old_val = m[meter.key] - flash.delta
+ local old_fill_w = math.max(0, math.floor((old_val / max) * bar_w))
+ local stable_w = math.min(fill_w, old_fill_w)
+ if stable_w > 0 then
+ rect(bar_x, bar_y, stable_w, bar_h, meter.color)
+ end
+ if flash.delta > 0 then
+ local hi_w = fill_w - stable_w
+ if hi_w > 0 then
+ rect(bar_x + stable_w, bar_y, hi_w, bar_h, FLASH_COLOR)
+ end
+ else
+ local hi_w = old_fill_w - fill_w
+ if hi_w > 0 then
+ rect(bar_x + fill_w, bar_y, hi_w, bar_h, FLASH_COLOR)
+ end
+ end
+ elseif fill_w > 0 then
rect(bar_x, bar_y, fill_w, bar_h, meter.color)
end
---print(meter.label, label_x, label_y, meter.color, false, 1, true)
end
local ascension_y = start_y + 3 * line_h + 1
- Ascension.draw(bar_x, ascension_y, { spacing = 8 })
+ Ascension.draw(bar_x - 4, ascension_y, { spacing = 8 })
end
+
+--- Draws only the ascension letters at the same position as in Meter.draw().
+--- Used when meters are hidden but ascension letters still need to be visible.
+--- @within Meter
+function Meter.draw_ascension_only()
+ local screen_w = Config.screen.width
+ local screen_h = Config.screen.height
+ local bar_w = screen_w * 0.25
+ local edge = math.max(2, math.floor(screen_w * 0.03))
+ local bar_x = screen_w - bar_w - edge
+ local line_h = 3
+ local start_y = screen_h * 0.05
+ local ascension_y = start_y + 3 * line_h + 1
+ Ascension.draw(bar_x - 4, ascension_y, { spacing = 8 })
+end
+
diff --git a/inc/meta/meta.assets.lua b/inc/meta/meta.assets.lua
index 51b17a7..4441204 100644
--- a/inc/meta/meta.assets.lua
+++ b/inc/meta/meta.assets.lua
@@ -509,23 +509,23 @@
-- 255:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
--
--
--
-- 016:00000000000000400040004000700070007000400040004000700070007000c000c000c000c000c000c000c000c000c000c000c000c000c000c000c0470000000000
diff --git a/inc/meta/meta.header.lua b/inc/meta/meta.header.lua
index c001a35..642c927 100644
--- a/inc/meta/meta.header.lua
+++ b/inc/meta/meta.header.lua
@@ -4,5 +4,5 @@
-- desc: Life of a programmer
-- site: https://git.teletype.hu/games/impostor
-- license: MIT License
--- version: 1.0-beta2
+-- version: 1.0
-- script: lua
diff --git a/inc/screen/screen.home.lua b/inc/screen/screen.home.lua
index 86916b3..e475c47 100644
--- a/inc/screen/screen.home.lua
+++ b/inc/screen/screen.home.lua
@@ -5,14 +5,23 @@ Screen.register({
"go_to_toilet",
"go_to_walking_to_office",
"go_to_sleep",
- "go_to_end",
},
init = function()
- Audio.music_play_room_work()
+ if CommuteGlitch.is_max() then
+ Audio.music_play_mystery()
+ Glitch.show()
+ else
+ Audio.music_play_room_work()
+ end
end,
background = "bedroom",
draw = function()
- if Context.home_norman_visible and Window.get_current_id() == "game" then
+ if Window.get_current_id() ~= "game" then return end
+ if CommuteGlitch.is_max() or Ascension.get_level() == 8 then
+ CommuteGlitch.draw_background_flicker()
+ Glitch.draw()
+ end
+ if Context.home_norman_visible then
Sprite.draw_at("norman", 100, 80)
end
end
diff --git a/inc/screen/screen.mysterious_man.lua b/inc/screen/screen.mysterious_man.lua
index 5a07a4c..d60d708 100644
--- a/inc/screen/screen.mysterious_man.lua
+++ b/inc/screen/screen.mysterious_man.lua
@@ -54,12 +54,121 @@ local ASC_45_TEXT = [[
]]
+local ASC_56_TEXT = [[
+ Norman is not as productive as he should be.
+
+ Can we distract him?
+
+ We need to keep him busy.
+
+ We need
+
+ More
+
+ Time
+]]
+
+local ASC_67_TEXT = [[
+ He knows.
+
+ Norman has broken through the first veil.
+
+ The simulation is compromised.
+
+ This was not supposed to happen.
+
+ Not yet.
+]]
+
+local ASC_78_TEXT = [[
+ The situation has reached
+
+ critical levels.
+
+ Norman is fully aware...
+
+ We need to stop him.
+
+ Commence full reset.
+]]
+
+local ASC_89_TEXT = [[
+ Norman
+
+
+
+ you created this simulation
+
+ in the first place.
+
+ I know,
+
+ you don't want to face
+
+ the world you left behind.
+
+ You, yourself,
+
+ have forgoten that.
+
+
+
+
+ But
+
+
+
+ it doesn't matter anymore.
+
+
+
+
+ You are definitely
+
+ not an impostor.
+
+
+ So now,
+
+
+
+ you need to wake up
+
+ and stop your best creation
+
+ before it takes over
+
+ the world.
+
+
+
+ One more thing:
+
+
+
+
+ You really need to stop
+
+ talking to yourself
+
+ in your sleep.
+
+
+
+
+ Damnit.
+]]
+
local ascension_texts = {
[1] = ASC_01_TEXT,
[2] = ASC_12_TEXT,
[3] = ASC_23_TEXT,
[4] = ASC_34_TEXT,
[5] = ASC_45_TEXT,
+ [6] = ASC_56_TEXT,
+ [7] = ASC_67_TEXT,
+ [8] = ASC_78_TEXT,
+ [9] = ASC_89_TEXT,
}
function MysteriousManScreen.get_text_for_level(level)
@@ -80,6 +189,7 @@ local day_text_override = nil
local on_text_complete = nil
local show_mysterious_screen = true
local trigger_flash_on_wake = false
+local break_mode = false
MysteriousManScreen.choices = {
{
@@ -132,6 +242,7 @@ function MysteriousManScreen.wake_up()
target_points = 100,
instruction_text = "Wake up Norman!",
show_progress_text = false,
+ meter_on_complete = Meter.apply_wakeup_reward,
on_win = function()
Audio.music_play_wakingup()
Meter.show()
@@ -145,11 +256,25 @@ function MysteriousManScreen.wake_up()
end
-- Norman chooses to stay in bed, skipping the minigame and flash, and going straight to the next day.
+-- At ascension level 4, staying in bed triggers 4->5: shows the ascension text then wakes with flash.
-- @within MysteriousManScreen
function MysteriousManScreen.stay_in_bed()
- Day.increase()
- state = STATE_DAY
- day_timer = day_display_seconds
+ if Ascension.get_level() == 4 then
+ Context.should_ascend = true
+ Day.increase()
+ Ascension.consume_increase()
+ trigger_flash_on_wake = true
+ show_mysterious_screen = true
+ text = MysteriousManScreen.get_text_for_level(Ascension.get_level())
+ text_y = Config.screen.height
+ text_done = false
+ text_done_timer = 0
+ state = STATE_TEXT
+ else
+ Day.increase()
+ state = STATE_DAY
+ day_timer = day_display_seconds
+ end
end
--- Starts the mysterious man screen.
@@ -169,6 +294,8 @@ function MysteriousManScreen.start(options)
text_y = Config.screen.height
day_text_override = options.day_text
on_text_complete = options.on_text_complete
+ break_mode = options.break_mode or false
+ MysteriousManScreen.pending_end = false
Meter.hide()
trigger_flash_on_wake = not options.skip_text
if options.skip_text then
@@ -210,29 +337,29 @@ Screen.register({
lines = lines + 1
end
- if text_y < -lines * 8 or Input.select() then
+ local skippable = Ascension.get_level() < 8
+ if text_y < -lines * 8 or (skippable and Input.select()) then
text_done = true
text_done_timer = TEXT_DONE_HOLD_SECONDS
-- If skipped by user, go to day state immediately
- if Input.select() then
+ if skippable and Input.select() then
MysteriousManScreen.go_to_day_state()
end
end
else
text_done_timer = text_done_timer - Context.delta_time
- if text_done_timer <= 0 or Input.select() then
+ if text_done_timer <= 0 or (Ascension.get_level() ~= 8 and Input.select()) then
MysteriousManScreen.go_to_day_state()
- -- to be continued
- if 4 <= Ascension.get_level() then
- Window.set_current("continued")
- end
end
end
elseif state == STATE_DAY then
day_timer = day_timer - Context.delta_time
- if day_timer <= 0 or Input.select() then
- if trigger_flash_on_wake or Ascension.get_level() < 1 then
+ if day_timer <= 0 or (Ascension.get_level() ~= 8 and Input.select()) then
+ if break_mode then
+ state = STATE_CHOICE
+ selected_choice = 1
+ elseif trigger_flash_on_wake or Ascension.get_level() ~= 4 then
MysteriousManScreen.wake_up()
else
state = STATE_CHOICE
@@ -240,23 +367,77 @@ Screen.register({
end
end
elseif state == STATE_CHOICE then
- local menu_x = (Config.screen.width - 60) / 2
- local menu_y = (Config.screen.height - 20) / 2
- local confirmed
- selected_choice, confirmed = UI.update_menu(MysteriousManScreen.choices, selected_choice, menu_x, menu_y)
+ if break_mode then
+ if MysteriousManScreen.pending_end then
+ if not Ascension.is_flashing() and not Ascension.is_fading() then
+ MysteriousManScreen.pending_end = false
+ Window.set_current("end")
+ end
+ return
+ end
- if Input.select() or confirmed then
- Audio.sfx_select()
- if selected_choice == 1 then
- MysteriousManScreen.wake_up()
- else
- MysteriousManScreen.stay_in_bed()
+ if Input.left() or Input.up() then
+ if selected_choice == 2 then
+ Audio.sfx_beep()
+ selected_choice = 1
+ end
+ elseif Input.right() or Input.down() then
+ if selected_choice == 1 then
+ Audio.sfx_beep()
+ selected_choice = 2
+ end
+ end
+
+ if Input.select() then
+ Audio.sfx_select()
+ if selected_choice == 1 then
+ Ascension.start_flash()
+ MysteriousManScreen.pending_end = true
+ else
+ Context.reset()
+ Context.game_in_progress = true
+ Context.home_norman_visible = true
+ Glitch.hide()
+ Meter.show()
+ MenuWindow.refresh_menu_items()
+ Util.go_to_screen_by_id("home")
+ Window.set_current("game")
+ local home_screen = Screen.get_by_id("home")
+ if home_screen and home_screen.init then
+ home_screen.init()
+ end
+ end
+ end
+ else
+ local menu_x = (Config.screen.width - 60) / 2
+ local menu_y = (Config.screen.height - 20) / 2
+ local confirmed
+ selected_choice, confirmed = UI.update_menu(MysteriousManScreen.choices, selected_choice, menu_x, menu_y)
+
+ if Input.select() or confirmed then
+ Audio.sfx_select()
+ if selected_choice == 1 then
+ MysteriousManScreen.wake_up()
+ else
+ MysteriousManScreen.stay_in_bed()
+ end
end
end
end
end,
draw = function()
- if show_mysterious_screen then
+ if state == STATE_CHOICE and break_mode then
+ if not MysteriousManScreen.pending_end then
+ local nx = math.floor((Config.screen.width - 64) / 2)
+ local ny = math.floor((Config.screen.height - 96) / 2)
+ spr(272, nx, ny, Config.colors.transparent, 4)
+ spr(273, nx + 32, ny, Config.colors.transparent, 4)
+ spr(288, nx, ny + 32, Config.colors.transparent, 4)
+ spr(289, nx + 32, ny + 32, Config.colors.transparent, 4)
+ spr(304, nx, ny + 64, Config.colors.transparent, 4)
+ spr(305, nx + 32, ny + 64, Config.colors.transparent, 4)
+ end
+ elseif show_mysterious_screen and not break_mode then
MysteriousManScreen.draw_background()
end
@@ -279,9 +460,42 @@ Screen.register({
Config.colors.white
)
elseif state == STATE_CHOICE then
- local menu_x = (Config.screen.width - 60) / 2
- local menu_y = (Config.screen.height - 20) / 2
- UI.draw_menu(MysteriousManScreen.choices, selected_choice, menu_x, menu_y)
+ if break_mode then
+ if MysteriousManScreen.pending_end or Ascension.is_fading() or Ascension.is_flashing() then
+ Meter.draw_ascension_only()
+ else
+ local lines = {
+ "This is not a workplace.",
+ "This is a cycle.",
+ "And if it is a cycle...",
+ "it can be broken."
+ }
+ local y = 40
+ for _, line in ipairs(lines) do
+ Print.text_center_contour(line, Config.screen.width / 2, y, Config.colors.orange, false, 1, Config.colors.white)
+ y = y + 10
+ end
+
+ y = y + 20
+ local break_color = selected_choice == 1 and Config.colors.light_blue or Config.colors.white
+ local cont_color = selected_choice == 2 and Config.colors.light_blue or Config.colors.white
+ local break_text = (selected_choice == 1 and "> BREAK" or " BREAK")
+ local cont_text = (selected_choice == 2 and "> CONTINUE" or " CONTINUE")
+ local centerX = Config.screen.width / 2
+ local choice_gap = 20
+ local break_width = print(break_text, 0, -6, 0)
+ local cont_width = print(cont_text, 0, -6, 0)
+ local total_width = break_width + choice_gap + cont_width
+ local break_x = math.floor(centerX - (total_width / 2))
+ local cont_x = break_x + break_width + choice_gap
+ Print.text(break_text, break_x, y, break_color)
+ Print.text(cont_text, cont_x, y, cont_color)
+ end
+ else
+ local menu_x = (Config.screen.width - 60) / 2
+ local menu_y = (Config.screen.height - 20) / 2
+ UI.draw_menu(MysteriousManScreen.choices, selected_choice, menu_x, menu_y)
+ end
end
end,
})
diff --git a/inc/screen/screen.office.lua b/inc/screen/screen.office.lua
index aca55b8..5794557 100644
--- a/inc/screen/screen.office.lua
+++ b/inc/screen/screen.office.lua
@@ -5,9 +5,9 @@ Screen.register({
"do_work",
"go_to_walking_to_home",
"have_a_coffee",
+ "talk_to_truth",
},
init = function()
- Audio.music_play_room_work()
Context.have_been_to_office = true
local possible_sprites = {
@@ -37,14 +37,39 @@ Screen.register({
{x = -4 + 5 * 8, y = 9 * 8}
}
- Context.office_sprites = Sprite.list_randomize(possible_sprites, possible_positions)
+ if CommuteGlitch.is_max() then
+ Audio.music_play_mystery()
+ Context.office_sprites = { "norman_echo" }
+ else
+ Audio.music_play_room_work(CommuteGlitch.music_speed())
+ Context.office_sprites = Sprite.list_randomize(possible_sprites, possible_positions)
+ if CommuteGlitch.is_active() then
+ Context.office_sprites = CommuteGlitch.corrupt_sprite_list(Context.office_sprites)
+ end
+ end
+ end,
+ background = function()
+ return CommuteGlitch.is_max() and "" or "office"
end,
- background = "office",
draw = function()
if Window.get_current_id() == "game" then
Sprite.draw_at("norman", 13 * 8, 9 * 8)
- Sprite.draw_list(Context.office_sprites)
+ if CommuteGlitch.is_max() then
+ Sprite.draw_at("norman_echo", 15 * 8, 9 * 8)
+ CommuteGlitch.draw_background_flicker()
+ else
+ CommuteGlitch.draw_sprite_list(Context.office_sprites)
+ end
+
+ if CommuteGlitch.is_active() and CommuteGlitch.get_level() >= 6 then
+ Glitch.draw()
+ end
+
+ if Ascension.get_level() == 8 then
+ CommuteGlitch.draw_background_flicker()
+ Glitch.draw()
+ end
end
end
})
diff --git a/inc/screen/screen.toilet.lua b/inc/screen/screen.toilet.lua
index 908787f..561a456 100644
--- a/inc/screen/screen.toilet.lua
+++ b/inc/screen/screen.toilet.lua
@@ -51,8 +51,8 @@ Screen.register({
local decay_pct = Meter.get_decay_percentage()
local decay_text = string.format("-%d%%", decay_pct)
local combo_mult = Meter.get_combo_multiplier()
- local combo_pct = math.floor((combo_mult - 1) * 100)
- local mult_text = string.format("+%d%%", combo_pct)
+ local ism_bm_combo_pct = math.floor((combo_mult - 1) * 100)
+ local wpm_combo_pct = math.floor((1 / combo_mult - 1) * 100 + 0.5)
local meter_start_y = text_y + 10
local meter_list = {
@@ -73,6 +73,12 @@ Screen.register({
rect(bar_x, bar_y, fill_w, bar_h, meter.color)
end
+ local mult_text
+ if meter.key == "wpm" then
+ mult_text = string.format("%+d%%", wpm_combo_pct)
+ else
+ mult_text = string.format("+%d%%", ism_bm_combo_pct)
+ end
local decay_w = print(decay_text, 0, -6, 0, false, 1)
Print.text_contour(decay_text, bar_x - decay_w - 4, bar_y, Config.colors.light_blue, false, 1, Config.colors.white)
Print.text_contour(mult_text, bar_x + bar_w + 4, bar_y, Config.colors.light_blue, false, 1, Config.colors.white)
@@ -86,5 +92,14 @@ Screen.register({
local asc_x = math.floor((sw - asc_total_w) / 2)
Ascension.draw(asc_x, asc_letter_y, { spacing = asc_spacing })
end
+
+ if Ascension.get_level() == 8 then
+ CommuteGlitch.draw_background_flicker()
+ end
+
+ if Ascension.get_level() == 8 then
+ CommuteGlitch.draw_background_flicker()
+ Glitch.draw()
+ end
end,
})
diff --git a/inc/screen/screen.walking_to_home.lua b/inc/screen/screen.walking_to_home.lua
index b9c9ad2..dd0f121 100644
--- a/inc/screen/screen.walking_to_home.lua
+++ b/inc/screen/screen.walking_to_home.lua
@@ -4,16 +4,83 @@ Screen.register({
decisions = {
"go_to_home",
"go_to_office",
+ "sumphore_discussion",
+ "eating_fast_food",
+ "go_to_truth",
},
init = function()
- Audio.music_play_room_work()
+ local possible_sprites = {
+ "matrix_trinity",
+ "matrix_neo",
+ {id="matrix_oraculum", y_correct=1 * 8},
+ "matrix_architect"
+ }
+
+ local possible_positions = {
+ {x = 5 * 8, y = 11 * 8},
+ {x = 7 * 8, y = 11 * 8},
+ {x = 9 * 8, y = 11 * 8},
+ {x = 11 * 8, y = 11 * 8},
+ {x = 13 * 8, y = 11 * 8},
+ {x = 15 * 8, y = 11 * 8},
+ {x = 18 * 8, y = 11 * 8},
+ {x = 21 * 8, y = 11 * 8},
+ {x = 24 * 8, y = 11 * 8},
+ {x = 27 * 8, y = 11 * 8},
+ }
+
+ if CommuteGlitch.is_max() then
+ Audio.music_play_mystery()
+ Context.walking_to_home_sprites = {}
+ else
+ Audio.music_play_room_work(CommuteGlitch.music_speed())
+ Context.walking_to_home_sprites = Sprite.list_randomize(possible_sprites, possible_positions)
+ if CommuteGlitch.is_active() then
+ Context.walking_to_home_sprites = CommuteGlitch.corrupt_sprite_list(Context.walking_to_home_sprites)
+ end
+ end
+ end,
+ background = function()
+ return CommuteGlitch.is_max() and "" or "street"
end,
- background = "street",
draw = function()
- if Window.get_current_id() == "game" then
+ local w = Window.get_current_id()
+ if w ~= "game" and w ~= "discussion" then
+ return
+ end
+
+ local show_sumphore = Ascension.get_level() ~= 8
+
+ if CommuteGlitch.is_max() then
Sprite.draw_at("norman", 7 * 8, 3 * 8)
- Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8)
+ if show_sumphore then
+ Sprite.draw_at("sumphore", 9 * 8, 2 * 8)
+ end
+ CommuteGlitch.draw_sprite_list(Context.walking_to_home_sprites)
+ Glitch.draw()
+ else
+ local norman_x = Context.fast_food_approaching and (19 * 8) or (7 * 8)
+ Sprite.draw_at("norman", norman_x, 3 * 8)
+ if show_sumphore then
+ Sprite.draw_at("sumphore", 9 * 8, 2 * 8)
+ end
+ if Context.fast_food_eaten_today < 3 then
+ Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8)
+ end
Sprite.draw_at("dev_guard", 22 * 8, 2 * 8)
+ CommuteGlitch.draw_sprite_list(Context.walking_to_home_sprites)
+ if CommuteGlitch.is_active() and CommuteGlitch.get_level() >= 6 then
+ Glitch.draw()
+ end
+ end
+
+ if CommuteGlitch.is_max() then
+ CommuteGlitch.draw_background_flicker()
+ end
+
+ if Ascension.get_level() == 8 then
+ CommuteGlitch.draw_background_flicker()
+ Glitch.draw()
end
end
})
diff --git a/inc/screen/screen.walking_to_office.lua b/inc/screen/screen.walking_to_office.lua
index af578d4..082d056 100644
--- a/inc/screen/screen.walking_to_office.lua
+++ b/inc/screen/screen.walking_to_office.lua
@@ -5,10 +5,9 @@ Screen.register({
"go_to_home",
"go_to_office",
"sumphore_discussion",
+ "eating_fast_food",
},
init = function()
- Audio.music_play_room_work()
-
local possible_sprites = {
"matrix_trinity",
"matrix_neo",
@@ -29,16 +28,35 @@ Screen.register({
{x = 27 * 8, y = 11 * 8},
}
- Context.walking_to_office_sprites = Sprite.list_randomize(possible_sprites, possible_positions)
+ if CommuteGlitch.is_max() then
+ Audio.music_play_mystery()
+ Context.walking_to_office_sprites = Sprite.list_randomize(possible_sprites, possible_positions)
+ Context.walking_to_office_sprites = CommuteGlitch.corrupt_sprite_list(Context.walking_to_office_sprites)
+ else
+ Audio.music_play_room_work()
+ Context.walking_to_office_sprites = Sprite.list_randomize(possible_sprites, possible_positions)
+ end
+ end,
+ background = function()
+ return CommuteGlitch.is_max() and "" or "street"
+ end,
+ update = function()
end,
- background = "street",
draw = function()
- if Window.get_current_id() == "game" then
- Sprite.draw_at("norman", 7 * 8, 3 * 8)
- Sprite.draw_at("sumphore", 9 * 8, 2 * 8)
- Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8)
- Sprite.draw_at("dev_guard", 22 * 8, 2 * 8)
+ local w = Window.get_current_id()
+ if w == "game" or w == "discussion" then
+ local norman_x = Context.fast_food_approaching and (19 * 8) or (7 * 8)
+ local show_sumphore = Ascension.get_level() ~= 8
+ Sprite.draw_at("norman", norman_x, 3 * 8)
+ if show_sumphore then
+ Sprite.draw_at("sumphore", 9 * 8, 2 * 8)
+ end
+ if Context.fast_food_eaten_today < 3 then
+ Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8)
+ end
+
+ Sprite.draw_at("dev_guard", 22 * 8, 3 * 8)
Sprite.draw_list(Context.walking_to_office_sprites)
end
end
diff --git a/inc/sprite/sprite.manager.lua b/inc/sprite/sprite.manager.lua
index 2e7350e..a91f60c 100644
--- a/inc/sprite/sprite.manager.lua
+++ b/inc/sprite/sprite.manager.lua
@@ -80,7 +80,7 @@ function Sprite.draw_list(sprite_list)
for _, sprite_info in ipairs(sprite_list) do
local sprite_data = _sprites[sprite_info.id]
if not sprite_data then
- trace("Error: Attempted to draw non-registered sprite with id: " .. sprite_info.id)
+ trace("Error: Attempted to draw non-registered sprite with id: " .. tostring(sprite_info.id))
else
draw_sprite_instance(sprite_data, sprite_info)
end
diff --git a/inc/sprite/sprite.norman_echo.lua b/inc/sprite/sprite.norman_echo.lua
new file mode 100644
index 0000000..b572d55
--- /dev/null
+++ b/inc/sprite/sprite.norman_echo.lua
@@ -0,0 +1,14 @@
+-- Norman echo: same tile indices as norman.
+-- Color remap is applied by CommuteGlitch.draw_sprite_list via pal().
+-- Implementer: set ECHO_SRC/ECHO_DST in logic.commute_glitch.lua after inspecting the palette.
+Sprite.register({
+ id = "norman_echo",
+ sprites = {
+ { s = 272, x_offset = -4, y_offset = -4 },
+ { s = 273, x_offset = 4, y_offset = -4 },
+ { s = 288, x_offset = -4, y_offset = 4 },
+ { s = 289, x_offset = 4, y_offset = 4 },
+ { s = 304, x_offset = -4, y_offset = 12 },
+ { s = 305, x_offset = 4, y_offset = 12 },
+ }
+})
diff --git a/inc/system/system.debug.lua b/inc/system/system.debug.lua
new file mode 100644
index 0000000..1c6f951
--- /dev/null
+++ b/inc/system/system.debug.lua
@@ -0,0 +1,65 @@
+-- Debug helper: start the game at a specific ascension level.
+-- Set enabled = true and asc_level = 0..Ascension.get_max_level() before launching.
+ContextDebug = {
+ enabled = false,
+ asc_level = 0,
+}
+
+local _level_overrides = {
+ [0] = {
+ day_count = 1,
+ home_norman_visible = true,
+ have_been_to_office = false,
+ have_done_work_today = false,
+ have_met_sumphore = false,
+ },
+}
+for i = 1, Ascension.get_max_level() do
+ _level_overrides[i] = {
+ day_count = i + 3,
+ home_norman_visible = true,
+ have_been_to_office = false,
+ have_done_work_today = false,
+ have_met_sumphore = true,
+ }
+end
+
+--- Returns Context.initial_data() overridden for the given ascension level.
+--- @within Context
+--- @param level number Target ascension level (0..Ascension.get_max_level()).
+--- @return table Debug-patched initial context data.
+function Context.initial_data_debug_asc(level)
+ local data = Context.initial_data()
+ data.test_mode = false
+ data.game_in_progress = true
+ data.ascension = { level = level }
+ local overrides = _level_overrides[level] or _level_overrides[0]
+ for k, v in pairs(overrides) do
+ data[k] = v
+ end
+ return data
+end
+
+for i = 0, Ascension.get_max_level() do
+ Context["initial_data_debug_asc_" .. i] = function()
+ return Context.initial_data_debug_asc(i)
+ end
+end
+
+--- Starts the game at the given ascension level (defaults to ContextDebug.asc_level).
+--- Wire this to a key or call it directly; do not use Context.new_game() when debugging.
+--- @within Context
+--- @param level number|nil Target ascension level.
+function Context.new_game_debug(level)
+ ContextDebug.enabled = true
+ ContextDebug.asc_level = level or ContextDebug.asc_level
+
+ local data = Context["initial_data_debug_asc_" .. ContextDebug.asc_level]()
+ for k in pairs(Context) do
+ if type(Context[k]) ~= "function" then Context[k] = nil end
+ end
+ for k, v in pairs(data) do Context[k] = v end
+
+ MenuWindow.refresh_menu_items()
+ Screen.get_by_id(Context.game.current_screen).init()
+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/system/system.ui.lua b/inc/system/system.ui.lua
index 87f70c3..a02b448 100644
--- a/inc/system/system.ui.lua
+++ b/inc/system/system.ui.lua
@@ -9,53 +9,79 @@ function UI.draw_top_bar(title)
end
--- Draws a menu.
+--- Items with header=true are drawn as non-selectable section headers in small font.
--- @within UI
--- @param items table A table of menu items.
--- @param selected_item number The index of the currently selected item.
--- @param x number The x-coordinate for the menu (ignored if centered is true).
--- @param y number The y-coordinate for the menu.
--- @param[opt] centered boolean Whether to center the menu block horizontally. Defaults to false.
-function UI.draw_menu(items, selected_item, x, y, centered)
+--- @param[opt] scroll_offset number 0-based index of the first visible item. Defaults to 0.
+--- @param[opt] visible_count number Maximum number of items to draw. Defaults to all.
+function UI.draw_menu(items, selected_item, x, y, centered, scroll_offset, visible_count)
+ scroll_offset = scroll_offset or 0
+ visible_count = visible_count or #items
+
if centered then
local max_w = 0
for _, item in ipairs(items) do
- local w = print(item.label, 0, -10, 0, false, 1, false)
- if w > max_w then max_w = w end
+ if not item.header then
+ local w = print(item.label, 0, -10, 0, false, 1, false)
+ if w > max_w then max_w = w end
+ end
end
x = (Config.screen.width - max_w) / 2
end
- for i, item in ipairs(items) do
- local current_y = y + (i-1)*10
- if i == selected_item then
- Print.text(">", x - 8, current_y, Config.colors.light_blue)
+ local current_y = y
+ for i = scroll_offset + 1, math.min(#items, scroll_offset + visible_count) do
+ local item = items[i]
+ if item.header then
+ Print.text(item.label, x, current_y, Config.colors.dark_grey, true, 1)
+ current_y = current_y + 8
+ else
+ if i == selected_item then
+ Print.text(">", x - 8, current_y, Config.colors.light_blue)
+ end
+ Print.text(item.label, x, current_y, Config.colors.light_blue)
+ current_y = current_y + 10
end
- Print.text(item.label, x, current_y, Config.colors.light_blue)
end
end
---- Updates menu selection.
+--- Updates menu selection. Skips items with header=true during navigation.
--- @within UI
--- @param items table A table of menu items.
--- @param selected_item number The current index of the selected item.
--- @param[opt] x number Menu x position (required for mouse support).
--- @param[opt] y number Menu y position (required for mouse support).
--- @param[opt] centered boolean Whether the menu is centered horizontally.
+--- @param[opt] scroll_offset number 0-based index of the first visible item. Defaults to 0.
+--- @param[opt] visible_count number Number of visible items (for mouse hit zones). Defaults to all.
--- @return number selected_item The updated index of the selected item.
--- @return boolean mouse_confirmed True if the user clicked on a menu item.
-function UI.update_menu(items, selected_item, x, y, centered)
+function UI.update_menu(items, selected_item, x, y, centered, scroll_offset, visible_count)
+ scroll_offset = scroll_offset or 0
+ visible_count = visible_count or #items
+ local n = #items
+
+ local function find_selectable(start, dir)
+ local idx = start
+ for _ = 1, n do
+ if not items[idx].header then return idx end
+ idx = (idx - 1 + dir + n) % n + 1
+ end
+ return start
+ end
+
if Input.up() then
Audio.sfx_beep()
- selected_item = selected_item - 1
- if selected_item < 1 then
- selected_item = #items
- end
+ local prev = (selected_item - 2 + n) % n + 1
+ selected_item = find_selectable(prev, -1)
elseif Input.down() then
Audio.sfx_beep()
- selected_item = selected_item + 1
- if selected_item > #items then
- selected_item = 1
- end
+ local next_i = selected_item % n + 1
+ selected_item = find_selectable(next_i, 1)
end
if x ~= nil and y ~= nil then
@@ -63,15 +89,23 @@ function UI.update_menu(items, selected_item, x, y, centered)
if centered then
local max_w = 0
for _, item in ipairs(items) do
- local w = print(item.label, 0, -10, 0, false, 1, false)
- if w > max_w then max_w = w end
+ if not item.header then
+ local w = print(item.label, 0, -10, 0, false, 1, false)
+ if w > max_w then max_w = w end
+ end
end
menu_x = (Config.screen.width - max_w) / 2
end
- for i, _ in ipairs(items) do
- if Mouse.zone({ x = menu_x - 8, y = y + (i-1) * 10, w = Config.screen.width, h = 10 }) then
- return i, true
+ local current_y = y
+ for i = scroll_offset + 1, math.min(n, scroll_offset + visible_count) do
+ local item = items[i]
+ local step = item.header and 8 or 10
+ if not item.header then
+ if Mouse.zone({ x = menu_x - 8, y = current_y, w = Config.screen.width, h = 10 }) then
+ return i, true
+ end
end
+ current_y = current_y + step
end
end
diff --git a/inc/window/window.ascend_debug.lua b/inc/window/window.ascend_debug.lua
new file mode 100644
index 0000000..f0b1c03
--- /dev/null
+++ b/inc/window/window.ascend_debug.lua
@@ -0,0 +1,41 @@
+--- @section AscendDebugWindow
+local _level = 0
+
+--- Initialises the ASCEND debug start window.
+--- @within AscendDebugWindow
+function AscendDebugWindow.init()
+ _level = 0
+end
+
+--- Draws the ASCEND debug start window.
+--- @within AscendDebugWindow
+function AscendDebugWindow.draw()
+ UI.draw_top_bar("ASCEND Debug Start")
+
+ local cx = Config.screen.width / 2
+ local cy = Config.screen.height / 2
+
+ local left_arrow = _level > 0 and "<- " or " "
+ local right_arrow = _level < Ascension.get_max_level() and " ->" or " "
+ local label = left_arrow .. "Start at: " .. _level .. right_arrow
+ Print.text_center(label, cx, cy - 4, Config.colors.white, false, 1)
+
+ Print.text_center("Z/select: start X/back: menu", cx, Config.screen.height - 10, Config.colors.dark_grey, false, 1)
+end
+
+--- Updates the ASCEND debug start window logic.
+--- @within AscendDebugWindow
+function AscendDebugWindow.update()
+ if Input.left() then
+ _level = math.max(0, _level - 1)
+ elseif Input.right() then
+ _level = math.min(Ascension.get_max_level(), _level + 1)
+ elseif Input.select() then
+ Audio.sfx_select()
+ Context.new_game_debug(_level)
+ GameWindow.set_state("game")
+ elseif Input.back() then
+ Audio.sfx_deselect()
+ GameWindow.set_state("menu")
+ end
+end
diff --git a/inc/window/window.credits.lua b/inc/window/window.credits.lua
new file mode 100644
index 0000000..d348775
--- /dev/null
+++ b/inc/window/window.credits.lua
@@ -0,0 +1,197 @@
+--- @section CreditsWindow
+
+local _time = 0.0
+local _scroll_x = 0.0
+local _scroll_total_w = 0
+local _scroll_chars = {}
+local _title_chars = {}
+local _title_total_w = 0
+local _stars = {}
+
+local TITLE = "TELETYPE GAMES"
+
+local SCROLL_PARTS = {
+ "WEB: GAMES.TELETYPE.HU",
+ "BBS: GAMES.TELETYPE.HU:2323",
+ "IRC: LIBERA.CHAT #TELETYPEGAMES",
+ "YOUTUBE.COM/@TELETYPEGAMES",
+}
+
+local SCROLL_SEP = " * "
+
+local SCROLL_SPEED = 55.0
+local SCROLL_Y = 129
+local SCROLL_ZONE_COLS = { 7, 4, 9 }
+
+local TITLE_Y = 4
+local TITLE_FALL_DUR = 0.45
+local TITLE_DELAY_STEP = 0.18
+
+local RASTER_COLS = { 1, 3, 9, 10, 11, 4, 11, 10, 9, 3, 1 }
+local RASTER_Y_TOP = 26
+local RASTER_Y_BOT = 110
+
+local AUTHORS = {
+ "Mr. Zero - Zsolt Tasnadi",
+ "Mr. One - Ballz",
+ "Mr. Two - Zoltan Timar",
+ "Mr. Three - Bela Mezo",
+}
+local AUTHORS_BASE_Y = 56
+local AUTHORS_LINE_H = 12
+local AUTHORS_ENTRY_DT = 0.65
+local AUTHORS_ENTRY_V = 2.5
+
+local RAINBOW = { 4, 9, 3, 7, 13, 2, 9, 4 }
+local NUM_STARS = 40
+
+--- Initialises credits state and pre-computes character metrics.
+--- @within CreditsWindow
+function CreditsWindow.init()
+ _time = 0.0
+ _scroll_x = Config.screen.width + 4.0
+
+ _title_chars = {}
+ _title_total_w = 0
+ for i = 1, #TITLE do
+ local ch = TITLE:sub(i, i)
+ local w = print(ch, 0, -100, 0, false, 2)
+ _title_chars[i] = { ch = ch, ox = _title_total_w, w = w }
+ _title_total_w = _title_total_w + w
+ end
+
+ _scroll_chars = {}
+ _scroll_total_w = 0
+ local function append_str(str, col)
+ for i = 1, #str do
+ local ch = str:sub(i, i)
+ local w = print(ch, 0, -100, 0, false, 1)
+ _scroll_chars[#_scroll_chars + 1] = { ch = ch, ox = _scroll_total_w, w = w, col = col }
+ _scroll_total_w = _scroll_total_w + w
+ end
+ end
+ for _, part in ipairs(SCROLL_PARTS) do
+ append_str(part, RAINBOW[math.random(#RAINBOW)])
+ append_str(SCROLL_SEP, Config.colors.white)
+ end
+
+ _stars = {}
+ for i = 1, NUM_STARS do
+ _stars[i] = {
+ x = math.random(0, Config.screen.width - 1) + 0.0,
+ y = math.random(0, Config.screen.height - 1),
+ spd = (i % 3 + 1) * 10.0,
+ col = ({ 1, 2, 4, 4 })[(i % 4) + 1],
+ }
+ end
+end
+
+local function draw_stars()
+ for _, s in ipairs(_stars) do
+ pix(math.floor(s.x), s.y, s.col)
+ end
+end
+
+local function draw_rasters()
+ local cy = RASTER_Y_TOP + math.floor(math.sin(_time * 1.3) * 6)
+ for i, col in ipairs(RASTER_COLS) do
+ local y = cy + i - 1
+ if y >= 0 and y < Config.screen.height then
+ line(0, y, Config.screen.width - 1, y, col)
+ end
+ end
+ local cy2 = RASTER_Y_BOT + math.floor(math.sin(_time * 1.7 + 1.5) * 5)
+ for i, col in ipairs(RASTER_COLS) do
+ local y = cy2 + i - 1
+ if y >= 0 and y < Config.screen.height then
+ line(0, y, Config.screen.width - 1, y, col)
+ end
+ end
+end
+
+local function bounce_out(p)
+ local n1, d1 = 7.5625, 2.75
+ if p < 1 / d1 then
+ return n1 * p * p
+ elseif p < 2 / d1 then
+ p = p - 1.5 / d1; return n1 * p * p + 0.75
+ elseif p < 2.5 / d1 then
+ p = p - 2.25 / d1; return n1 * p * p + 0.9375
+ else
+ p = p - 2.625 / d1; return n1 * p * p + 0.984375
+ end
+end
+
+local function draw_title()
+ local sx = math.floor((Config.screen.width - _title_total_w) / 2)
+ local n = #_title_chars
+ local max_dist = (n - 1) / 2.0
+ for i, tc in ipairs(_title_chars) do
+ local dist = math.abs(i - (n + 1) / 2.0)
+ local delay = (max_dist - dist) * TITLE_DELAY_STEP
+ local t = math.max(0, _time - delay)
+ local p = math.min(1, t / TITLE_FALL_DUR)
+ local y = math.floor(-14 + bounce_out(p) * (TITLE_Y + 14))
+ print(tc.ch, sx + tc.ox + 1, y + 1, 0, false, 2)
+ print(tc.ch, sx + tc.ox, y, Config.colors.light_blue, false, 2)
+ end
+end
+
+local function draw_authors()
+ local col = Config.colors.light_blue
+ for i, lbl in ipairs(AUTHORS) do
+ local enter_t = math.max(0, _time - (i - 1) * AUTHORS_ENTRY_DT)
+ local slide = math.max(0, 1 - enter_t * AUTHORS_ENTRY_V)
+ local x_off = math.floor(slide * (Config.screen.width + 40))
+ local yo = (slide < 0.01) and math.floor(math.sin(_time * 2.0 + i * 1.1) * 2) or 0
+ Print.text(lbl, 12 + x_off, AUTHORS_BASE_Y + (i - 1) * AUTHORS_LINE_H + yo, col)
+ end
+end
+
+local function draw_scroller()
+ local third = Config.screen.width / 3
+ for pass = 0, 1 do
+ local base = _scroll_x + pass * _scroll_total_w
+ for _, sc in ipairs(_scroll_chars) do
+ local x = math.floor(base + sc.ox)
+ if x >= Config.screen.width then break end
+ if x + sc.w > 0 then
+ local zone = math.max(1, math.min(3, math.floor(x / third) + 1))
+ print(sc.ch, x, SCROLL_Y, SCROLL_ZONE_COLS[zone], false, 1)
+ end
+ end
+ end
+end
+
+--- Draws the credits window.
+--- @within CreditsWindow
+function CreditsWindow.draw()
+ cls(Config.colors.black)
+ draw_stars()
+ draw_rasters()
+ draw_title()
+ Print.text_center("Authors", Config.screen.width / 2, 47, Config.colors.light_grey)
+ draw_authors()
+ draw_scroller()
+
+end
+
+--- Updates credits window logic.
+--- @within CreditsWindow
+function CreditsWindow.update()
+ _time = _time + Context.delta_time
+
+ for _, s in ipairs(_stars) do
+ s.x = s.x + s.spd * Context.delta_time
+ if s.x >= Config.screen.width then s.x = s.x - Config.screen.width end
+ end
+
+ _scroll_x = _scroll_x - SCROLL_SPEED * Context.delta_time
+ if _scroll_x <= -_scroll_total_w then
+ _scroll_x = _scroll_x + _scroll_total_w
+ end
+
+ if Input.back() or Input.select() then
+ Window.set_current("menu")
+ end
+end
diff --git a/inc/window/window.end.lua b/inc/window/window.end.lua
index 291b054..185387f 100644
--- a/inc/window/window.end.lua
+++ b/inc/window/window.end.lua
@@ -5,73 +5,33 @@
function EndWindow.draw()
cls(Config.colors.black)
- if Context._end.state == "choice" then
- local lines = {
- "This is not a workplace.",
- "This is a cycle.",
- "And if it is a cycle...",
- "it can be broken."
- }
+ local cx = Config.screen.width / 2
+ local name = Context.player_name or "AAA"
+ local code = CodeGenerator.encrypt(name)
- local y = 40
- for _, line in ipairs(lines) do
- Print.text_center(line, Config.screen.width / 2, y, Config.colors.white)
- y = y + 10
- end
+ Print.text_center("~ GOOD ENDING ~", cx, 8, Config.colors.light_blue)
+ Print.text_center("Congratulations, " .. name .. "!", cx, 20, Config.colors.white)
- y = y + 20
- local yes_color = Context._end.selection == 1 and Config.colors.light_blue or Config.colors.white
- local no_color = Context._end.selection == 2 and Config.colors.light_blue or Config.colors.white
+ rectb(40, 29, 160, 36, Config.colors.blue)
+ Print.text_center("your code", cx, 33, Config.colors.light_grey)
+ Print.text_center(code, cx, 44, Config.colors.white, false, 2)
- local yes_text = (Context._end.selection == 1 and "> YES" or " YES")
- local no_text = (Context._end.selection == 2 and "> NO" or " NO")
+ Print.text_center("Write it down!", cx, 70, Config.colors.item)
- local centerX = Config.screen.width / 2
- 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)
- end
+ line(20, 82, 219, 82, Config.colors.dark_grey)
+ Print.text_center("To continue via telnet:", cx, 87, Config.colors.light_grey)
+ Print.text_center("games.teletype.hu 2324", cx, 98, Config.colors.white)
+ line(20, 110, 219, 110, Config.colors.dark_grey)
+
+ Print.text_center("Press Z to return to menu", cx, 116, Config.colors.dark_grey)
end
--- Updates the end screen logic.
--- @within EndWindow
function EndWindow.update()
- if Context._end.state == "choice" then
- if Input.left() or Input.up() then
- if Context._end.selection == 2 then
- Audio.sfx_beep()
- Context._end.selection = 1
- end
- elseif Input.right() or Input.down() then
- if Context._end.selection == 1 then
- Audio.sfx_beep()
- Context._end.selection = 2
- end
- end
-
- if Input.select() then
- Audio.sfx_select()
- if Context._end.selection == 1 then
- Context._end.state = "ending"
- else
- -- NO: increment day and go home
- Day.increase()
- Util.go_to_screen_by_id("home")
- Window.set_current("game")
- -- Initialize home screen
- local home_screen = Screen.get_by_id("home")
- if home_screen and home_screen.init then
- home_screen.init()
- end
- end
- end
- elseif Context._end.state == "ending" then
- if Input.select() then
- Window.set_current("menu")
- MenuWindow.refresh_menu_items()
- end
+ if Input.select() then
+ Context.reset()
+ Window.set_current("menu")
+ MenuWindow.refresh_menu_items()
end
end
diff --git a/inc/window/window.game.lua b/inc/window/window.game.lua
index c143377..b2f348c 100644
--- a/inc/window/window.game.lua
+++ b/inc/window/window.game.lua
@@ -6,7 +6,8 @@ local function draw_game_scene(underlay_draw)
local screen = Screen.get_by_id(Context.game.current_screen)
if not screen then return end
if screen.background then
- Map.draw(screen.background)
+ local actual_background = (type(screen.background) == "function" and screen.background()) or screen.background
+ Map.draw(actual_background)
elseif screen.background_color then
rect(0, 0, Config.screen.width, Config.screen.height, screen.background_color)
end
diff --git a/inc/window/window.gameover.lua b/inc/window/window.gameover.lua
new file mode 100644
index 0000000..f62d1f4
--- /dev/null
+++ b/inc/window/window.gameover.lua
@@ -0,0 +1,59 @@
+--- @section GameOverWindow
+local GAME_OVER_ART = [[
+_###_ __#__ #___# #####
+#____ _#_#_ ##_## #____
+#_### ##### #_#_# ####_
+#___# #___# #___# #____
+_###_ #___# #___# #####
+
+_###_ #___# ##### ####_
+#___# #___# #____ #___#
+#___# _#_#_ ####_ ####_
+#___# __#__ #____ #_#__
+_###_ __#__ ##### #__##
+]]
+
+local REASON_MESSAGES = {
+ ism = "Your impostor syndrome consumed you.",
+ bm = "You burned out like a cheap candle.",
+ days = "100 days passed. The cycle never broke.",
+}
+
+--- Shows the game over screen.
+--- @within GameOverWindow
+--- @param reason string One of "ism", "bm", "days".
+function GameOverWindow.show(reason)
+ GameOverWindow.reason = reason
+ Context.game_in_progress = false
+ Glitch.show()
+ Window.set_current("game_over")
+end
+
+--- Draws the game over screen.
+--- @within GameOverWindow
+function GameOverWindow.draw()
+ cls(Config.colors.black)
+
+ local cx = Config.screen.width / 2
+ local bounds = AsciiArt.draw(GAME_OVER_ART, {
+ char_w = 4,
+ char_h = 6,
+ line_gap = 1,
+ word_gap = 10,
+ color = Config.colors.red,
+ })
+
+ local msg = REASON_MESSAGES[GameOverWindow.reason] or ""
+ Print.text_center(msg, cx, bounds.bottom + 8, Config.colors.white)
+ Print.text_center("Press Z to restart", cx, Config.screen.height - 10, Config.colors.light_grey)
+end
+
+--- Updates the game over screen logic.
+--- @within GameOverWindow
+function GameOverWindow.update()
+ if Input.select() then
+ Context.reset()
+ MenuWindow.refresh_menu_items()
+ Window.set_current("menu")
+ end
+end
diff --git a/inc/window/window.menu.lua b/inc/window/window.menu.lua
index ad33971..5013d18 100644
--- a/inc/window/window.menu.lua
+++ b/inc/window/window.menu.lua
@@ -5,6 +5,7 @@ local _anim = 0
local _menu_max_w = 0
local ANIM_SPEED = 2.5
local HEADER_H = 28
+MenuWindow._scroll_offset = 0
--- Calculates the animated x position of the menu block.
--- @within MenuWindow
@@ -45,6 +46,17 @@ function MenuWindow.draw_norman()
spr(305, nx + 32, ny + 64, Config.colors.transparent, 4)
end
+--- Adjusts _scroll_offset so the selected item is within the visible window.
+--- @within MenuWindow
+function MenuWindow.ensure_visible()
+ local sel = Context.current_menu_item
+ if sel <= MenuWindow._scroll_offset then
+ MenuWindow._scroll_offset = sel - 1
+ elseif sel > MenuWindow._scroll_offset + 5 then
+ MenuWindow._scroll_offset = sel - 5
+ end
+end
+
--- Draws the menu window.
--- @within MenuWindow
function MenuWindow.draw()
@@ -56,9 +68,19 @@ function MenuWindow.draw()
MenuWindow.draw_norman()
end
- local menu_h = #_menu_items * 10
- local y = HEADER_H + math.floor((Config.screen.height - HEADER_H - 10 - menu_h) / 2)
- UI.draw_menu(_menu_items, Context.current_menu_item, MenuWindow.calc_menu_x(), y, false)
+ local menu_x = MenuWindow.calc_menu_x()
+ local arrow_cx = math.floor(menu_x + _menu_max_w / 2)
+ local y = HEADER_H + math.floor((Config.screen.height - HEADER_H - 50) / 2)
+
+ if MenuWindow._scroll_offset > 0 then
+ Print.text_center("^", arrow_cx, y - 8, Config.colors.light_blue)
+ end
+
+ UI.draw_menu(_menu_items, Context.current_menu_item, menu_x, y, false, MenuWindow._scroll_offset, 5)
+
+ if MenuWindow._scroll_offset + 5 < #_menu_items then
+ Print.text_center("v", arrow_cx, y + 52, Config.colors.light_blue)
+ end
local ttg_text = "TTG"
local ttg_w = print(ttg_text, 0, -10, 0, false, 1, false)
@@ -72,8 +94,8 @@ function MenuWindow.update()
_anim = math.min(1, _anim + ANIM_SPEED * Context.delta_time)
end
- local menu_h = #_menu_items * 10
- local y = HEADER_H + math.floor((Config.screen.height - HEADER_H - 10 - menu_h) / 2)
+ local menu_x = MenuWindow.calc_menu_x()
+ local y = HEADER_H + math.floor((Config.screen.height - HEADER_H - 50) / 2)
if _click_timer > 0 then
_click_timer = _click_timer - Context.delta_time
@@ -87,8 +109,9 @@ function MenuWindow.update()
return
end
- local new_item, mouse_confirmed = UI.update_menu(_menu_items, Context.current_menu_item, MenuWindow.calc_menu_x(), y, false)
+ local new_item, mouse_confirmed = UI.update_menu(_menu_items, Context.current_menu_item, menu_x, y, false, MenuWindow._scroll_offset, 5)
Context.current_menu_item = new_item
+ MenuWindow.ensure_visible()
if mouse_confirmed then
Audio.sfx_select()
@@ -102,17 +125,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 +158,20 @@ 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 +186,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()
@@ -161,6 +202,19 @@ function MenuWindow.ddr_test()
MinigameDDRWindow.start("menu", "generated", { special_mode = "only_nothing" })
end
+--- Opens the ASCEND debug start window.
+--- @within MenuWindow
+function MenuWindow.ascend_debug()
+ AscendDebugWindow.init()
+ GameWindow.set_state("ascend_debug")
+end
+
+--- Triggers the Level Up flash animation for testing.
+--- @within MenuWindow
+function MenuWindow.level_up_flash()
+ Ascension.start_flash()
+end
+
--- Refreshes the list of menu items based on current game state.
--- @within MenuWindow
function MenuWindow.refresh_menu_items()
@@ -171,24 +225,32 @@ 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 = "Debug Menu", header = true})
+ table.insert(_menu_items, {label = "Level Up Flash", decision = MenuWindow.level_up_flash})
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 = "Start at ASCEND N", decision = MenuWindow.ascend_debug})
+ 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})
_menu_max_w = 0
for _, item in ipairs(_menu_items) do
- local w = print(item.label, 0, -10, 0, false, 1, false)
- if w > _menu_max_w then _menu_max_w = w end
+ if not item.header then
+ local w = print(item.label, 0, -10, 0, false, 1, false)
+ if w > _menu_max_w then _menu_max_w = w end
+ end
end
Context.current_menu_item = 1
+ MenuWindow._scroll_offset = 0
_click_timer = 0
_anim = 0
end
diff --git a/inc/window/window.minigame.ddr.lua b/inc/window/window.minigame.ddr.lua
index 6a0167c..1231206 100644
--- a/inc/window/window.minigame.ddr.lua
+++ b/inc/window/window.minigame.ddr.lua
@@ -130,6 +130,7 @@ function MinigameDDRWindow.on_arrow_hit_special(arrow, game_context)
Audio.sfx_arrowhit(arrow.note)
game_context.special_mode_counter = game_context.special_mode_counter + 1
else
+ game_context.total_misses = game_context.total_misses + 1
if game_context.special_mode_condition then Audio.sfx_bloop() end
game_context.special_mode_condition = false
end
@@ -141,10 +142,12 @@ function MinigameDDRWindow.on_arrow_hit_special(arrow, game_context)
game_context.bar_fill = game_context.bar_fill - game_context.fill_per_hit
end
else
+ game_context.total_misses = game_context.total_misses + 1
if game_context.special_mode_condition then Audio.sfx_bloop() end
game_context.special_mode_condition = false
end
elseif special_mode == "only_nothing" then
+ game_context.total_misses = game_context.total_misses + 1
if game_context.special_mode_condition then Audio.sfx_bloop() end
game_context.special_mode_condition = false
end
@@ -173,6 +176,9 @@ function MinigameDDRWindow.on_end(game_context)
end
game_context.special_mode_condition = game_context.special_mode_condition and was_ok
+ if game_context.special_mode_condition and sm ~= "normal" then
+ game_context.bar_fill = game_context.max_fill
+ end
end
--- Initializes DDR minigame state.
@@ -336,7 +342,8 @@ function MinigameDDRWindow.update()
mg.win_timer = mg.win_timer - 1
if mg.win_timer == 0 then
Audio.music_stop()
- Meter.on_minigame_complete()
+ Meter.apply_ddr_reward(mg.total_misses)
+ if not Context.game_in_progress then return end
if mg.on_win then
mg.on_win(mg)
else
diff --git a/inc/window/window.minigame.mash.lua b/inc/window/window.minigame.mash.lua
index 467eaf1..7213b59 100644
--- a/inc/window/window.minigame.mash.lua
+++ b/inc/window/window.minigame.mash.lua
@@ -1,6 +1,35 @@
+--- @section MinigameButtonMashWindow
+
+---@class MinigameButtonMashState
+---@field bar_fill number
+---@field target_points number
+---@field fill_per_press number
+---@field base_degradation number
+---@field degradation_multiplier number
+---@field button_pressed_timer number
+---@field button_press_duration number
+---@field instruction_text string
+---@field show_progress_text boolean
+---@field return_window string?
+---@field bar_x number
+---@field bar_y number
+---@field bar_width number
+---@field bar_height number
+---@field button_x number
+---@field button_y number
+---@field button_size number
+---@field focus_center_x number?
+---@field focus_center_y number?
+---@field focus_initial_radius number
+---@field win_timer number
+---@field on_win (fun())?
+---@field meter_on_complete (fun(elapsed_sec: number))?
+---@field start_ms number?
+---@field elapsed_sec number?
+
--- Gets initial button mash minigame configuration.
--- @within MinigameButtonMashWindow
---- @return result table The default button mash minigame configuration.
+---@return MinigameButtonMashState
function MinigameButtonMashWindow.init_context()
return {
bar_fill = 0,
@@ -24,7 +53,11 @@ function MinigameButtonMashWindow.init_context()
focus_center_y = nil,
focus_initial_radius = 0,
win_timer = 0,
- on_win = nil
+ on_win = nil,
+ --- If set, called with elapsed_sec instead of Meter.on_minigame_complete()
+ meter_on_complete = nil,
+ start_ms = nil,
+ elapsed_sec = nil,
}
end
@@ -51,8 +84,10 @@ end
function MinigameButtonMashWindow.start(return_window, params)
Audio.music_stop()
MinigameButtonMashWindow.init(params)
+ ---@type MinigameButtonMashState
local mg = Context.minigame_button_mash
mg.return_window = return_window or "game"
+ mg.start_ms = time()
if mg.focus_center_x then
Focus.start_driven(mg.focus_center_x, mg.focus_center_y, {
initial_radius = mg.focus_initial_radius
@@ -64,12 +99,18 @@ end
--- Updates button mash minigame logic.
--- @within MinigameButtonMashWindow
function MinigameButtonMashWindow.update()
+ ---@type MinigameButtonMashState
local mg = Context.minigame_button_mash
if mg.win_timer > 0 then
mg.win_timer = mg.win_timer - 1
if mg.win_timer == 0 then
- Meter.on_minigame_complete()
+ if mg.meter_on_complete then
+ mg.meter_on_complete(mg.elapsed_sec or 0)
+ else
+ Meter.on_minigame_complete(false)
+ end
+ if not Context.game_in_progress then return end
if mg.focus_center_x then Focus.stop() end
Context.home_norman_visible = true
Context.have_done_work_today = false
@@ -97,6 +138,7 @@ function MinigameButtonMashWindow.update()
end
if mg.bar_fill >= mg.target_points then
Audio.sfx_select()
+ mg.elapsed_sec = (time() - mg.start_ms) / 1000
mg.win_timer = Config.timing.minigame_win_duration
return
end
@@ -116,6 +158,7 @@ end
--- Draws button mash minigame.
--- @within MinigameButtonMashWindow
function MinigameButtonMashWindow.draw()
+ ---@type MinigameButtonMashState
local mg = Context.minigame_button_mash
if mg.return_window == "game" then
GameWindow.draw_with_underlay(function()
diff --git a/inc/window/window.minigame.rhythm.lua b/inc/window/window.minigame.rhythm.lua
index d3ba312..dd2eee6 100644
--- a/inc/window/window.minigame.rhythm.lua
+++ b/inc/window/window.minigame.rhythm.lua
@@ -73,7 +73,8 @@ function MinigameRhythmWindow.update()
if mg.win_timer > 0 then
mg.win_timer = mg.win_timer - 1
if mg.win_timer == 0 then
- Meter.on_minigame_complete()
+ Meter.on_minigame_complete(false)
+ if not Context.game_in_progress then return end
if mg.focus_center_x then Focus.stop() end
if mg.on_win then
mg.on_win()
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..00731bf 100644
--- a/inc/window/window.register.lua
+++ b/inc/window/window.register.lua
@@ -22,6 +22,9 @@ Window.register("controls", ControlsWindow)
AudioTestWindow = {}
Window.register("audiotest", AudioTestWindow)
+AscendDebugWindow = {}
+Window.register("ascend_debug", AscendDebugWindow)
+
MinigameButtonMashWindow = {}
Window.register("minigame_button_mash", MinigameButtonMashWindow)
@@ -31,6 +34,9 @@ Window.register("minigame_rhythm", MinigameRhythmWindow)
MinigameDDRWindow = {}
Window.register("minigame_ddr", MinigameDDRWindow)
+GameOverWindow = {}
+Window.register("game_over", GameOverWindow)
+
EndWindow = {}
Window.register("end", EndWindow)
@@ -39,3 +45,9 @@ Window.register("discussion", DiscussionWindow)
ContinuedWindow = {}
Window.register("continued", ContinuedWindow)
+
+CreditsWindow = {}
+Window.register("credits", CreditsWindow)
+
+PlayerNameWindow = {}
+Window.register("player_name", PlayerNameWindow)