feature/IMP-112-ascension-8-9 #59

Merged
mr.two merged 13 commits from feature/IMP-112-ascension-8-9 into develop 2026-04-29 21:27:24 +00:00
39 changed files with 1360 additions and 186 deletions

View File

@@ -4,12 +4,15 @@
globals = { globals = {
"AsciiArt", "AsciiArt",
"Ascension", "Ascension",
"AscendDebugWindow",
"Audio", "Audio",
"AudioTestWindow", "AudioTestWindow",
"BriefIntroWindow", "BriefIntroWindow",
"CodeGenerator", "CodeGenerator",
"Config", "Config",
"CommuteGlitch",
"Context", "Context",
"ContextDebug",
"ContinuedWindow", "ContinuedWindow",
"ControlsWindow", "ControlsWindow",
"CreditsWindow", "CreditsWindow",
@@ -67,6 +70,7 @@ globals = {
"music", "music",
"musicator_generate_pattern", "musicator_generate_pattern",
"pix", "pix",
"poke4",
"print", "print",
"rect", "rect",
"rectb", "rectb",

148
CLAUDE.md Normal file
View File

@@ -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 (01000), 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` (07), `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
```

View File

@@ -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 # 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: Based on the analysis of `impostor.lua`, the following regularities and conventions should be followed for future modifications and development within this project:

View File

@@ -1,7 +1,6 @@
meta/meta.header.lua meta/meta.header.lua
init/init.module.lua init/init.module.lua
init/init.config.lua init/init.config.lua
init/init.ascension.lua
init/init.context.lua init/init.context.lua
system/system.util.lua system/system.util.lua
system/system.print.lua system/system.print.lua
@@ -10,6 +9,7 @@ system/system.textinput.lua
system/system.mouse.lua system/system.mouse.lua
system/system.asciiart.lua system/system.asciiart.lua
system/system.rle.lua system/system.rle.lua
logic/logic.ascension.lua
logic/logic.meter.lua logic/logic.meter.lua
logic/logic.focus.lua logic/logic.focus.lua
logic/logic.day.lua logic/logic.day.lua
@@ -17,14 +17,17 @@ logic/logic.timer.lua
logic/logic.trigger.lua logic/logic.trigger.lua
logic/logic.minigame.lua logic/logic.minigame.lua
logic/logic.glitch.lua logic/logic.glitch.lua
logic/logic.commute_glitch.lua
logic/logic.codegenerator.lua logic/logic.codegenerator.lua
logic/logic.discussion.lua logic/logic.discussion.lua
system/system.debug.lua
system/system.ui.lua system/system.ui.lua
audio/audio.manager.lua audio/audio.manager.lua
audio/audio.generator.lua audio/audio.generator.lua
audio/audio.songs.lua audio/audio.songs.lua
sprite/sprite.manager.lua sprite/sprite.manager.lua
sprite/sprite.norman.lua sprite/sprite.norman.lua
sprite/sprite.norman_echo.lua
sprite/sprite.sumphore.lua sprite/sprite.sumphore.lua
sprite/sprite.pizza_vendor.lua sprite/sprite.pizza_vendor.lua
sprite/sprite.dev_boy.lua sprite/sprite.dev_boy.lua
@@ -46,15 +49,17 @@ decision/decision.go_to_home.lua
decision/decision.go_to_toilet.lua decision/decision.go_to_toilet.lua
decision/decision.go_to_walking_to_office.lua decision/decision.go_to_walking_to_office.lua
decision/decision.go_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_walking_to_home.lua
decision/decision.go_to_sleep.lua decision/decision.go_to_sleep.lua
decision/decision.do_work.lua decision/decision.do_work.lua
decision/decision.have_a_coffee.lua decision/decision.have_a_coffee.lua
decision/decision.sumphore_discussion.lua decision/decision.sumphore_discussion.lua
decision/decision.eating_fast_food.lua decision/decision.talk_to_truth.lua
discussion/discussion.sumphore.lua discussion/discussion.sumphore.lua
discussion/discussion.coworker.lua discussion/discussion.coworker.lua
discussion/discussion.commute_glitch.lua
decision/decision.eating_fast_food.lua
discussion/discussion.pizza_vendor.lua discussion/discussion.pizza_vendor.lua
map/map.manager.lua map/map.manager.lua
map/map.bedroom.lua map/map.bedroom.lua
@@ -78,6 +83,7 @@ window/window.intro.brief.lua
window/window.menu.lua window/window.menu.lua
window/window.controls.lua window/window.controls.lua
window/window.audiotest.lua window/window.audiotest.lua
window/window.ascend_debug.lua
window/window.popup.lua window/window.popup.lua
window/window.minigame.mash.lua window/window.minigame.mash.lua
window/window.minigame.rhythm.lua window/window.minigame.rhythm.lua

View File

@@ -1,7 +1,8 @@
--- @section Audio --- @section Audio
Audio = { Audio = {
music_playing = nil music_playing = nil,
music_playing_tempo = nil,
} }
--- Stops current music. --- Stops current music.
@@ -9,13 +10,17 @@ Audio = {
function Audio.music_stop() function Audio.music_stop()
music() music()
Audio.music_playing = nil Audio.music_playing = nil
Audio.music_playing_tempo = nil
end end
--- Plays track, doesn't restart if already playing. --- Plays track at optional speed. Doesn't restart if track and speed are unchanged.
function Audio.music_play(track) --- @param track number Track index.
if Audio.music_playing ~= track then --- @param[opt] tempo number TIC-80 music speed override (-1 = default).
music(track) 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 = track
Audio.music_playing_tempo = tempo
end end
end end
@@ -47,9 +52,11 @@ function Audio.music_play_room_street_2() end
--- @within Audio --- @within Audio
function Audio.music_play_room_() end function Audio.music_play_room_() end
--- Plays room work music. --- Plays room work music. Speed scales with commute glitch level when active.
--- @within Audio --- @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. --- Plays activity work music.
--- @within Audio --- @within Audio

View File

@@ -1,6 +1,9 @@
Decision.register({ Decision.register({
id = "do_work", id = "do_work",
label = "Do Work", label = "Do Work",
condition = function()
return (not CommuteGlitch.is_active()) or (CommuteGlitch.is_active() and CommuteGlitch.get_level() <= 4)
end,
handle = function() handle = function()
Meter.hide() Meter.hide()
Util.go_to_screen_by_id("work") Util.go_to_screen_by_id("work")
@@ -12,7 +15,7 @@ Decision.register({
modes_for_ascension_levels[3] = "only_nothing" modes_for_ascension_levels[3] = "only_nothing"
modes_for_ascension_levels[4] = "normal" modes_for_ascension_levels[4] = "normal"
MinigameDDRWindow.start("game", "generated", { local ddr_config = {
on_win = function(game_context) on_win = function(game_context)
if (game_context.special_mode_condition and Context.ascension.level == 1) then if (game_context.special_mode_condition and Context.ascension.level == 1) then
Context.should_ascend = true Context.should_ascend = true
@@ -28,6 +31,11 @@ Decision.register({
Context.have_done_work_today = true Context.have_done_work_today = true
end, end,
special_mode = modes_for_ascension_levels[Ascension.get_level()] or "normal" 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, end,
}) })

View File

@@ -2,7 +2,9 @@ Decision.register({
id = "eating_fast_food", id = "eating_fast_food",
label = "Eat Fast Food", label = "Eat Fast Food",
condition = function() condition = function()
return Context.fast_food_eaten_today < 3 return
(not CommuteGlitch.is_active() and Context.fast_food_eaten_today < 3) or
(CommuteGlitch.is_active() and CommuteGlitch.get_level() <= 4)
end, end,
handle = function() handle = function()
Context.fast_food_approaching = true Context.fast_food_approaching = true

View File

@@ -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,
})

View File

@@ -2,9 +2,39 @@ Decision.register({
id = "go_to_home", id = "go_to_home",
label = "Go Home", label = "Go Home",
condition = function() 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 return Context.have_been_to_office and Context.have_done_work_today
end, end,
handle = function() 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") Util.go_to_screen_by_id("home")
end, end,
}) })

View File

@@ -1,7 +1,14 @@
Decision.register({ Decision.register({
id = "go_to_office", id = "go_to_office",
label = "Go to Office", label = "Go to Office",
condition = function()
return not (CommuteGlitch.is_active() and CommuteGlitch.get_level() >= 6)
end,
handle = function() handle = function()
if CommuteGlitch.is_active() then
CommuteGlitch.increment()
end
Util.go_to_screen_by_id("office") Util.go_to_screen_by_id("office")
end, end,
}) })

View File

@@ -1,6 +1,11 @@
Decision.register({ Decision.register({
id = "go_to_sleep", 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() condition = function()
return Context.have_been_to_office and Context.have_done_work_today return Context.have_been_to_office and Context.have_done_work_today
end, end,
@@ -12,11 +17,15 @@ Decision.register({
focus_center_y = (Config.screen.height / 2) - 18, focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0, focus_initial_radius = 0,
on_win = function() on_win = function()
if Ascension.get_level() == 8 then
Ascension.increase()
end
local ascended = Ascension.consume_increase() local ascended = Ascension.consume_increase()
local level = Ascension.get_level() local level = Ascension.get_level()
MysteriousManScreen.start({ MysteriousManScreen.start({
skip_text = not ascended, skip_text = not ascended,
text = ascended and MysteriousManScreen.get_text_for_level(level) or nil, text = ascended and MysteriousManScreen.get_text_for_level(level) or nil,
break_mode = level >= 9,
}) })
end, end,
}) })

View File

@@ -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,
})

View File

@@ -1,6 +1,12 @@
Decision.register({ Decision.register({
id = "go_to_walking_to_home", id = "go_to_walking_to_home",
label = "Walk 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() handle = function()
Util.go_to_screen_by_id("walking_to_home") Util.go_to_screen_by_id("walking_to_home")
end, end,

View File

@@ -1,6 +1,9 @@
Decision.register({ Decision.register({
id = "have_a_coffee", id = "have_a_coffee",
label = "Have a Coffee", label = "Have a Coffee",
condition = function()
return Ascension.get_level() < 8 and not CommuteGlitch.is_max()
end,
handle = function() handle = function()
local level = Ascension.get_level() local level = Ascension.get_level()
local disc_id = "coworker_disc_0" local disc_id = "coworker_disc_0"
@@ -15,6 +18,11 @@ Decision.register({
Discussion.start("coworker_disc_asc_6_" .. Context.glitch_conversation_count, "game") Discussion.start("coworker_disc_asc_6_" .. Context.glitch_conversation_count, "game")
return return
end 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 end
Discussion.start(disc_id, "game") Discussion.start(disc_id, "game")
end, end,

View File

@@ -3,10 +3,24 @@ Decision.register({
label = "Play Rhythm Game", label = "Play Rhythm Game",
handle = function() handle = function()
Meter.hide() 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_x = (Config.screen.width / 2) - 22,
focus_center_y = (Config.screen.height / 2) - 18, focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0, 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, end,
}) })

View File

@@ -6,12 +6,25 @@ Decision.register({
end end
return "Talk to the homeless guy" return "Talk to the homeless guy"
end, end,
condition = function()
return Ascension.get_level() < 8
end,
handle = function() 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 if not Context.have_met_sumphore then
Discussion.start("homeless_guy", "game") Discussion.start("homeless_guy", "game")
return return
end end
local level = Ascension.get_level()
if level >= 1 and level <= 5 then if level >= 1 and level <= 5 then
Discussion.start("sumphore_disc_asc_" .. level, "game") Discussion.start("sumphore_disc_asc_" .. level, "game")
@@ -21,8 +34,11 @@ Decision.register({
else else
Discussion.start("sumphore_disc_asc_6_waiting", "game") Discussion.start("sumphore_disc_asc_6_waiting", "game")
end end
elseif level == 7 then
local g = math.min(CommuteGlitch.get_level(), 7)
Discussion.start("sumphore_disc_cg_" .. g, "game")
else else
Discussion.start("homeless_guy", "game", 4) Discussion.start("sumphore_disc_asc_" .. level, "game")
end end
end, end,
}) })

View File

@@ -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,
})

View File

@@ -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 },
},
},
},
})

View File

@@ -124,24 +124,25 @@ Discussion.register({
on_end = Meter.apply_sumphore_discussion_reward, on_end = Meter.apply_sumphore_discussion_reward,
steps = { steps = {
{ {
question = "You saw something you weren't supposed to, didn't you.", question = "You saw the seams, didn't you. Good. That means the work is finally wearing thin.",
answers = { answers = {
{ label = "I don't know what you mean.", next_step = 2 }, { label = "Wearing thin how?", next_step = 2 },
{ label = "Maybe.", next_step = 2 }, { label = "Maybe.", next_step = 2 },
}, },
}, },
{ {
question = "The world around you has seams. Your coworkers slip sometimes. Say things that don't quite fit.", 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 = { answers = {
{ label = "They seem fine to me.", next_step = nil }, { label = "You want me to stop trying?", next_step = 3 },
{ label = "I've noticed something odd.", next_step = 3 }, { label = "I've noticed something odd.", next_step = 3 },
}, },
}, },
{ {
question = "Count those moments. Six of them should be enough to see the whole picture.", question = "Drain the work out of yourself. When that measure hits nothing, you'll see what was waiting behind it.",
answers = { answers = {
{ label = "Six of what, exactly?", next_step = nil, on_select = function() { label = "The work measure?", next_step = nil, on_select = function()
Meter.add("ism", 5) Meter.add("ism", 5)
Meter.add("wpm", -100)
end }, end },
{ label = "How would you know any of this?", next_step = nil }, { label = "How would you know any of this?", next_step = nil },
}, },

View File

@@ -61,12 +61,16 @@ function Context.initial_data()
fast_food_eaten_today = 0, fast_food_eaten_today = 0,
office_sprites = {}, office_sprites = {},
walking_to_office_sprites = {}, walking_to_office_sprites = {},
walking_to_home_sprites = {},
game = { game = {
current_screen = "home", current_screen = "home",
}, },
day_count = 1, day_count = 1,
delta_time = 0, delta_time = 0,
last_frame_time = 0, last_frame_time = 0,
commute_glitch_level = 0,
talked_to_norman_echo = false,
talked_to_true_sumphore = false,
glitch = { glitch = {
enabled = false, enabled = false,
state = "active", state = "active",

View File

@@ -21,7 +21,7 @@ local FADE_COLORS = nil
function Ascension.get_initial() function Ascension.get_initial()
_increased_this_cycle = false _increased_this_cycle = false
return { return {
level = 0, -- FYI: change this to test ascension levels without having to play through them level = 0,
} }
end end
@@ -145,6 +145,11 @@ function Ascension.draw_flash()
local flash_color = (pulse > 0.5) and Config.colors.white or Config.colors.light_grey local flash_color = (pulse > 0.5) and Config.colors.white or Config.colors.light_grey
rect(0, 0, sw, sh, flash_color) 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 if _flash_timer >= _flash_total then
_flash_active = false _flash_active = false
Ascension.start_fade() Ascension.start_fade()
@@ -167,3 +172,10 @@ end
function Ascension.is_flashing() function Ascension.is_flashing()
return _flash_active return _flash_active
end 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

View File

@@ -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

View File

@@ -168,6 +168,7 @@ function Meter.apply_ddr_reward(mistake_count)
if not Context or not Context.meters then return end if not Context or not Context.meters then return end
local max = Meter.get_max() local max = Meter.get_max()
local m = Context.meters local m = Context.meters
local wpm_was_high = m.wpm > 900
local wpm_pct, ism_pct, bm_pct local wpm_pct, ism_pct, bm_pct
if mistake_count == 0 then if mistake_count == 0 then
wpm_pct, ism_pct, bm_pct = -0.10, 0.05, 0.05 wpm_pct, ism_pct, bm_pct = -0.10, 0.05, 0.05
@@ -185,6 +186,10 @@ function Meter.apply_ddr_reward(mistake_count)
if bm_pct ~= 0 then if bm_pct ~= 0 then
Meter.add("bm", math.floor(max * bm_pct)) Meter.add("bm", math.floor(max * bm_pct))
end 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 = m.combo + 1
m.combo_timer = 0 m.combo_timer = 0
end end
@@ -312,3 +317,18 @@ function Meter.draw()
Ascension.draw(bar_x - 4, ascension_y, { spacing = 8 }) Ascension.draw(bar_x - 4, ascension_y, { spacing = 8 })
end 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

View File

@@ -5,14 +5,23 @@ Screen.register({
"go_to_toilet", "go_to_toilet",
"go_to_walking_to_office", "go_to_walking_to_office",
"go_to_sleep", "go_to_sleep",
"go_to_end",
}, },
init = function() init = function()
if CommuteGlitch.is_max() then
Audio.music_play_mystery()
Glitch.show()
else
Audio.music_play_room_work() Audio.music_play_room_work()
end
end, end,
background = "bedroom", background = "bedroom",
draw = function() 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) Sprite.draw_at("norman", 100, 80)
end end
end end

View File

@@ -80,6 +80,85 @@ local ASC_67_TEXT = [[
Not yet. 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 = { local ascension_texts = {
[1] = ASC_01_TEXT, [1] = ASC_01_TEXT,
[2] = ASC_12_TEXT, [2] = ASC_12_TEXT,
@@ -88,6 +167,8 @@ local ascension_texts = {
[5] = ASC_45_TEXT, [5] = ASC_45_TEXT,
[6] = ASC_56_TEXT, [6] = ASC_56_TEXT,
[7] = ASC_67_TEXT, [7] = ASC_67_TEXT,
[8] = ASC_78_TEXT,
[9] = ASC_89_TEXT,
} }
function MysteriousManScreen.get_text_for_level(level) function MysteriousManScreen.get_text_for_level(level)
@@ -108,6 +189,7 @@ local day_text_override = nil
local on_text_complete = nil local on_text_complete = nil
local show_mysterious_screen = true local show_mysterious_screen = true
local trigger_flash_on_wake = false local trigger_flash_on_wake = false
local break_mode = false
MysteriousManScreen.choices = { MysteriousManScreen.choices = {
{ {
@@ -212,6 +294,8 @@ function MysteriousManScreen.start(options)
text_y = Config.screen.height text_y = Config.screen.height
day_text_override = options.day_text day_text_override = options.day_text
on_text_complete = options.on_text_complete on_text_complete = options.on_text_complete
break_mode = options.break_mode or false
MysteriousManScreen.pending_end = false
Meter.hide() Meter.hide()
trigger_flash_on_wake = not options.skip_text trigger_flash_on_wake = not options.skip_text
if options.skip_text then if options.skip_text then
@@ -253,29 +337,29 @@ Screen.register({
lines = lines + 1 lines = lines + 1
end 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 = true
text_done_timer = TEXT_DONE_HOLD_SECONDS text_done_timer = TEXT_DONE_HOLD_SECONDS
-- If skipped by user, go to day state immediately -- If skipped by user, go to day state immediately
if Input.select() then if skippable and Input.select() then
MysteriousManScreen.go_to_day_state() MysteriousManScreen.go_to_day_state()
end end
end end
else else
text_done_timer = text_done_timer - Context.delta_time 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() MysteriousManScreen.go_to_day_state()
-- to be continued
if 4 <= Ascension.get_level() then
Window.set_current("continued")
end
end end
end end
elseif state == STATE_DAY then elseif state == STATE_DAY then
day_timer = day_timer - Context.delta_time day_timer = day_timer - Context.delta_time
if day_timer <= 0 or Input.select() then if day_timer <= 0 or (Ascension.get_level() ~= 8 and Input.select()) then
if trigger_flash_on_wake or Ascension.get_level() < 1 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() MysteriousManScreen.wake_up()
else else
state = STATE_CHOICE state = STATE_CHOICE
@@ -283,6 +367,48 @@ Screen.register({
end end
end end
elseif state == STATE_CHOICE then elseif state == STATE_CHOICE then
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.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_x = (Config.screen.width - 60) / 2
local menu_y = (Config.screen.height - 20) / 2 local menu_y = (Config.screen.height - 20) / 2
local confirmed local confirmed
@@ -297,9 +423,21 @@ Screen.register({
end end
end end
end end
end
end, end,
draw = function() 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() MysteriousManScreen.draw_background()
end end
@@ -322,9 +460,42 @@ Screen.register({
Config.colors.white Config.colors.white
) )
elseif state == STATE_CHOICE then elseif state == STATE_CHOICE then
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_x = (Config.screen.width - 60) / 2
local menu_y = (Config.screen.height - 20) / 2 local menu_y = (Config.screen.height - 20) / 2
UI.draw_menu(MysteriousManScreen.choices, selected_choice, menu_x, menu_y) UI.draw_menu(MysteriousManScreen.choices, selected_choice, menu_x, menu_y)
end end
end
end, end,
}) })

View File

@@ -5,9 +5,9 @@ Screen.register({
"do_work", "do_work",
"go_to_walking_to_home", "go_to_walking_to_home",
"have_a_coffee", "have_a_coffee",
"talk_to_truth",
}, },
init = function() init = function()
Audio.music_play_room_work()
Context.have_been_to_office = true Context.have_been_to_office = true
local possible_sprites = { local possible_sprites = {
@@ -37,14 +37,39 @@ Screen.register({
{x = -4 + 5 * 8, y = 9 * 8} {x = -4 + 5 * 8, y = 9 * 8}
} }
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) 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, end,
background = "office",
draw = function() draw = function()
if Window.get_current_id() == "game" then if Window.get_current_id() == "game" then
Sprite.draw_at("norman", 13 * 8, 9 * 8) 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
end end
}) })

View File

@@ -92,5 +92,14 @@ Screen.register({
local asc_x = math.floor((sw - asc_total_w) / 2) local asc_x = math.floor((sw - asc_total_w) / 2)
Ascension.draw(asc_x, asc_letter_y, { spacing = asc_spacing }) Ascension.draw(asc_x, asc_letter_y, { spacing = asc_spacing })
end 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, end,
}) })

View File

@@ -4,21 +4,83 @@ Screen.register({
decisions = { decisions = {
"go_to_home", "go_to_home",
"go_to_office", "go_to_office",
"sumphore_discussion",
"eating_fast_food", "eating_fast_food",
"go_to_truth",
}, },
init = function() 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, end,
background = "street",
draw = function() draw = function()
local w = Window.get_current_id() local w = Window.get_current_id()
if w == "game" or w == "discussion" then 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)
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) local norman_x = Context.fast_food_approaching and (19 * 8) or (7 * 8)
Sprite.draw_at("norman", norman_x, 3 * 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 if Context.fast_food_eaten_today < 3 then
Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8) Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8)
end end
Sprite.draw_at("dev_guard", 22 * 8, 2 * 8) 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
end end
}) })

View File

@@ -8,8 +8,6 @@ Screen.register({
"eating_fast_food", "eating_fast_food",
}, },
init = function() init = function()
Audio.music_play_room_work()
local possible_sprites = { local possible_sprites = {
"matrix_trinity", "matrix_trinity",
"matrix_neo", "matrix_neo",
@@ -30,22 +28,35 @@ Screen.register({
{x = 27 * 8, y = 11 * 8}, {x = 27 * 8, y = 11 * 8},
} }
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 = 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, end,
background = "street",
update = function() update = function()
end, end,
draw = function() draw = function()
local w = Window.get_current_id() local w = Window.get_current_id()
if w == "game" or w == "discussion" then if w == "game" or w == "discussion" then
local norman_x = Context.fast_food_approaching and (19 * 8) or (7 * 8) 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) Sprite.draw_at("norman", norman_x, 3 * 8)
if show_sumphore then
Sprite.draw_at("sumphore", 9 * 8, 2 * 8) Sprite.draw_at("sumphore", 9 * 8, 2 * 8)
end
if Context.fast_food_eaten_today < 3 then if Context.fast_food_eaten_today < 3 then
Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8) Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8)
end end
Sprite.draw_at("dev_guard", 22 * 8, 3 * 8)
Sprite.draw_at("dev_guard", 22 * 8, 3 * 8)
Sprite.draw_list(Context.walking_to_office_sprites) Sprite.draw_list(Context.walking_to_office_sprites)
end end
end end

View File

@@ -80,7 +80,7 @@ function Sprite.draw_list(sprite_list)
for _, sprite_info in ipairs(sprite_list) do for _, sprite_info in ipairs(sprite_list) do
local sprite_data = _sprites[sprite_info.id] local sprite_data = _sprites[sprite_info.id]
if not sprite_data then 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 else
draw_sprite_instance(sprite_data, sprite_info) draw_sprite_instance(sprite_data, sprite_info)
end end

View File

@@ -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 },
}
})

View File

@@ -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

View File

@@ -9,53 +9,79 @@ function UI.draw_top_bar(title)
end end
--- Draws a menu. --- Draws a menu.
--- Items with header=true are drawn as non-selectable section headers in small font.
--- @within UI --- @within UI
--- @param items table A table of menu items.<br/> --- @param items table A table of menu items.<br/>
--- @param selected_item number The index of the currently selected item.<br/> --- @param selected_item number The index of the currently selected item.<br/>
--- @param x number The x-coordinate for the menu (ignored if centered is true).<br/> --- @param x number The x-coordinate for the menu (ignored if centered is true).<br/>
--- @param y number The y-coordinate for the menu.<br/> --- @param y number The y-coordinate for the menu.<br/>
--- @param[opt] centered boolean Whether to center the menu block horizontally. Defaults to false.<br/> --- @param[opt] centered boolean Whether to center the menu block horizontally. Defaults to false.<br/>
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.<br/>
--- @param[opt] visible_count number Maximum number of items to draw. Defaults to all.<br/>
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 if centered then
local max_w = 0 local max_w = 0
for _, item in ipairs(items) do for _, item in ipairs(items) do
if not item.header then
local w = print(item.label, 0, -10, 0, false, 1, false) local w = print(item.label, 0, -10, 0, false, 1, false)
if w > max_w then max_w = w end if w > max_w then max_w = w end
end end
end
x = (Config.screen.width - max_w) / 2 x = (Config.screen.width - max_w) / 2
end end
for i, item in ipairs(items) do local current_y = y
local current_y = y + (i-1)*10 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 if i == selected_item then
Print.text(">", x - 8, current_y, Config.colors.light_blue) Print.text(">", x - 8, current_y, Config.colors.light_blue)
end end
Print.text(item.label, x, current_y, Config.colors.light_blue) Print.text(item.label, x, current_y, Config.colors.light_blue)
current_y = current_y + 10
end
end end
end end
--- Updates menu selection. --- Updates menu selection. Skips items with header=true during navigation.
--- @within UI --- @within UI
--- @param items table A table of menu items.<br/> --- @param items table A table of menu items.<br/>
--- @param selected_item number The current index of the selected item.<br/> --- @param selected_item number The current index of the selected item.<br/>
--- @param[opt] x number Menu x position (required for mouse support).<br/> --- @param[opt] x number Menu x position (required for mouse support).<br/>
--- @param[opt] y number Menu y position (required for mouse support).<br/> --- @param[opt] y number Menu y position (required for mouse support).<br/>
--- @param[opt] centered boolean Whether the menu is centered horizontally.<br/> --- @param[opt] centered boolean Whether the menu is centered horizontally.<br/>
--- @param[opt] scroll_offset number 0-based index of the first visible item. Defaults to 0.<br/>
--- @param[opt] visible_count number Number of visible items (for mouse hit zones). Defaults to all.<br/>
--- @return number selected_item The updated index of the selected item. --- @return number selected_item The updated index of the selected item.
--- @return boolean mouse_confirmed True if the user clicked on a menu 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 if Input.up() then
Audio.sfx_beep() Audio.sfx_beep()
selected_item = selected_item - 1 local prev = (selected_item - 2 + n) % n + 1
if selected_item < 1 then selected_item = find_selectable(prev, -1)
selected_item = #items
end
elseif Input.down() then elseif Input.down() then
Audio.sfx_beep() Audio.sfx_beep()
selected_item = selected_item + 1 local next_i = selected_item % n + 1
if selected_item > #items then selected_item = find_selectable(next_i, 1)
selected_item = 1
end
end end
if x ~= nil and y ~= nil then if x ~= nil and y ~= nil then
@@ -63,16 +89,24 @@ function UI.update_menu(items, selected_item, x, y, centered)
if centered then if centered then
local max_w = 0 local max_w = 0
for _, item in ipairs(items) do for _, item in ipairs(items) do
if not item.header then
local w = print(item.label, 0, -10, 0, false, 1, false) local w = print(item.label, 0, -10, 0, false, 1, false)
if w > max_w then max_w = w end if w > max_w then max_w = w end
end end
end
menu_x = (Config.screen.width - max_w) / 2 menu_x = (Config.screen.width - max_w) / 2
end end
for i, _ in ipairs(items) do local current_y = y
if Mouse.zone({ x = menu_x - 8, y = y + (i-1) * 10, w = Config.screen.width, h = 10 }) then 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 return i, true
end end
end end
current_y = current_y + step
end
end end
return selected_item, false return selected_item, false

View File

@@ -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

View File

@@ -33,7 +33,7 @@ local RASTER_Y_BOT = 110
local AUTHORS = { local AUTHORS = {
"Mr. Zero - Zsolt Tasnadi", "Mr. Zero - Zsolt Tasnadi",
"Mr. One - Balazs Tari", "Mr. One - Ballz",
"Mr. Two - Zoltan Timar", "Mr. Two - Zoltan Timar",
"Mr. Three - Bela Mezo", "Mr. Three - Bela Mezo",
} }

View File

@@ -5,31 +5,6 @@
function EndWindow.draw() function EndWindow.draw()
cls(Config.colors.black) 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 y = 40
for _, line in ipairs(lines) do
Print.text_center(line, Config.screen.width / 2, y, Config.colors.white)
y = y + 10
end
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
local yes_text = (Context._end.selection == 1 and "> YES" or " YES")
local no_text = (Context._end.selection == 2 and "> NO" or " NO")
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
local cx = Config.screen.width / 2 local cx = Config.screen.width / 2
local name = Context.player_name or "AAA" local name = Context.player_name or "AAA"
local code = CodeGenerator.encrypt(name) local code = CodeGenerator.encrypt(name)
@@ -49,45 +24,14 @@ function EndWindow.draw()
line(20, 110, 219, 110, Config.colors.dark_grey) line(20, 110, 219, 110, Config.colors.dark_grey)
Print.text_center("Press Z to return to menu", cx, 116, Config.colors.dark_grey) Print.text_center("Press Z to return to menu", cx, 116, Config.colors.dark_grey)
end
end end
--- Updates the end screen logic. --- Updates the end screen logic.
--- @within EndWindow --- @within EndWindow
function EndWindow.update() 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 if Input.select() then
Context.reset()
Window.set_current("menu") Window.set_current("menu")
MenuWindow.refresh_menu_items() MenuWindow.refresh_menu_items()
end end
end
end end

View File

@@ -6,7 +6,8 @@ local function draw_game_scene(underlay_draw)
local screen = Screen.get_by_id(Context.game.current_screen) local screen = Screen.get_by_id(Context.game.current_screen)
if not screen then return end if not screen then return end
if screen.background then 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 elseif screen.background_color then
rect(0, 0, Config.screen.width, Config.screen.height, screen.background_color) rect(0, 0, Config.screen.width, Config.screen.height, screen.background_color)
end end

View File

@@ -5,6 +5,7 @@ local _anim = 0
local _menu_max_w = 0 local _menu_max_w = 0
local ANIM_SPEED = 2.5 local ANIM_SPEED = 2.5
local HEADER_H = 28 local HEADER_H = 28
MenuWindow._scroll_offset = 0
--- Calculates the animated x position of the menu block. --- Calculates the animated x position of the menu block.
--- @within MenuWindow --- @within MenuWindow
@@ -45,6 +46,17 @@ function MenuWindow.draw_norman()
spr(305, nx + 32, ny + 64, Config.colors.transparent, 4) spr(305, nx + 32, ny + 64, Config.colors.transparent, 4)
end 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. --- Draws the menu window.
--- @within MenuWindow --- @within MenuWindow
function MenuWindow.draw() function MenuWindow.draw()
@@ -56,9 +68,19 @@ function MenuWindow.draw()
MenuWindow.draw_norman() MenuWindow.draw_norman()
end end
local menu_h = #_menu_items * 10 local menu_x = MenuWindow.calc_menu_x()
local y = HEADER_H + math.floor((Config.screen.height - HEADER_H - 10 - menu_h) / 2) local arrow_cx = math.floor(menu_x + _menu_max_w / 2)
UI.draw_menu(_menu_items, Context.current_menu_item, MenuWindow.calc_menu_x(), y, false) 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_text = "TTG"
local ttg_w = print(ttg_text, 0, -10, 0, false, 1, false) 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) _anim = math.min(1, _anim + ANIM_SPEED * Context.delta_time)
end end
local menu_h = #_menu_items * 10 local menu_x = MenuWindow.calc_menu_x()
local y = HEADER_H + math.floor((Config.screen.height - HEADER_H - 10 - menu_h) / 2) local y = HEADER_H + math.floor((Config.screen.height - HEADER_H - 50) / 2)
if _click_timer > 0 then if _click_timer > 0 then
_click_timer = _click_timer - Context.delta_time _click_timer = _click_timer - Context.delta_time
@@ -87,8 +109,9 @@ function MenuWindow.update()
return return
end 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 Context.current_menu_item = new_item
MenuWindow.ensure_visible()
if mouse_confirmed then if mouse_confirmed then
Audio.sfx_select() Audio.sfx_select()
@@ -179,6 +202,19 @@ function MenuWindow.ddr_test()
MinigameDDRWindow.start("menu", "generated", { special_mode = "only_nothing" }) MinigameDDRWindow.start("menu", "generated", { special_mode = "only_nothing" })
end 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. --- Refreshes the list of menu items based on current game state.
--- @within MenuWindow --- @within MenuWindow
function MenuWindow.refresh_menu_items() function MenuWindow.refresh_menu_items()
@@ -193,9 +229,12 @@ function MenuWindow.refresh_menu_items()
table.insert(_menu_items, {label = "Credits", decision = MenuWindow.credits}) table.insert(_menu_items, {label = "Credits", decision = MenuWindow.credits})
if Context.test_mode then 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 = "Audio Test", decision = MenuWindow.audio_test})
table.insert(_menu_items, {label = "To Be Continued...", decision = MenuWindow.continued}) 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 = "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 = "End Screen", decision = MenuWindow.end_screen})
table.insert(_menu_items, {label = "Player Name", decision = MenuWindow.player_name}) table.insert(_menu_items, {label = "Player Name", decision = MenuWindow.player_name})
end end
@@ -204,11 +243,14 @@ function MenuWindow.refresh_menu_items()
_menu_max_w = 0 _menu_max_w = 0
for _, item in ipairs(_menu_items) do for _, item in ipairs(_menu_items) do
if not item.header then
local w = print(item.label, 0, -10, 0, false, 1, false) local w = print(item.label, 0, -10, 0, false, 1, false)
if w > _menu_max_w then _menu_max_w = w end if w > _menu_max_w then _menu_max_w = w end
end end
end
Context.current_menu_item = 1 Context.current_menu_item = 1
MenuWindow._scroll_offset = 0
_click_timer = 0 _click_timer = 0
_anim = 0 _anim = 0
end end

View File

@@ -22,6 +22,9 @@ Window.register("controls", ControlsWindow)
AudioTestWindow = {} AudioTestWindow = {}
Window.register("audiotest", AudioTestWindow) Window.register("audiotest", AudioTestWindow)
AscendDebugWindow = {}
Window.register("ascend_debug", AscendDebugWindow)
MinigameButtonMashWindow = {} MinigameButtonMashWindow = {}
Window.register("minigame_button_mash", MinigameButtonMashWindow) Window.register("minigame_button_mash", MinigameButtonMashWindow)