Compare commits

..

29 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
48d65424a0 Merge branch 'develop' into codegenerator
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-27 22:58:03 +02:00
47e5e0ca17 codegenerator
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-27 22:54:50 +02:00
b5dd0cc686 Merge pull request 'feature/imp-139-gameover-and-meters' (#56) from feature/imp-139-gameover-and-meters into develop
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #56
2026-04-27 20:39:48 +00:00
Zoltan Timar
340d0ff78c Merge branch 'develop' into feature/imp-139-gameover-and-meters
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-27 22:28:08 +02:00
Zoltan Timar
7df42dd2cd feat: added game over screen, fixed bar filling on ddr, applied tamagochi logic to game
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-27 22:26:16 +02:00
5d4aa1ee26 Merge pull request 'credit window' (#55) from credits-window into develop
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #55
2026-04-27 19:23:47 +00:00
52 changed files with 2511 additions and 260 deletions

View File

@@ -2,74 +2,83 @@
-- Configuration for luacheck -- Configuration for luacheck
globals = { globals = {
"Focus",
"Day",
"Timer",
"Glitch",
"Trigger",
"Discussion",
"Util",
"Decision",
"Screen",
"Sprite",
"UI",
"Print",
"Input",
"Audio",
"AsciiArt", "AsciiArt",
"Ascension", "Ascension",
"Config", "AscendDebugWindow",
"Context", "Audio",
"Meter",
"Minigame",
"Window",
"ContinuedWindow",
"CreditsWindow",
"TTGIntroWindow",
"BriefIntroWindow",
"TitleIntroWindow",
"MenuWindow",
"GameWindow",
"PopupWindow",
"ControlsWindow",
"AudioTestWindow", "AudioTestWindow",
"MinigameButtonMashWindow", "BriefIntroWindow",
"MinigameRhythmWindow", "CodeGenerator",
"MinigameDDRWindow", "Config",
"MysteriousManScreen", "CommuteGlitch",
"Context",
"ContextDebug",
"ContinuedWindow",
"ControlsWindow",
"CreditsWindow",
"Day",
"Decision",
"Discussion",
"DiscussionWindow", "DiscussionWindow",
"EndWindow", "EndWindow",
"mset", "Focus",
"mget", "GameOverWindow",
"GameWindow",
"Glitch",
"Input",
"Map",
"MapBedroom",
"MenuWindow",
"Meter",
"Minigame",
"MinigameButtonMashWindow",
"MinigameDDRWindow",
"MinigameRhythmWindow",
"Mouse",
"MysteriousManScreen",
"PlayerNameWindow",
"PopupWindow",
"Print",
"RLE",
"Screen",
"Songs",
"Sprite",
"TIC",
"TTGIntroWindow",
"TextInput",
"Timer",
"TitleIntroWindow",
"Trigger",
"UI",
"Util",
"WalkingToOfficeScreen",
"Window",
"beats_to_pattern",
"btnp", "btnp",
"circb",
"circ",
"cls",
"exit",
"frame_from_beat",
"index_menu",
"keyp", "keyp",
"line",
"map",
"mouse",
"mget",
"mset",
"music", "music",
"sfx", "musicator_generate_pattern",
"spr", "pix",
"poke4",
"print",
"rect", "rect",
"rectb", "rectb",
"circ", "sfx",
"circb", "spr",
"cls",
"tri",
"pix",
"line",
"Songs",
"frame_from_beat",
"beats_to_pattern",
"MapBedroom",
"TIC",
"exit",
"trace",
"index_menu",
"Map",
"map",
"time", "time",
"RLE", "trace",
"mouse", "tri",
"Mouse",
"print",
"musicator_generate_pattern",
} }

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,14 +1,15 @@
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
system/system.input.lua system/system.input.lua
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
@@ -16,13 +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.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
@@ -44,14 +49,18 @@ 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.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
map/map.manager.lua map/map.manager.lua
map/map.bedroom.lua map/map.bedroom.lua
map/map.street.lua map/map.street.lua
@@ -66,6 +75,7 @@ screen/screen.work.lua
screen/screen.mysterious_man.lua screen/screen.mysterious_man.lua
window/window.manager.lua window/window.manager.lua
window/window.register.lua window/window.register.lua
window/window.gameover.lua
window/window.end.lua window/window.end.lua
window/window.intro.title.lua window/window.intro.title.lua
window/window.intro.ttg.lua window/window.intro.ttg.lua
@@ -73,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
@@ -80,6 +91,7 @@ window/window.minigame.ddr.lua
window/window.discussion.lua window/window.discussion.lua
window/window.continued.lua window/window.continued.lua
window/window.credits.lua window/window.credits.lua
window/window.player_name.lua
window/window.game.lua window/window.game.lua
system/system.main.lua system/system.main.lua
meta/meta.assets.lua meta/meta.assets.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")
@@ -10,8 +13,9 @@ Decision.register({
modes_for_ascension_levels[1] = "only_special" modes_for_ascension_levels[1] = "only_special"
modes_for_ascension_levels[2] = "only_left" modes_for_ascension_levels[2] = "only_left"
modes_for_ascension_levels[3] = "only_nothing" 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) 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
@@ -19,8 +23,6 @@ Decision.register({
Context.should_ascend = true Context.should_ascend = true
elseif (game_context.special_mode_condition and Context.ascension.level == 3) then elseif (game_context.special_mode_condition and Context.ascension.level == 3) then
Context.should_ascend = true Context.should_ascend = true
elseif (game_context.special_mode_condition and Context.ascension.level == 4) then
Context.should_ascend = true
end end
Meter.show() Meter.show()
@@ -28,7 +30,12 @@ Decision.register({
Window.set_current("game") Window.set_current("game")
Context.have_done_work_today = true Context.have_done_work_today = true
end, 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, 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", 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

@@ -1,7 +1,23 @@
local function apply_home_toilet_meter_delta()
local max = Meter.get_max()
Meter.add("bm", -math.floor(max * 0.15))
Meter.add("ism", -math.floor(max * 0.10))
Meter.add("wpm", math.floor(max * 0.05))
end
Decision.register({ Decision.register({
id = "go_to_toilet", id = "go_to_toilet",
label = "Go to Toilet", label = "Go to Toilet",
handle = function() handle = function()
if not Context.have_done_work_today and not Context.toilet_meters_today_morning then
apply_home_toilet_meter_delta()
Context.toilet_meters_today_morning = true
elseif Context.have_been_to_office
and Context.have_done_work_today
and not Context.toilet_meters_today_evening then
apply_home_toilet_meter_delta()
Context.toilet_meters_today_evening = true
end
Util.go_to_screen_by_id("toilet") Util.go_to_screen_by_id("toilet")
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,14 +1,29 @@
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"
-- TODO: Add more discussions for levels above 3 if level >= 1 and level <= 5 then
if level >= 1 and level <= 3 then
local suffix = Context.have_done_work_today and ("_asc_" .. level) or ("_" .. level) local suffix = Context.have_done_work_today and ("_asc_" .. level) or ("_" .. level)
disc_id = "coworker_disc" .. suffix 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 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,18 +6,39 @@ 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()
-- TODO: Add more discussions for levels above 3 if level >= 1 and level <= 5 then
if level >= 1 and level <= 3 then
Discussion.start("sumphore_disc_asc_" .. level, "game") 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 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

@@ -1,5 +1,6 @@
Discussion.register({ Discussion.register({
id = "coworker_disc_0", id = "coworker_disc_0",
on_end = Meter.apply_coworker_discussion_reward,
steps = { steps = {
{ {
question = "Good morning Normal, enjoying your coffee as usual, huh?", question = "Good morning Normal, enjoying your coffee as usual, huh?",
@@ -18,6 +19,7 @@ Discussion.register({
Discussion.register({ Discussion.register({
id = "coworker_disc_1", id = "coworker_disc_1",
on_end = Meter.apply_coworker_discussion_reward,
steps = { steps = {
{ {
question = "Norman, you look confused, what's up?", question = "Norman, you look confused, what's up?",
@@ -36,6 +38,7 @@ Discussion.register({
Discussion.register({ Discussion.register({
id = "coworker_disc_asc_1", id = "coworker_disc_asc_1",
on_end = Meter.apply_coworker_discussion_reward,
steps = { steps = {
{ {
question = "Normann you look weird and unfocused. You are usually locked in and not like this, what's up?", question = "Normann you look weird and unfocused. You are usually locked in and not like this, what's up?",
@@ -54,6 +57,7 @@ Discussion.register({
Discussion.register({ Discussion.register({
id = "coworker_disc_2", id = "coworker_disc_2",
on_end = Meter.apply_coworker_discussion_reward,
steps = { steps = {
{ {
question = "Hey Norman, do you have new socks on? That's a weird color!", question = "Hey Norman, do you have new socks on? That's a weird color!",
@@ -79,6 +83,7 @@ Discussion.register({
Discussion.register({ Discussion.register({
id = "coworker_disc_asc_2", id = "coworker_disc_asc_2",
on_end = Meter.apply_coworker_discussion_reward,
steps = { steps = {
{ {
question = "Normann, are you ok? You were doing weird things while typing?", question = "Normann, are you ok? You were doing weird things while typing?",
@@ -97,6 +102,7 @@ Discussion.register({
Discussion.register({ Discussion.register({
id = "coworker_disc_3", id = "coworker_disc_3",
on_end = Meter.apply_coworker_discussion_reward,
steps = { steps = {
{ {
question = "You look so happy, did you catch a bull or something?", question = "You look so happy, did you catch a bull or something?",
@@ -120,6 +126,7 @@ Discussion.register({
}) })
Discussion.register({ Discussion.register({
id = "coworker_disc_asc_3", id = "coworker_disc_asc_3",
on_end = Meter.apply_coworker_discussion_reward,
steps = { steps = {
{ {
question = "Normal, you should take a break, you don't live up to your name today", question = "Normal, you should take a break, you don't live up to your name today",
@@ -134,4 +141,227 @@ Discussion.register({
}, },
}, },
}, },
})
Discussion.register({
id = "coworker_disc_4",
on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Normaaan! Same spot, same cup, same time. You're like a statue that drinks coffee!",
answers = {
{ label = "I'm a person.", next_step = 2 },
{ label = "Yep. Still here.", next_step = 2 },
},
},
{
question = "I love that about you! So reliable! If the coffee machine breaks we still have Norman!",
answers = {
{ label = "Please don't.", next_step = nil },
},
},
},
})
Discussion.register({
id = "coworker_disc_asc_4",
on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Norman, you were seriously locked in today. What on earth were you building?",
answers = {
{ label = "Just some things.", next_step = 2 },
{ label = "Nothing important.", next_step = 2 },
},
},
{
question = "So modest! I heard the seniors literally whispering your name!",
answers = {
{ label = "Concerning.", next_step = 3 },
{ label = "That's... fine.", next_step = 3 },
},
},
{
question = "You should celebrate! Coffee's on me tomorrow!",
answers = {
{ label = "Already have one.", next_step = nil },
},
},
},
})
Discussion.register({
id = "coworker_disc_5",
on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Morning! Funny thought — I feel like we do this exact same thing every single day!",
answers = {
{ label = "We do.", next_step = 2 },
{ label = "Yes. We do.", next_step = 2 },
},
},
{
question = "Ha! Routine is such a comfort, right? Same coffee, same smile, same everything!",
answers = {
{ label = "Sure. Very comforting.", next_step = nil },
},
},
},
})
Discussion.register({
id = "coworker_disc_asc_5",
on_end = Meter.apply_coworker_discussion_reward,
steps = {
{
question = "Norman! You were staring right THROUGH your screen today. What was going on up there?",
answers = {
{ label = "Coffee was cold.", next_step = 2 },
{ label = "Maybe I was.", next_step = 2 },
},
},
{
question = "Were you meditating? Or doing your intense bug-stare thing?",
answers = {
{ label = "Something like that.", next_step = 3 },
{ label = "Bug stare thing?", next_step = 3 },
},
},
{
question = "You're always somewhere else in your head, Norman. Must be nice up there!",
answers = {
{ label = "It's complicated.", next_step = nil },
},
},
},
})
local function _glitch_on_end()
Meter.apply_coworker_discussion_reward()
Context.glitch_conversation_count = Context.glitch_conversation_count + 1
Glitch.hide()
end
Discussion.register({
id = "coworker_disc_asc_6_1",
on_end = _glitch_on_end,
steps = {
{
question = "Hey Norman, good morning! Good morning! Good morning! ...Sorry. I don't know why I keep saying that.",
answers = {
{ label = "Are you feeling ok?", next_step = 2 },
},
},
{
question = "Good morning. Good morning. Good— I can't stop. Why can't I stop?",
answers = {
{ label = "...", next_step = nil },
},
},
},
})
Discussion.register({
id = "coworker_disc_asc_6_2",
on_end = _glitch_on_end,
steps = {
{
question = "Hey... Marcus. How's it going?",
answers = {
{ label = "My name is Norman.", next_step = 2 },
},
},
{
question = "Right, sorry. Marcus. You look tired today.",
answers = {
{ label = "Norman. It's Norman.", next_step = 3 },
},
},
{
question = "Sure, sure. You should get some rest, Marcus.",
answers = {
{ label = "...", next_step = nil },
},
},
},
})
Discussion.register({
id = "coworker_disc_asc_6_3",
on_end = _glitch_on_end,
steps = {
{
question = "We've had this conversation before, haven't we? Exact same words. Same coffee. Same spot.",
answers = {
{ label = "I don't think so.", next_step = 2 },
{ label = "Maybe.", next_step = 2 },
},
},
{
question = "Every day. I always say the same thing and you always say that. It's very strange.",
answers = {
{ label = "That's just routine.", next_step = nil },
},
},
},
})
Discussion.register({
id = "coworker_disc_asc_6_4",
on_end = _glitch_on_end,
steps = {
{
question = "Do you ever look at the walls here? Really look? Sometimes they don't feel... solid.",
answers = {
{ label = "They're just walls.", next_step = 2 },
{ label = "I know what you mean.", next_step = 2 },
},
},
{
question = "Like they're only there because we expect them to be. Like set dressing. Does any of this feel load-bearing to you?",
answers = {
{ label = "I need more coffee.", next_step = nil },
},
},
},
})
Discussion.register({
id = "coworker_disc_asc_6_5",
on_end = _glitch_on_end,
steps = {
{
question = "Norman, I'm not supposed to— I mean. How are you doing today?",
answers = {
{ label = "What weren't you supposed to say?", next_step = 2 },
},
},
{
question = "...",
answers = {
{ label = "Hello?", next_step = nil },
},
},
},
})
Discussion.register({
id = "coworker_disc_asc_6_6",
on_end = _glitch_on_end,
steps = {
{
question = "Forget it. You won't remember this conversation anyway. None of us do.",
answers = {
{ label = "What do you mean?", next_step = 2 },
{ label = "That's a strange thing to say.", next_step = 2 },
},
},
{
question = "Tomorrow you'll come back and it'll be like this never happened. Same coffee. Same office. Same Norman.",
answers = {
{ label = "...", next_step = nil },
},
},
},
}) })

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

@@ -1,5 +1,6 @@
Discussion.register({ Discussion.register({
id = "sumphore_disc_asc_1", id = "sumphore_disc_asc_1",
on_end = Meter.apply_sumphore_discussion_reward,
steps = { steps = {
{ {
question = "Are you still seeking the ox?", question = "Are you still seeking the ox?",
@@ -19,6 +20,7 @@ Discussion.register({
Discussion.register({ Discussion.register({
id = "sumphore_disc_asc_2", id = "sumphore_disc_asc_2",
on_end = Meter.apply_sumphore_discussion_reward,
steps = { steps = {
{ {
question = "How's work? Your face looks strange", question = "How's work? Your face looks strange",
@@ -61,6 +63,7 @@ Discussion.register({
Discussion.register({ Discussion.register({
id = "sumphore_disc_asc_3", id = "sumphore_disc_asc_3",
on_end = Meter.apply_sumphore_discussion_reward,
steps = { steps = {
{ {
question = "Do you think it's work you're doing?", question = "Do you think it's work you're doing?",
@@ -86,8 +89,129 @@ Discussion.register({
}, },
}) })
Discussion.register({
id = "sumphore_disc_asc_4",
on_end = Meter.apply_sumphore_discussion_reward,
steps = {
{
question = "The alarm wakes you every morning without fail, I bet.",
answers = {
{ label = "It's how it works.", next_step = 2 },
{ label = "Sometimes I wish it didn't.", next_step = 2 },
},
},
{
question = "What if the alarm is part of the problem?",
answers = {
{ label = "That doesn't make any sense.", next_step = 3 },
{ label = "Go on.", next_step = 3 },
},
},
{
question = "One morning, Norman. When the choice comes, choose the bed. See what the world does without you in it.",
answers = {
{ label = "You're drunk.", next_step = nil },
{ label = "What choice?", next_step = nil, on_select = function()
Meter.add("ism", 5)
end },
},
},
},
})
Discussion.register({
id = "sumphore_disc_asc_5",
on_end = Meter.apply_sumphore_discussion_reward,
steps = {
{
question = "You saw the seams, didn't you. Good. That means the work is finally wearing thin.",
answers = {
{ label = "Wearing thin how?", next_step = 2 },
{ label = "Maybe.", next_step = 2 },
},
},
{
question = "Not your body. The part of you that still keeps score, still tries to be productive. Let that run empty and the world will slip again.",
answers = {
{ label = "You want me to stop trying?", next_step = 3 },
{ label = "I've noticed something odd.", next_step = 3 },
},
},
{
question = "Drain the work out of yourself. When that measure hits nothing, you'll see what was waiting behind it.",
answers = {
{ label = "The work measure?", next_step = nil, on_select = function()
Meter.add("ism", 5)
Meter.add("wpm", -100)
end },
{ label = "How would you know any of this?", next_step = nil },
},
},
},
})
Discussion.register({
id = "sumphore_disc_asc_6_waiting",
on_end = Meter.apply_sumphore_discussion_reward,
steps = {
{
question = "You look like a man who has seen something he can't explain.",
answers = {
{ label = "I'm hearing things.", next_step = 2 },
{ label = "Maybe.", next_step = 2 },
},
},
{
question = "Keep looking. The world has a way of showing you what you need to see. Talk to people. You're almost there.",
answers = {
{ label = "Almost where?", next_step = nil },
},
},
},
})
Discussion.register({
id = "sumphore_disc_asc_6",
on_end = function()
Meter.apply_sumphore_discussion_reward()
Context.should_ascend = true
Day.increase()
MysteriousManScreen.start({
text = MysteriousManScreen.get_text_for_level(Ascension.get_level())
})
end,
steps = {
{
question = "You've been seeing the cracks, haven't you? The repetition. The loops. The coworkers who can't quite remember.",
answers = {
{ label = "How do you know that?", next_step = 2 },
},
},
{
question = "Because I was you, once. This isn't a workplace, Norman. It never was. You're in a system. And you've just become aware of it.",
answers = {
{ label = "That can't be true.", next_step = 3 },
{ label = "I knew something was wrong.", next_step = 3 },
},
},
{
question = "It doesn't matter if you believe it. You already know. That's why the cracks are showing. That's why you're still here.",
answers = {
{ label = "What do I do now?", next_step = 4 },
},
},
{
question = "Oh look, is that a squirrel ?",
answers = {
{ label = "Alcoholic ...", next_step = nil },
},
},
},
})
Discussion.register({ Discussion.register({
id = "homeless_guy", id = "homeless_guy",
on_end = Meter.apply_sumphore_discussion_reward,
steps = { steps = {
{ {
question = "Sup bro, how are you?", question = "Sup bro, how are you?",

View File

@@ -23,6 +23,10 @@ Context = {}
--- * have_met_sumphore (boolean) Whether the player has talked to the homeless guy.<br/> --- * have_met_sumphore (boolean) Whether the player has talked to the homeless guy.<br/>
--- * have_been_to_office (boolean) Whether the player has been to the office.<br/> --- * have_been_to_office (boolean) Whether the player has been to the office.<br/>
--- * have_done_work_today (boolean) Whether the player has done work today.<br/> --- * have_done_work_today (boolean) Whether the player has done work today.<br/>
--- * toilet_meters_today_morning (boolean) Whether the home (before work) toilet meter delta was already applied this day.<br/>
--- * toilet_meters_today_evening (boolean) Whether the home (after work) toilet meter delta was already applied this day.<br/>
--- * coworker_discussion_meter_applied_today (boolean) Whether the daily coworker discussion meter roll was already applied this day.<br/>
--- * sumphore_discussion_meter_applied_today (boolean) Whether the daily sumphore discussion meter roll was already applied this day.<br/>
--- * game (table) Current game progress state. Contains: `current_screen` (string) active screen ID<br/> --- * game (table) Current game progress state. Contains: `current_screen` (string) active screen ID<br/>
function Context.initial_data() function Context.initial_data()
return { return {
@@ -45,16 +49,28 @@ function Context.initial_data()
home_norman_visible = false, home_norman_visible = false,
have_been_to_office = false, have_been_to_office = false,
have_done_work_today = false, have_done_work_today = false,
toilet_meters_today_morning = false,
toilet_meters_today_evening = false,
coworker_discussion_meter_applied_today = false,
sumphore_discussion_meter_applied_today = false,
glitch_conversation_count = 0,
glitch_conversation_done_today = false,
should_ascend = false, should_ascend = false,
have_met_sumphore = false, have_met_sumphore = false,
fast_food_approaching = false,
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",
@@ -124,6 +140,7 @@ function Context.new_game()
target_points = 100, target_points = 100,
instruction_text = "Wake up Norman!", instruction_text = "Wake up Norman!",
show_progress_text = false, show_progress_text = false,
meter_on_complete = Meter.apply_wakeup_reward,
on_win = function() on_win = function()
Audio.music_play_room_work() Audio.music_play_room_work()
Meter.show() Meter.show()

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 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,60 @@
--- @section CodeGenerator
CodeGenerator = {}
local SALT = 27471
local BASE36 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
local NAME_LEN = 3
-- Per-position offsets derived from SALT so each character slot
-- maps to a different region of the 2-char base-36 space.
local SALTS = {
SALT % 36,
math.floor(SALT / 36) % 36,
math.floor(SALT / 1296) % 36,
}
--- Encodes a number (0935) as exactly 2 base-36 characters.
--- @within CodeGenerator
function CodeGenerator.encode_pair(n)
return BASE36:sub(math.floor(n / 36) + 1, math.floor(n / 36) + 1)
.. BASE36:sub(n % 36 + 1, n % 36 + 1)
end
--- Decodes 2 base-36 characters back to a number.
--- @within CodeGenerator
function CodeGenerator.decode_pair(s)
local d1 = BASE36:find(s:sub(1, 1), 1, true) - 1
local d2 = BASE36:find(s:sub(2, 2), 1, true) - 1
return d1 * 36 + d2
end
--- Encrypts a player name into a code twice its length.
--- Each input character (A-Z, value 0-25) is encoded as
--- c + SALTS[i] * 26, producing 2 base-36 output characters.
--- @within CodeGenerator
--- @param text string NAME_LEN-character uppercase player name.
--- @return string Encrypted code (2 * NAME_LEN base-36 characters).
function CodeGenerator.encrypt(text)
local result = ""
for i = 1, NAME_LEN do
local c = math.max(0, (string.byte(text, i) or 65) - 65)
result = result .. CodeGenerator.encode_pair(c + SALTS[i] * 26)
end
return result
end
--- Decrypts a personal code back to the original player name.
--- @within CodeGenerator
--- @param encrypted_text string The code to decrypt (2 * NAME_LEN chars).
--- @return string Original player name, or "???" if the code is invalid.
function CodeGenerator.decrypt(encrypted_text)
local t = encrypted_text:upper()
if #t ~= NAME_LEN * 2 then return "???" end
local result = ""
for i = 1, NAME_LEN do
local pair = CodeGenerator.decode_pair(t:sub((i - 1) * 2 + 1, i * 2))
result = result .. string.char(pair % 26 + 65)
end
return result
end

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

@@ -8,6 +8,10 @@ function Day.increase()
if Context.day_count == 3 then if Context.day_count == 3 then
Context.should_ascend = true Context.should_ascend = true
end end
if Context.day_count >= 100 and not Ascension.is_complete() then
GameOverWindow.show("days")
return
end
for _, handler in ipairs(_day_increase_handlers) do for _, handler in ipairs(_day_increase_handlers) do
handler() handler()
end end
@@ -27,6 +31,15 @@ Day.register_handler(function()
m.bm = math.max(0, m.bm - METER_DECAY_PER_DAY) m.bm = math.max(0, m.bm - METER_DECAY_PER_DAY)
end) end)
Day.register_handler(function()
Context.toilet_meters_today_morning = false
Context.toilet_meters_today_evening = false
Context.coworker_discussion_meter_applied_today = false
Context.sumphore_discussion_meter_applied_today = false
Context.glitch_conversation_done_today = false
Context.fast_food_eaten_today = 0
end)
Day.register_handler(function() Day.register_handler(function()
if Context.should_ascend then if Context.should_ascend then
Ascension.increase() Ascension.increase()

View File

@@ -1,11 +1,15 @@
--- @section Meter --- @section Meter
local METER_MAX = 1000 local METER_MAX = 1000
local METER_DEFAULT = 500 local BM_METER_DEFAULT = 200
local ISM_METER_DEFAULT = 500
local WPM_METER_DEFAULT = 200
local METER_GAIN_PER_CHORE = 100 local METER_GAIN_PER_CHORE = 100
local METER_DECAY_PER_DAY = 20 local METER_DECAY_PER_DAY = 20
local COMBO_BASE_BONUS = 0.02 local COMBO_BASE_BONUS = 0.02
local COMBO_MAX_BONUS = 0.16 local COMBO_MAX_BONUS = 0.16
local COMBO_TIMEOUT_FRAMES = 600 local COMBO_TIMEOUT_FRAMES = 600
local METER_FLASH_DURATION = 2.0
local FLASH_COLOR = 4
-- Internal meters for tracking game progress and player stats. -- Internal meters for tracking game progress and player stats.
Meter.COLOR_ISM = Config.colors.orange Meter.COLOR_ISM = Config.colors.orange
@@ -14,6 +18,12 @@ Meter.COLOR_BM = Config.colors.red
Meter.COLOR_BG = Config.colors.meter_bg Meter.COLOR_BG = Config.colors.meter_bg
Meter.COLOR_CONTOUR = Config.colors.white 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. --- Gets initial meter values.
--- @within Meter --- @within Meter
--- @return result table Initial meter values. </br> --- @return result table Initial meter values. </br>
@@ -26,9 +36,9 @@ Meter.COLOR_CONTOUR = Config.colors.white
--- * hidden (boolean) Whether meters are hidden. --- * hidden (boolean) Whether meters are hidden.
function Meter.get_initial() function Meter.get_initial()
return { return {
ism = METER_DEFAULT, ism = ISM_METER_DEFAULT,
wpm = METER_DEFAULT, wpm = WPM_METER_DEFAULT,
bm = METER_DEFAULT, bm = BM_METER_DEFAULT,
combo = 0, combo = 0,
combo_timer = 0, combo_timer = 0,
hidden = false, hidden = false,
@@ -93,6 +103,12 @@ function Meter.update()
end end
end 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 end
--- Adds amount to a meter. --- Adds amount to a meter.
@@ -103,22 +119,145 @@ function Meter.add(key, amount)
if not Context or not Context.meters then return end if not Context or not Context.meters then return end
local m = Context.meters local m = Context.meters
if m[key] ~= nil then if m[key] ~= nil then
m[key] = math.min(METER_MAX, m[key] + amount) if amount > 0 and (key == "ism" or key == "bm") and m[key] >= METER_MAX then
GameOverWindow.show(key)
return
end
local prev_wpm = (key == "wpm") and m.wpm or nil
local old_val = m[key]
m[key] = math.max(0, math.min(METER_MAX, m[key] + amount))
local actual_delta = m[key] - old_val
if actual_delta ~= 0 and _flash[key] then
_flash[key].delta = actual_delta
_flash[key].timer = METER_FLASH_DURATION
end
if prev_wpm and prev_wpm > 0 and m.wpm == 0 and Context.game_in_progress
and Ascension.get_level() == 5 then
Context.should_ascend = true
end
end end
end end
--- Called on minigame completion. --- Called on minigame completion.
--- @within Meter --- @within Meter
function Meter.on_minigame_complete() --- @param is_work boolean If true (work-style minigame), apply combo to WPM/ISM/BM and advance combo. DDR uses `Meter.apply_ddr_reward` instead. Otherwise flat equal gain, combo unchanged.
function Meter.on_minigame_complete(is_work)
local m = Context.meters local m = Context.meters
local gain = math.floor(METER_GAIN_PER_CHORE * Meter.get_combo_multiplier()) if is_work then
Meter.add("wpm", gain) local mult = Meter.get_combo_multiplier()
Meter.add("ism", gain) local wpm_delta = math.floor(METER_GAIN_PER_CHORE / mult)
Meter.add("bm", gain) local ism_bm_delta = math.floor(METER_GAIN_PER_CHORE * mult)
Meter.add("wpm", wpm_delta)
Meter.add("ism", ism_bm_delta)
Meter.add("bm", ism_bm_delta)
m.combo = m.combo + 1
m.combo_timer = 0
else
local flat = METER_GAIN_PER_CHORE
Meter.add("wpm", flat)
Meter.add("ism", flat)
Meter.add("bm", flat)
end
end
--- Meter changes after DDR: uses max-meter percentages; combo advances like other work minigames.
--- 0 mistakes: WPM 10%, ISM +5%, BM +5%. 13: WPM 5%, ISM +10%, BM +10%. More than 3: WPM unchanged, ISM +10%, BM +10%.
--- @within Meter
--- @param mistake_count number Total mistakes (missed arrows, wrong inputs, and special-mode rule violations).
function Meter.apply_ddr_reward(mistake_count)
if not Context or not Context.meters then return end
local max = Meter.get_max()
local m = Context.meters
local wpm_was_high = m.wpm > 900
local wpm_pct, ism_pct, bm_pct
if mistake_count == 0 then
wpm_pct, ism_pct, bm_pct = -0.10, 0.05, 0.05
elseif mistake_count <= 3 then
wpm_pct, ism_pct, bm_pct = -0.05, 0.10, 0.10
else
wpm_pct, ism_pct, bm_pct = 0, 0.10, 0.10
end
if wpm_pct ~= 0 then
Meter.add("wpm", math.floor(max * wpm_pct))
end
if ism_pct ~= 0 then
Meter.add("ism", math.floor(max * ism_pct))
end
if bm_pct ~= 0 then
Meter.add("bm", math.floor(max * bm_pct))
end
if wpm_was_high then
Meter.add("ism", math.floor(max * 0.05))
Meter.add("bm", math.floor(max * 0.05))
end
m.combo = m.combo + 1 m.combo = m.combo + 1
m.combo_timer = 0 m.combo_timer = 0
end end
--- Meter changes for the wake-up button mash: faster completion is better for WPM.
--- Perfect: under 2s — WPM +20%. Good: 23s — WPM +10%, ISM +5%, BM +5%. Bad: over 3s — WPM 5%, ISM +10%, BM +10%.
--- @within Meter
--- @param elapsed_sec number Seconds from minigame start until the bar was filled.
function Meter.apply_wakeup_reward(elapsed_sec)
if not Context or not Context.meters then return end
local max = Meter.get_max()
local wpm_pct, ism_pct, bm_pct
if elapsed_sec < 2 then
wpm_pct, ism_pct, bm_pct = 0.20, 0, 0
elseif elapsed_sec <= 3 then
wpm_pct, ism_pct, bm_pct = 0.10, 0.05, 0.05
else
wpm_pct, ism_pct, bm_pct = -0.05, 0.10, 0.10
end
if wpm_pct ~= 0 then
Meter.add("wpm", math.floor(max * wpm_pct))
end
if ism_pct ~= 0 then
Meter.add("ism", math.floor(max * ism_pct))
end
if bm_pct ~= 0 then
Meter.add("bm", math.floor(max * bm_pct))
end
end
--- Random single meter shift after finishing a coworker discussion: ISM +10%, WPM 10%, or BM +10%.
--- @within Meter
function Meter.apply_coworker_discussion_reward()
if not Context or not Context.meters then return end
if Context.coworker_discussion_meter_applied_today then return end
local max = Meter.get_max()
local delta = math.floor(max * 0.10)
local roll = math.random(1, 3)
if roll == 1 then
Meter.add("ism", delta)
elseif roll == 2 then
Meter.add("wpm", -delta)
else
Meter.add("bm", delta)
end
Context.coworker_discussion_meter_applied_today = true
end
--- After finishing a sumphore discussion: reduce whichever of ISM / WPM / BM is highest by 10% of max (stable tie to ISM, then WPM, then BM).
--- @within Meter
function Meter.apply_sumphore_discussion_reward()
if not Context or not Context.meters then return end
if Context.sumphore_discussion_meter_applied_today then return end
local m = Context.meters
local max = Meter.get_max()
local delta = math.floor(max * 0.10)
local biggest_val_key = "ism"
local biggest_val = m.ism
for _, key in ipairs({ "wpm", "bm" }) do
if m[key] > biggest_val then
biggest_val = m[key]
biggest_val_key = key
end
end
Meter.add(biggest_val_key, -delta)
Context.sumphore_discussion_meter_applied_today = true
end
--- Draws meters. --- Draws meters.
--- @within Meter --- @within Meter
function Meter.draw() function Meter.draw()
@@ -149,12 +288,47 @@ function Meter.draw()
local fill_w = math.max(0, math.floor((m[meter.key] / max) * bar_w)) 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 - 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) 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) rect(bar_x, bar_y, fill_w, bar_h, meter.color)
end end
---print(meter.label, label_x, label_y, meter.color, false, 1, true) ---print(meter.label, label_x, label_y, meter.color, false, 1, true)
end end
local ascension_y = start_y + 3 * line_h + 1 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 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 -- desc: Life of a programmer
-- site: https://git.teletype.hu/games/impostor -- site: https://git.teletype.hu/games/impostor
-- license: MIT License -- license: MIT License
-- version: 1.0-beta3 -- version: 1.0
-- script: lua -- script: lua

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()
Audio.music_play_room_work() if CommuteGlitch.is_max() then
Audio.music_play_mystery()
Glitch.show()
else
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

@@ -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 = { local ascension_texts = {
[1] = ASC_01_TEXT, [1] = ASC_01_TEXT,
[2] = ASC_12_TEXT, [2] = ASC_12_TEXT,
[3] = ASC_23_TEXT, [3] = ASC_23_TEXT,
[4] = ASC_34_TEXT, [4] = ASC_34_TEXT,
[5] = ASC_45_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) function MysteriousManScreen.get_text_for_level(level)
@@ -80,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 = {
{ {
@@ -132,6 +242,7 @@ function MysteriousManScreen.wake_up()
target_points = 100, target_points = 100,
instruction_text = "Wake up Norman!", instruction_text = "Wake up Norman!",
show_progress_text = false, show_progress_text = false,
meter_on_complete = Meter.apply_wakeup_reward,
on_win = function() on_win = function()
Audio.music_play_wakingup() Audio.music_play_wakingup()
Meter.show() Meter.show()
@@ -145,11 +256,25 @@ function MysteriousManScreen.wake_up()
end end
-- Norman chooses to stay in bed, skipping the minigame and flash, and going straight to the next day. -- 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 -- @within MysteriousManScreen
function MysteriousManScreen.stay_in_bed() function MysteriousManScreen.stay_in_bed()
Day.increase() if Ascension.get_level() == 4 then
state = STATE_DAY Context.should_ascend = true
day_timer = day_display_seconds 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 end
--- Starts the mysterious man screen. --- Starts the mysterious man screen.
@@ -169,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
@@ -210,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
@@ -240,23 +367,77 @@ Screen.register({
end end
end end
elseif state == STATE_CHOICE then elseif state == STATE_CHOICE then
local menu_x = (Config.screen.width - 60) / 2 if break_mode then
local menu_y = (Config.screen.height - 20) / 2 if MysteriousManScreen.pending_end then
local confirmed if not Ascension.is_flashing() and not Ascension.is_fading() then
selected_choice, confirmed = UI.update_menu(MysteriousManScreen.choices, selected_choice, menu_x, menu_y) MysteriousManScreen.pending_end = false
Window.set_current("end")
end
return
end
if Input.select() or confirmed then if Input.left() or Input.up() then
Audio.sfx_select() if selected_choice == 2 then
if selected_choice == 1 then Audio.sfx_beep()
MysteriousManScreen.wake_up() selected_choice = 1
else end
MysteriousManScreen.stay_in_bed() 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
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
@@ -279,9 +460,42 @@ Screen.register({
Config.colors.white Config.colors.white
) )
elseif state == STATE_CHOICE then elseif state == STATE_CHOICE then
local menu_x = (Config.screen.width - 60) / 2 if break_mode then
local menu_y = (Config.screen.height - 20) / 2 if MysteriousManScreen.pending_end or Ascension.is_fading() or Ascension.is_flashing() then
UI.draw_menu(MysteriousManScreen.choices, selected_choice, menu_x, menu_y) 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
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}
} }
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, 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

@@ -51,8 +51,8 @@ Screen.register({
local decay_pct = Meter.get_decay_percentage() local decay_pct = Meter.get_decay_percentage()
local decay_text = string.format("-%d%%", decay_pct) local decay_text = string.format("-%d%%", decay_pct)
local combo_mult = Meter.get_combo_multiplier() local combo_mult = Meter.get_combo_multiplier()
local combo_pct = math.floor((combo_mult - 1) * 100) local ism_bm_combo_pct = math.floor((combo_mult - 1) * 100)
local mult_text = string.format("+%d%%", combo_pct) local wpm_combo_pct = math.floor((1 / combo_mult - 1) * 100 + 0.5)
local meter_start_y = text_y + 10 local meter_start_y = text_y + 10
local meter_list = { local meter_list = {
@@ -73,6 +73,12 @@ Screen.register({
rect(bar_x, bar_y, fill_w, bar_h, meter.color) rect(bar_x, bar_y, fill_w, bar_h, meter.color)
end end
local mult_text
if meter.key == "wpm" then
mult_text = string.format("%+d%%", wpm_combo_pct)
else
mult_text = string.format("+%d%%", ism_bm_combo_pct)
end
local decay_w = print(decay_text, 0, -6, 0, false, 1) local decay_w = print(decay_text, 0, -6, 0, false, 1)
Print.text_contour(decay_text, bar_x - decay_w - 4, bar_y, Config.colors.light_blue, false, 1, Config.colors.white) Print.text_contour(decay_text, bar_x - decay_w - 4, bar_y, Config.colors.light_blue, false, 1, Config.colors.white)
Print.text_contour(mult_text, bar_x + bar_w + 4, bar_y, Config.colors.light_blue, false, 1, Config.colors.white) Print.text_contour(mult_text, bar_x + bar_w + 4, bar_y, Config.colors.light_blue, false, 1, Config.colors.white)
@@ -86,5 +92,14 @@ Screen.register({
local asc_x = math.floor((sw - asc_total_w) / 2) 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,16 +4,83 @@ Screen.register({
decisions = { decisions = {
"go_to_home", "go_to_home",
"go_to_office", "go_to_office",
"sumphore_discussion",
"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()
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("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) 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

@@ -5,10 +5,9 @@ Screen.register({
"go_to_home", "go_to_home",
"go_to_office", "go_to_office",
"sumphore_discussion", "sumphore_discussion",
"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",
@@ -29,16 +28,35 @@ Screen.register({
{x = 27 * 8, y = 11 * 8}, {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, end,
background = "street",
draw = function() draw = function()
if Window.get_current_id() == "game" then local w = Window.get_current_id()
Sprite.draw_at("norman", 7 * 8, 3 * 8) if w == "game" or w == "discussion" then
Sprite.draw_at("sumphore", 9 * 8, 2 * 8) local norman_x = Context.fast_food_approaching and (19 * 8) or (7 * 8)
Sprite.draw_at("pizza_vendor", 19 * 8, 1 * 8) local show_sumphore = Ascension.get_level() ~= 8
Sprite.draw_at("dev_guard", 22 * 8, 2 * 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) 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

@@ -30,3 +30,9 @@ function Input.back() return btnp(INPUT_KEY_B) or keyp(INPUT_KEY_BACKSPACE) end
--- Checks if Enter is pressed. --- Checks if Enter is pressed.
--- @within Input --- @within Input
function Input.enter() return keyp(INPUT_KEY_ENTER) end function Input.enter() return keyp(INPUT_KEY_ENTER) end
--- Checks if Up is pressed or held (with repeat).
--- @within Input
function Input.up_repeat() return btnp(INPUT_KEY_UP, 20, 4) end
--- Checks if Down is pressed or held (with repeat).
--- @within Input
function Input.down_repeat() return btnp(INPUT_KEY_DOWN, 20, 4) end

View File

@@ -0,0 +1,81 @@
--- @section TextInput
TextInput = {}
local LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
local _pos = {}
local _cursor = 1
local _max_len = 3
--- Initialises a new text input session.
--- @within TextInput
--- @param max_len number Maximum character count (default 3).
function TextInput.init(max_len)
_max_len = max_len or 3
_pos = {}
for i = 1, _max_len do _pos[i] = 1 end
_cursor = 1
end
--- Advances to the next letter at the cursor position (wraps Z→A).
--- @within TextInput
function TextInput.next_letter()
_pos[_cursor] = (_pos[_cursor] % #LETTERS) + 1
end
--- Goes back to the previous letter at the cursor position (wraps A→Z).
--- @within TextInput
function TextInput.prev_letter()
_pos[_cursor] = ((_pos[_cursor] - 2) % #LETTERS) + 1
end
--- Confirms the current letter and advances the cursor to the next position.
--- When called on the last position the cursor moves into the done state.
--- @within TextInput
function TextInput.select_letter()
if _cursor <= _max_len then _cursor = _cursor + 1 end
end
--- Moves the cursor one position to the right (stops at last position).
--- @within TextInput
function TextInput.next_position()
if _cursor < _max_len then _cursor = _cursor + 1 end
end
--- Moves the cursor one position to the left (stops at first position).
--- Also steps back out of the done state.
--- @within TextInput
function TextInput.prev_position()
if _cursor > 1 then _cursor = _cursor - 1 end
end
--- Returns the assembled name string.
--- @within TextInput
--- @return string
function TextInput.get_name()
local s = ""
for i = 1, _max_len do s = s .. LETTERS:sub(_pos[i], _pos[i]) end
return s
end
--- Returns the current 1-based cursor position.
--- @within TextInput
--- @return number
function TextInput.get_position()
return _cursor
end
--- Returns the letter at the given 1-based position.
--- @within TextInput
--- @param i number
--- @return string
function TextInput.get_letter(i)
return LETTERS:sub(_pos[i], _pos[i])
end
--- Returns true when all positions have been confirmed.
--- @within TextInput
--- @return boolean
function TextInput.is_done()
return _cursor > _max_len
end

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
local w = print(item.label, 0, -10, 0, false, 1, false) if not item.header then
if w > max_w then max_w = w end local w = print(item.label, 0, -10, 0, false, 1, false)
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
if i == selected_item then local item = items[i]
Print.text(">", x - 8, current_y, Config.colors.light_blue) 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 end
Print.text(item.label, x, current_y, Config.colors.light_blue)
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,15 +89,23 @@ 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
local w = print(item.label, 0, -10, 0, false, 1, false) if not item.header then
if w > max_w then max_w = w end local w = print(item.label, 0, -10, 0, false, 1, false)
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
return i, true 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 end
current_y = current_y + step
end end
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 = { 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,73 +5,33 @@
function EndWindow.draw() function EndWindow.draw()
cls(Config.colors.black) cls(Config.colors.black)
if Context._end.state == "choice" then local cx = Config.screen.width / 2
local lines = { local name = Context.player_name or "AAA"
"This is not a workplace.", local code = CodeGenerator.encrypt(name)
"This is a cycle.",
"And if it is a cycle...",
"it can be broken."
}
local y = 40 Print.text_center("~ GOOD ENDING ~", cx, 8, Config.colors.light_blue)
for _, line in ipairs(lines) do Print.text_center("Congratulations, " .. name .. "!", cx, 20, Config.colors.white)
Print.text_center(line, Config.screen.width / 2, y, Config.colors.white)
y = y + 10
end
y = y + 20 rectb(40, 29, 160, 36, Config.colors.blue)
local yes_color = Context._end.selection == 1 and Config.colors.light_blue or Config.colors.white Print.text_center("your code", cx, 33, Config.colors.light_grey)
local no_color = Context._end.selection == 2 and Config.colors.light_blue or Config.colors.white Print.text_center(code, cx, 44, Config.colors.white, false, 2)
local yes_text = (Context._end.selection == 1 and "> YES" or " YES") Print.text_center("Write it down!", cx, 70, Config.colors.item)
local no_text = (Context._end.selection == 2 and "> NO" or " NO")
local centerX = Config.screen.width / 2 line(20, 82, 219, 82, Config.colors.dark_grey)
Print.text(yes_text, centerX - 40, y, yes_color) Print.text_center("To continue via telnet:", cx, 87, Config.colors.light_grey)
Print.text(no_text, centerX + 10, y, no_color) Print.text_center("games.teletype.hu 2324", cx, 98, Config.colors.white)
elseif Context._end.state == "ending" then line(20, 110, 219, 110, Config.colors.dark_grey)
Print.text_center("Game over -- good ending.", Config.screen.width / 2, 50, Config.colors.light_blue)
Print.text_center("Congratulations!", Config.screen.width / 2, 70, Config.colors.white) Print.text_center("Press Z to return to menu", cx, 116, Config.colors.dark_grey)
Print.text_center("Press Z to return to menu", Config.screen.width / 2, 110, Config.colors.light_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.select() then
if Input.left() or Input.up() then Context.reset()
if Context._end.selection == 2 then Window.set_current("menu")
Audio.sfx_beep() MenuWindow.refresh_menu_items()
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
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

@@ -0,0 +1,59 @@
--- @section GameOverWindow
local GAME_OVER_ART = [[
_###_ __#__ #___# #####
#____ _#_#_ ##_## #____
#_### ##### #_#_# ####_
#___# #___# #___# #____
_###_ #___# #___# #####
_###_ #___# ##### ####_
#___# #___# #____ #___#
#___# _#_#_ ####_ ####_
#___# __#__ #____ #_#__
_###_ __#__ ##### #__##
]]
local REASON_MESSAGES = {
ism = "Your impostor syndrome consumed you.",
bm = "You burned out like a cheap candle.",
days = "100 days passed. The cycle never broke.",
}
--- Shows the game over screen.
--- @within GameOverWindow
--- @param reason string One of "ism", "bm", "days".
function GameOverWindow.show(reason)
GameOverWindow.reason = reason
Context.game_in_progress = false
Glitch.show()
Window.set_current("game_over")
end
--- Draws the game over screen.
--- @within GameOverWindow
function GameOverWindow.draw()
cls(Config.colors.black)
local cx = Config.screen.width / 2
local bounds = AsciiArt.draw(GAME_OVER_ART, {
char_w = 4,
char_h = 6,
line_gap = 1,
word_gap = 10,
color = Config.colors.red,
})
local msg = REASON_MESSAGES[GameOverWindow.reason] or ""
Print.text_center(msg, cx, bounds.bottom + 8, Config.colors.white)
Print.text_center("Press Z to restart", cx, Config.screen.height - 10, Config.colors.light_grey)
end
--- Updates the game over screen logic.
--- @within GameOverWindow
function GameOverWindow.update()
if Input.select() then
Context.reset()
MenuWindow.refresh_menu_items()
Window.set_current("menu")
end
end

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()
@@ -102,17 +125,13 @@ function MenuWindow.update()
end end
end end
--- Starts a new game from the menu. --- Opens player name entry then starts a new game.
--- @within MenuWindow --- @within MenuWindow
function MenuWindow.new_game() function MenuWindow.new_game()
Context.new_game() PlayerNameWindow.init(function()
end Context.new_game()
end)
--- Loads a game from the menu. Window.set_current("player_name")
--- @within MenuWindow
function MenuWindow.load_game()
Context.load_game()
GameWindow.set_state("game")
end end
--- Saves the current game from the menu. --- Saves the current game from the menu.
@@ -139,6 +158,13 @@ function MenuWindow.controls()
Window.set_current("controls") Window.set_current("controls")
end end
--- Opens the player name entry screen (test mode shortcut).
--- @within MenuWindow
function MenuWindow.player_name()
PlayerNameWindow.init()
Window.set_current("player_name")
end
--- Opens the credits screen. --- Opens the credits screen.
--- @within MenuWindow --- @within MenuWindow
function MenuWindow.credits() function MenuWindow.credits()
@@ -160,6 +186,14 @@ function MenuWindow.continued()
GameWindow.set_state("continued") GameWindow.set_state("continued")
end end
--- Opens the end screen for testing.
--- @within MenuWindow
function MenuWindow.end_screen()
Context._end.state = "ending"
Context._end.selection = 1
GameWindow.set_state("end")
end
--- Opens the DDR minigame test. --- Opens the DDR minigame test.
--- @within MenuWindow --- @within MenuWindow
function MenuWindow.ddr_test() function MenuWindow.ddr_test()
@@ -168,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()
@@ -178,25 +225,32 @@ function MenuWindow.refresh_menu_items()
end end
table.insert(_menu_items, {label = "New Game", decision = MenuWindow.new_game}) table.insert(_menu_items, {label = "New Game", decision = MenuWindow.new_game})
table.insert(_menu_items, {label = "Load Game", decision = MenuWindow.load_game})
table.insert(_menu_items, {label = "Controls", decision = MenuWindow.controls}) table.insert(_menu_items, {label = "Controls", decision = MenuWindow.controls})
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 = "Player Name", decision = MenuWindow.player_name})
end end
table.insert(_menu_items, {label = "Exit", decision = MenuWindow.exit}) table.insert(_menu_items, {label = "Exit", decision = MenuWindow.exit})
_menu_max_w = 0 _menu_max_w = 0
for _, item in ipairs(_menu_items) do for _, item in ipairs(_menu_items) do
local w = print(item.label, 0, -10, 0, false, 1, false) if not item.header then
if w > _menu_max_w then _menu_max_w = w end local w = print(item.label, 0, -10, 0, false, 1, false)
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

@@ -130,6 +130,7 @@ function MinigameDDRWindow.on_arrow_hit_special(arrow, game_context)
Audio.sfx_arrowhit(arrow.note) Audio.sfx_arrowhit(arrow.note)
game_context.special_mode_counter = game_context.special_mode_counter + 1 game_context.special_mode_counter = game_context.special_mode_counter + 1
else else
game_context.total_misses = game_context.total_misses + 1
if game_context.special_mode_condition then Audio.sfx_bloop() end if game_context.special_mode_condition then Audio.sfx_bloop() end
game_context.special_mode_condition = false game_context.special_mode_condition = false
end end
@@ -141,10 +142,12 @@ function MinigameDDRWindow.on_arrow_hit_special(arrow, game_context)
game_context.bar_fill = game_context.bar_fill - game_context.fill_per_hit game_context.bar_fill = game_context.bar_fill - game_context.fill_per_hit
end end
else else
game_context.total_misses = game_context.total_misses + 1
if game_context.special_mode_condition then Audio.sfx_bloop() end if game_context.special_mode_condition then Audio.sfx_bloop() end
game_context.special_mode_condition = false game_context.special_mode_condition = false
end end
elseif special_mode == "only_nothing" then elseif special_mode == "only_nothing" then
game_context.total_misses = game_context.total_misses + 1
if game_context.special_mode_condition then Audio.sfx_bloop() end if game_context.special_mode_condition then Audio.sfx_bloop() end
game_context.special_mode_condition = false game_context.special_mode_condition = false
end end
@@ -173,6 +176,9 @@ function MinigameDDRWindow.on_end(game_context)
end end
game_context.special_mode_condition = game_context.special_mode_condition and was_ok game_context.special_mode_condition = game_context.special_mode_condition and was_ok
if game_context.special_mode_condition and sm ~= "normal" then
game_context.bar_fill = game_context.max_fill
end
end end
--- Initializes DDR minigame state. --- Initializes DDR minigame state.
@@ -336,7 +342,8 @@ function MinigameDDRWindow.update()
mg.win_timer = mg.win_timer - 1 mg.win_timer = mg.win_timer - 1
if mg.win_timer == 0 then if mg.win_timer == 0 then
Audio.music_stop() Audio.music_stop()
Meter.on_minigame_complete() Meter.apply_ddr_reward(mg.total_misses)
if not Context.game_in_progress then return end
if mg.on_win then if mg.on_win then
mg.on_win(mg) mg.on_win(mg)
else else

View File

@@ -1,6 +1,35 @@
--- @section MinigameButtonMashWindow
---@class MinigameButtonMashState
---@field bar_fill number
---@field target_points number
---@field fill_per_press number
---@field base_degradation number
---@field degradation_multiplier number
---@field button_pressed_timer number
---@field button_press_duration number
---@field instruction_text string
---@field show_progress_text boolean
---@field return_window string?
---@field bar_x number
---@field bar_y number
---@field bar_width number
---@field bar_height number
---@field button_x number
---@field button_y number
---@field button_size number
---@field focus_center_x number?
---@field focus_center_y number?
---@field focus_initial_radius number
---@field win_timer number
---@field on_win (fun())?
---@field meter_on_complete (fun(elapsed_sec: number))?
---@field start_ms number?
---@field elapsed_sec number?
--- Gets initial button mash minigame configuration. --- Gets initial button mash minigame configuration.
--- @within MinigameButtonMashWindow --- @within MinigameButtonMashWindow
--- @return result table The default button mash minigame configuration. ---@return MinigameButtonMashState
function MinigameButtonMashWindow.init_context() function MinigameButtonMashWindow.init_context()
return { return {
bar_fill = 0, bar_fill = 0,
@@ -24,7 +53,11 @@ function MinigameButtonMashWindow.init_context()
focus_center_y = nil, focus_center_y = nil,
focus_initial_radius = 0, focus_initial_radius = 0,
win_timer = 0, win_timer = 0,
on_win = nil on_win = nil,
--- If set, called with elapsed_sec instead of Meter.on_minigame_complete()
meter_on_complete = nil,
start_ms = nil,
elapsed_sec = nil,
} }
end end
@@ -51,8 +84,10 @@ end
function MinigameButtonMashWindow.start(return_window, params) function MinigameButtonMashWindow.start(return_window, params)
Audio.music_stop() Audio.music_stop()
MinigameButtonMashWindow.init(params) MinigameButtonMashWindow.init(params)
---@type MinigameButtonMashState
local mg = Context.minigame_button_mash local mg = Context.minigame_button_mash
mg.return_window = return_window or "game" mg.return_window = return_window or "game"
mg.start_ms = time()
if mg.focus_center_x then if mg.focus_center_x then
Focus.start_driven(mg.focus_center_x, mg.focus_center_y, { Focus.start_driven(mg.focus_center_x, mg.focus_center_y, {
initial_radius = mg.focus_initial_radius initial_radius = mg.focus_initial_radius
@@ -64,12 +99,18 @@ end
--- Updates button mash minigame logic. --- Updates button mash minigame logic.
--- @within MinigameButtonMashWindow --- @within MinigameButtonMashWindow
function MinigameButtonMashWindow.update() function MinigameButtonMashWindow.update()
---@type MinigameButtonMashState
local mg = Context.minigame_button_mash local mg = Context.minigame_button_mash
if mg.win_timer > 0 then if mg.win_timer > 0 then
mg.win_timer = mg.win_timer - 1 mg.win_timer = mg.win_timer - 1
if mg.win_timer == 0 then if mg.win_timer == 0 then
Meter.on_minigame_complete() if mg.meter_on_complete then
mg.meter_on_complete(mg.elapsed_sec or 0)
else
Meter.on_minigame_complete(false)
end
if not Context.game_in_progress then return end
if mg.focus_center_x then Focus.stop() end if mg.focus_center_x then Focus.stop() end
Context.home_norman_visible = true Context.home_norman_visible = true
Context.have_done_work_today = false Context.have_done_work_today = false
@@ -97,6 +138,7 @@ function MinigameButtonMashWindow.update()
end end
if mg.bar_fill >= mg.target_points then if mg.bar_fill >= mg.target_points then
Audio.sfx_select() Audio.sfx_select()
mg.elapsed_sec = (time() - mg.start_ms) / 1000
mg.win_timer = Config.timing.minigame_win_duration mg.win_timer = Config.timing.minigame_win_duration
return return
end end
@@ -116,6 +158,7 @@ end
--- Draws button mash minigame. --- Draws button mash minigame.
--- @within MinigameButtonMashWindow --- @within MinigameButtonMashWindow
function MinigameButtonMashWindow.draw() function MinigameButtonMashWindow.draw()
---@type MinigameButtonMashState
local mg = Context.minigame_button_mash local mg = Context.minigame_button_mash
if mg.return_window == "game" then if mg.return_window == "game" then
GameWindow.draw_with_underlay(function() GameWindow.draw_with_underlay(function()

View File

@@ -73,7 +73,8 @@ function MinigameRhythmWindow.update()
if mg.win_timer > 0 then if mg.win_timer > 0 then
mg.win_timer = mg.win_timer - 1 mg.win_timer = mg.win_timer - 1
if mg.win_timer == 0 then if mg.win_timer == 0 then
Meter.on_minigame_complete() Meter.on_minigame_complete(false)
if not Context.game_in_progress then return end
if mg.focus_center_x then Focus.stop() end if mg.focus_center_x then Focus.stop() end
if mg.on_win then if mg.on_win then
mg.on_win() mg.on_win()

View File

@@ -0,0 +1,115 @@
--- @section PlayerNameWindow
local _frame = 0
local _on_confirm = nil
local MAX_LEN = 3
local BOX_W = 24
local BOX_H = 24
local BOX_GAP = 12
local BOX_Y = 50
local WARN_Y = 104
local function box_start_x()
return math.floor((Config.screen.width - (MAX_LEN * BOX_W + (MAX_LEN - 1) * BOX_GAP)) / 2)
end
local function box_x(i)
return box_start_x() + (i - 1) * (BOX_W + BOX_GAP)
end
--- Initialises the player name window.
--- @within PlayerNameWindow
--- @param on_confirm function Called with the entered name when the player saves.
function PlayerNameWindow.init(on_confirm)
_frame = 0
_on_confirm = on_confirm
TextInput.init(MAX_LEN)
end
local function draw_boxes()
local cursor = TextInput.get_position()
local blink = math.floor(_frame / 18) % 2 == 0
for i = 1, MAX_LEN do
local x = box_x(i)
local is_cur = (i == cursor)
local done = TextInput.is_done()
local fill = (is_cur and not done) and Config.colors.blue or Config.colors.black
local border = (is_cur and not done) and Config.colors.white
or done and Config.colors.light_blue
or Config.colors.dark_grey
rect (x, BOX_Y, BOX_W, BOX_H, fill)
rectb(x, BOX_Y, BOX_W, BOX_H, border)
local show = not (is_cur and blink and not done)
if show then
local ch = TextInput.get_letter(i)
local cw = print(ch, 0, -100, 0, false, 2)
local cx = x + math.floor((BOX_W - cw) / 2)
local cy = BOX_Y + math.floor((BOX_H - 11) / 2)
local col = (is_cur and not done) and Config.colors.white or Config.colors.light_grey
print(ch, cx, cy, col, false, 2)
end
end
-- caret arrow below active box
if not TextInput.is_done() then
local cx = box_x(cursor) + math.floor(BOX_W / 2)
local ay = BOX_Y + BOX_H + 4
line(cx - 4, ay, cx, ay + 4, Config.colors.white)
line(cx + 4, ay, cx, ay + 4, Config.colors.white)
end
end
--- Draws the player name window.
--- @within PlayerNameWindow
function PlayerNameWindow.draw()
cls(Config.colors.black)
Print.text_center("Player Name", Config.screen.width / 2, 14, Config.colors.white, false, 2)
draw_boxes()
if TextInput.is_done() then
Print.text_center("Z: save name B: edit", Config.screen.width / 2, BOX_Y + BOX_H + 12, Config.colors.light_blue)
else
Print.text_center("Up/Dn: letter Lft/Rgt: move Z: ok", Config.screen.width / 2, BOX_Y + BOX_H + 12, Config.colors.dark_grey)
end
-- Warning section
rect(0, WARN_Y, Config.screen.width, Config.screen.height - WARN_Y, Config.colors.blue)
rectb(0, WARN_Y, Config.screen.width, Config.screen.height - WARN_Y, Config.colors.light_blue)
Print.text_center("Remember your name!", Config.screen.width / 2, WARN_Y + 8, Config.colors.white)
Print.text_center("You will need it to load the game.", Config.screen.width / 2, WARN_Y + 20, Config.colors.light_grey)
end
--- Updates player name window logic.
--- @within PlayerNameWindow
function PlayerNameWindow.update()
_frame = _frame + 1
if TextInput.is_done() then
if Input.select() then
Context.player_name = TextInput.get_name()
if _on_confirm then _on_confirm() else Window.set_current("menu") end
elseif Input.back() then
TextInput.prev_position()
end
return
end
if Input.up_repeat() then TextInput.next_letter() end
if Input.down_repeat() then TextInput.prev_letter() end
if Input.right() then TextInput.next_position() end
if Input.select() then TextInput.select_letter() end
if Input.left() or Input.back() then
if TextInput.get_position() > 1 then
TextInput.prev_position()
else
Window.set_current("menu")
end
end
end

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)
@@ -31,6 +34,9 @@ Window.register("minigame_rhythm", MinigameRhythmWindow)
MinigameDDRWindow = {} MinigameDDRWindow = {}
Window.register("minigame_ddr", MinigameDDRWindow) Window.register("minigame_ddr", MinigameDDRWindow)
GameOverWindow = {}
Window.register("game_over", GameOverWindow)
EndWindow = {} EndWindow = {}
Window.register("end", EndWindow) Window.register("end", EndWindow)
@@ -42,3 +48,6 @@ Window.register("continued", ContinuedWindow)
CreditsWindow = {} CreditsWindow = {}
Window.register("credits", CreditsWindow) Window.register("credits", CreditsWindow)
PlayerNameWindow = {}
Window.register("player_name", PlayerNameWindow)