Compare commits

..

23 Commits

Author SHA1 Message Date
6a99ad76b8 set version to 1.0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-30 17:54:47 +02:00
5094ba2b9c Merge pull request 'feature/IMP-112-ascension-8-9' (#59) from feature/IMP-112-ascension-8-9 into develop
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #59
2026-04-29 21:27:23 +00:00
Zoltan Timar
44a7d10037 lint fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-29 23:25:54 +02:00
Zoltan Timar
1b991f1f62 Merge branch 'feature/IMP-112-ascension-8-9' of https://git.teletype.hu/games/impostor into feature/IMP-112-ascension-8-9
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
# Conflicts:
#	inc/screen/screen.mysterious_man.lua
2026-04-29 23:21:42 +02:00
Zoltan Timar
0d569ccf56 correcting bugs and texts 2026-04-29 23:20:04 +02:00
77d6f95721 ascension flash label, scrollable menu
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-29 23:15:22 +02:00
e2cd3d6dc7 linter fixes 2026-04-29 22:53:40 +02:00
mr.one
92a217c389 fixes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-29 20:45:03 +02:00
Zoltan Timar
3356d837c2 wpm modifies things
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-29 17:20:07 +02:00
Zoltan Timar
fe6f39e748 correction in mm text
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-29 16:50:06 +02:00
Zoltan Timar
b5596bbbe0 bugfixes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-29 16:41:18 +02:00
Zoltan Timar
3e31398d9d added 89 ascension
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-29 16:20:11 +02:00
Zoltan Timar
395208f814 Merge branch 'develop' into feature/ascension_7_8
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
# Conflicts:
#	impostor.inc
#	inc/decision/decision.have_a_coffee.lua
#	inc/decision/decision.sumphore_discussion.lua
#	inc/screen/screen.mysterious_man.lua
#	inc/screen/screen.walking_to_home.lua
#	inc/screen/screen.walking_to_office.lua
#	inc/window/window.menu.lua
2026-04-29 10:18:30 +02:00
mr.one
4cc0025f5e sort-of progress, lots of bugs
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-28 23:42:34 +02:00
0ea538ce24 new end window
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-28 01:07:37 +02:00
629a27cfa8 Merge pull request 'feature/imp-112-dialogues-and-asc-4-7' (#58) from feature/imp-112-dialogues-and-asc-4-7 into develop
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #58
2026-04-27 23:02:51 +00:00
Zoltan Timar
53c95bf17d lint fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-28 01:01:15 +02:00
Zoltan Timar
9fc659c819 lint fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-28 01:00:23 +02:00
mr.one
07bc598ae9 Added new menu to start at ASCENSION N when in test_mode.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-28 00:55:35 +02:00
Zoltan Timar
133980f569 Merge branch 'develop' into feature/imp-112-dialogues-and-asc-4-7
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-28 00:52:22 +02:00
Zoltan Timar
efe903110b feat: added ascension logic 4-7, added new decision (eating fast food), indicating meter changes better, added discussions (needs more work, but meh ... fine like this)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-28 00:51:42 +02:00
73b2a88437 alphabetize luacheck config
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-27 23:33:07 +02:00
2812b0c17a Merge pull request 'codegenerator' (#57) from codegenerator into develop
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #57
2026-04-27 21:00:21 +00:00
43 changed files with 1926 additions and 243 deletions

View File

@@ -2,79 +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",
"CreditsWindow",
"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",
"PlayerNameWindow",
"TextInput",
"CodeGenerator",
"CreditsWindow",
"Focus",
"GameOverWindow",
"mset",
"mget",
"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",
}

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
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
init/init.module.lua
init/init.config.lua
init/init.ascension.lua
init/init.context.lua
system/system.util.lua
system/system.print.lua
@@ -10,6 +9,7 @@ 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
@@ -17,14 +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
@@ -46,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
@@ -76,6 +83,7 @@ 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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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({
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,

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -89,6 +89,126 @@ 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,

View File

@@ -53,16 +53,24 @@ function Context.initial_data()
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",

View File

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

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

@@ -36,6 +36,8 @@ Day.register_handler(function()
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()

View File

@@ -8,6 +8,8 @@ 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
@@ -16,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. </br>
@@ -95,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.
@@ -109,7 +123,18 @@ function Meter.add(key, amount)
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
@@ -143,6 +168,7 @@ 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
@@ -160,6 +186,10 @@ function Meter.apply_ddr_reward(mistake_count)
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
@@ -258,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

View File

@@ -4,5 +4,5 @@
-- desc: Life of a programmer
-- site: https://git.teletype.hu/games/impostor
-- license: MIT License
-- version: 1.0-beta3
-- version: 1.0
-- script: lua

View File

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

View File

@@ -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 = {
{
@@ -146,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.
@@ -170,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
@@ -211,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
@@ -241,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
@@ -280,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,
})

View File

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

View File

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

View File

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

View File

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

View File

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

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
--- 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.<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 y number The y-coordinate for the menu.<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
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.<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] y number Menu y position (required for mouse support).<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 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

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 = {
"Mr. Zero - Zsolt Tasnadi",
"Mr. One - Balazs Tari",
"Mr. One - Ballz",
"Mr. Two - Zoltan Timar",
"Mr. Three - Bela Mezo",
}

View File

@@ -5,79 +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
local cx = Config.screen.width / 2
local name = Context.player_name or "AAA"
local code = CodeGenerator.encrypt(name)
Print.text_center("Game over -- good ending.", cx, 40, Config.colors.light_blue)
Print.text_center("Congrats " .. name .. "!", cx, 54, Config.colors.white)
Print.text_center("Your personal code:", cx, 72, Config.colors.light_grey)
Print.text_center(code, cx, 84, Config.colors.white, false, 3)
Print.text_center("Write it down!", cx, 112, Config.colors.item)
Print.text_center("Press Z to return to menu", cx, 126, Config.colors.dark_grey)
end
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

View File

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

View File

@@ -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()
@@ -179,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()
@@ -193,9 +229,12 @@ function MenuWindow.refresh_menu_items()
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
@@ -204,11 +243,14 @@ function MenuWindow.refresh_menu_items()
_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

View File

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