Files
mazegame/projects/kissaten_yugure/kissaten.c
T
jurjen.ladeniusandClaude Opus 5 cbde457da5 M2 complete: disk saving works via MSXgl's tool/disk_save module
Replaces the hand-rolled PHYDIO/DSKIO sector writer, which never worked,
with engine/src/tool/disk_save.h — added in MSXgl v1.3.0 for exactly this
("save to disk from a ROM application"). Saves are now a real FAT file,
KISSAT00.SAV, on the disk in drive A.

Four non-obvious requirements, all documented in CLAUDE.md:
- LibModules needs both "tool/disk_save" and "dos"
- ROMDelayBoot = true, or the Disk ROM's INIT never runs
- boot then takes 40-90s of emulated time (use `set throttle off`)
- DiskSave_Check() returns SAVEDATA_UNSIGNED for good files: with APPSIGN
  it wants the first 4 bytes to be g_AppSignature, but DiskSave_Save()
  writes the payload raw and never adds it

Also fixes a save-timing bug: the write now happens in AdvanceDay() after
the day increments, so the file describes the morning the player wakes to.
Saving during the evening made a reload replay that day and double-count
its cups.

Verified: play a full day, cold boot, resume at day 2 with served intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:17:49 +02:00

829 lines
25 KiB
C

// Kissaten Yūgure — stage 1 of the scope ladder (see docs/DESIGN.md §8):
// shop scene + one customer + siphon brew minigame + serve loop. No story,
// no money, no day cycle yet. All "art" is procedural placeholder fills;
// real bitmaps will replace the scene drawing and sprite data later.
//=============================================================================
// INCLUDES
//=============================================================================
#include "msxgl.h"
#include "tool/disk_save.h"
// Fonts data
#include "font/font_mgl_sample6.h"
//=============================================================================
// DEFINES
//=============================================================================
// Screen layout (DESIGN.md §4): 16px status bar, 132px shop scene, 64px dialogue
#define STATUS_Y 0
#define STATUS_H 16
#define SCENE_Y 16
#define SCENE_H 132
#define DLG_Y 148
#define DLG_H 64
// Palette slots (DESIGN.md §5: sky in 8-11, wood/warm in 4-7, so seasons and
// time-of-day can later be palette swaps without touching the bitmap)
#define C_OUTLINE 1 // near black
#define C_WOOD_D 2 // dark wood / floor / shadow
#define C_WOOD_M 3 // mid wood (wall panels, door)
#define C_WOOD_L 4 // light wood (counter top)
#define C_LAMP 5 // warm lamp glow
#define C_CREAM 6 // cream (glassware, cup)
#define C_WALL 7 // muted upper wall
#define C_SKY1 8 // dusk sky, deep
#define C_SKY2 9 // dusk violet
#define C_SKY3 10 // dusk rose
#define C_SKY4 11 // horizon amber
#define C_COFFEE 12 // coffee brown
#define C_COAT 13 // customer coat blue
#define C_SKIN 14 // skin
#define C_TEXT 15 // warm white
// G4 mode: one byte = two pixels of the same palette index
#define PX(c) (u8)(((c) << 4) | (c))
// Shop scene landmarks
#define COUNTER_TOP_Y 98
#define DOOR_X 216 // where the customer appears/exits
#define SEAT_X 104 // where the customer stands at the counter
#define CUST_Y 84 // sprite top: bottom edge rests on the counter top
#define SIPHON_X 32 // siphon rig center
#define CUP_X 86 // served cup position on the counter
// Sprites (mode 2 is implicit in SCREEN 5; two layers per customer)
#define SPR_CUST_FILL 0 // front layer
#define SPR_CUST_LINE 1 // outline layer behind
#define SPR_STEAM 2
#define PAT_CUST_FILL 0 // 16x16 patterns: indices are multiples of 4
#define PAT_CUST_LINE 4
#define PAT_STEAM_A 8
#define PAT_STEAM_B 12
// Day length. Event-driven clock: customers *are* the clock, so this single
// constant is the whole pacing dial (PLAN.md §8 items 2-3).
#define CUSTOMERS_PER_DAY 5
// Brew minigame gauge (drawn in the dialogue window)
#define GAUGE_X 64
#define GAUGE_Y 186
#define GAUGE_W 148
#define GAUGE_H 10
#define GAUGE_CENTER (GAUGE_W / 2)
// Dialogue text layout: 48px portrait zone left, 3 text lines right
#define DLG_TEXT_X 64
#define DLG_LINE_Y(n) (u8)(156 + (n) * 14)
//=============================================================================
// READ-ONLY DATA
//=============================================================================
// Palette: u16 = G<<8 | R<<4 | B, components 0-7
#define RGB(r, g, b) (u16)(((g) << 8) | ((r) << 4) | (b))
const u16 g_Palette[16] = {
RGB(0, 0, 0), // 0 (transparent)
RGB(1, 1, 1), // 1 outline
RGB(2, 1, 1), // 2 dark wood
RGB(4, 2, 1), // 3 mid wood
RGB(5, 3, 2), // 4 light wood
RGB(7, 5, 2), // 5 lamp glow
RGB(7, 6, 4), // 6 cream
RGB(3, 2, 2), // 7 muted wall
RGB(2, 1, 4), // 8 dusk deep
RGB(4, 2, 5), // 9 dusk violet
RGB(6, 3, 3), // 10 dusk rose
RGB(7, 4, 2), // 11 horizon amber
RGB(3, 1, 0), // 12 coffee
RGB(2, 3, 5), // 13 coat blue
RGB(7, 5, 4), // 14 skin
RGB(7, 7, 6), // 15 warm white
};
// 16x16 sprite patterns: 32 bytes each, left column rows 0-15 then right column.
// Customer silhouette, fill layer
const u8 g_SprCustFill[32] = {
0x07, 0x0F, 0x1F, 0x1F, 0x1F, 0x0F, 0x07, 0x1F, // left, rows 0-7
0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0x1F, 0x0E, 0x0E, // left, rows 8-15
0xE0, 0xF0, 0xF8, 0xF8, 0xF8, 0xF0, 0xE0, 0xF8, // right, rows 0-7
0xFC, 0xFC, 0xFC, 0xFC, 0xFC, 0xF8, 0x70, 0x70, // right, rows 8-15
};
// Customer silhouette dilated by 1px: black outline layer drawn behind the fill
const u8 g_SprCustLine[32] = {
0x1F, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0x7F,
0x7F, 0x7F, 0x7F, 0x7F, 0x7F, 0x7F, 0x3F, 0x1F,
0xF8, 0xFC, 0xFC, 0xFC, 0xFC, 0xFC, 0xFC, 0xFE,
0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0xFC, 0xF8,
};
// Steam wisps, two animation frames
const u8 g_SprSteamA[32] = {
0x01, 0x03, 0x07, 0x0E, 0x1C, 0x1E, 0x0F, 0x07,
0x03, 0x03, 0x07, 0x0E, 0x06, 0x00, 0x00, 0x00,
0x80, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
0xC0, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};
const u8 g_SprSteamB[32] = {
0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x80, 0xC0, 0xE0, 0x70, 0x38, 0x78, 0xF0, 0xE0,
0xC0, 0xC0, 0xE0, 0x70, 0x60, 0x00, 0x00, 0x00,
};
// Ambient lines shown while the shop is empty
const c8* const g_Ambient[4] = {
"The radio hums softly.",
"Rain taps at the window.",
"The kettle ticks as it cools.",
"Dusk settles over the street.",
};
//=============================================================================
// GAME STATE
//=============================================================================
enum State
{
STATE_WAIT, // shop empty, waiting for the next customer
STATE_ARRIVE, // customer walks from door to counter
STATE_ORDER, // order shown, waiting for SPACE
STATE_BREW, // siphon minigame running
STATE_SERVE, // reaction to the brew
STATE_DRINK, // quiet moment with the cup
STATE_LEAVE, // customer walks back to the door
};
// Day phases (DESIGN.md §2). The open-hours phase holds the STATE_* loop above.
enum Phase
{
PHASE_MORNING, // prep — day title card, waiting to open
PHASE_OPEN, // the serve loop runs
PHASE_EVENING, // close — tally, journal line, save
};
u8 g_State = STATE_WAIT;
u16 g_Timer = 120; // frames left in timed states
u8 g_CustX = DOOR_X; // customer sprite X
u16 g_Served = 0; // cups served, lifetime
u8 g_Ambience = 0; // rotating ambient line index
i16 g_Needle = 0; // brew gauge needle position
i8 g_NeedleDir = 3;
u8 g_Quality = 0; // 0 weak/overdone .. 3 perfect
bool g_Overdone = FALSE; // which side of center the lock landed on
u8 g_Frame = 0;
bool g_SpaceHit = FALSE; // SPACE pressed this frame (edge, sampled after Halt)
u8 g_SpacePrev = 0;
u8 g_Phase = PHASE_MORNING;
u8 g_DayServed = 0; // customers served so far today
u8 g_DayQuality = 0; // summed brew quality today, for the journal line
bool g_SaveOk = TRUE; // did the last write succeed (shown next morning)
//=============================================================================
// SAVE STATE (DESIGN.md §6)
//=============================================================================
// Bumped whenever the layout below changes; a mismatch is treated as "no save"
// rather than migrated — there is no shipped version to be compatible with yet.
#define SAVE_MAGIC 0x594B // 'KY'
#define SAVE_VERSION 1
typedef struct
{
u8 id;
u8 arc_stage; // progress through their story
u8 affinity; // 0-255, raised by right orders
u8 last_visit_day;
} Regular;
typedef struct
{
u16 magic;
u8 version;
u16 day;
u8 season; // derives palette set
u8 weather; // affects visitor table
u16 money;
u16 served;
u8 unlocked_items; // bitmask: grinder, records, cat...
Regular regulars[8];
u8 checksum; // sum of all preceding bytes, negated
} SaveState;
SaveState g_Save;
//=============================================================================
// DISK SAVE
//=============================================================================
//
// Uses MSXgl's tool/disk_save module (added in v1.3.0, "save to disk from a
// ROM application"). It writes a real named file — KISSAT0.SAV — through the
// Disk ROM, so the save disk stays a normal, inspectable FAT disk.
//
// This replaced a hand-rolled PHYDIO/DSKIO sector writer that never worked:
// both entry points returned carry-clear without transferring anything. Do
// not go back to raw sectors; use this module.
//
// Requirements, both non-obvious:
// - LibModules must include BOTH "tool/disk_save" and "dos".
// - ROMDelayBoot = true, or the Disk ROM's INIT never runs (see CLAUDE.md).
// Save slot. Multiple save files would be entries 1, 2, ... on the same disk.
#define SAVE_ENTRY 0
bool g_HasDisk = FALSE; // a usable save disk was found at boot
//-----------------------------------------------------------------------------
u8 Save_Checksum(const SaveState* s)
{
const u8* p = (const u8*)s;
u8 sum = 0;
for (u8 i = 0; i < sizeof(SaveState) - 1; i++)
sum += p[i];
return (u8)(0 - sum);
}
//-----------------------------------------------------------------------------
// A missing drive or a blank disk is not an error the player has to deal
// with — it just means nothing will persist.
void Disk_Init()
{
DiskSave_SetName("KISSAT"); // 6 chars max
DiskSave_SetExtension("SAV"); // 3 chars max
g_HasDisk = (DiskSave_Initialize() == SAVEDATA_VALID);
}
//-----------------------------------------------------------------------------
// TRUE if a valid save was loaded; FALSE means "start a new game", which is
// also what a blank disk gives us.
bool Save_Load()
{
if (!g_HasDisk)
return FALSE;
// SAVEDATA_UNSIGNED is expected, not an error: with AppSignature on,
// DiskSave_Check() wants the file's first 4 bytes to be g_AppSignature,
// but DiskSave_Save() writes the payload raw and never adds it — so a
// perfectly good file always reports UNSIGNED. Only a genuinely absent or
// unreadable file is a reason to bail; the magic and checksum below are a
// stronger check than the signature would have been anyway.
u8 status = DiskSave_Check(SAVE_ENTRY);
if ((status != SAVEDATA_VALID) && (status != SAVEDATA_UNSIGNED))
return FALSE;
if (!DiskSave_Load(SAVE_ENTRY, (u8*)&g_Save, sizeof(SaveState)))
return FALSE;
// Trust nothing that came off a disk
if (g_Save.magic != SAVE_MAGIC || g_Save.version != SAVE_VERSION)
return FALSE;
if (g_Save.checksum != Save_Checksum(&g_Save))
return FALSE;
return TRUE;
}
//-----------------------------------------------------------------------------
bool Save_Write()
{
if (!g_HasDisk)
return FALSE;
g_Save.magic = SAVE_MAGIC;
g_Save.version = SAVE_VERSION;
g_Save.checksum = Save_Checksum(&g_Save);
return DiskSave_Save(SAVE_ENTRY, (const u8*)&g_Save, sizeof(SaveState));
}
//-----------------------------------------------------------------------------
void Save_Reset()
{
Mem_Set(0, &g_Save, sizeof(SaveState));
g_Save.day = 1;
g_Save.season = 0;
g_Save.money = 0;
g_Save.served = 0;
for (u8 i = 0; i < 8; i++)
g_Save.regulars[i].id = i;
}
//=============================================================================
// HELPERS
//=============================================================================
//-----------------------------------------------------------------------------
// Customer sprites: fill layer in front, outline layer behind
void Customer_Show(u8 x, u8 y)
{
VDP_SetSpritePosition(SPR_CUST_FILL, x, y);
VDP_SetSpritePosition(SPR_CUST_LINE, x, y);
}
void Customer_Hide()
{
VDP_HideSprite(SPR_CUST_FILL);
VDP_HideSprite(SPR_CUST_LINE);
}
//-----------------------------------------------------------------------------
// Steam wisp: alternates two patterns every 16 frames
void Steam_Show(u8 x, u8 y)
{
VDP_SetSpriteExUniColor(SPR_STEAM, x, y,
(g_Frame & 16) ? PAT_STEAM_A : PAT_STEAM_B, C_CREAM);
}
void Steam_Hide()
{
VDP_HideSprite(SPR_STEAM);
}
//-----------------------------------------------------------------------------
// Status bar: title left, served count right
void DrawStatusBar()
{
VDP_CommandHMMV(0, STATUS_Y, 256, STATUS_H, PX(C_OUTLINE));
VDP_CommandWait();
Print_SetColor(C_LAMP, C_OUTLINE);
Print_SetPosition(4, 4);
Print_DrawText("DAY ");
Print_DrawInt(g_Save.day);
// Phase marker sits mid-bar; the clock is the day phase, nothing finer
Print_SetColor(C_CREAM, C_OUTLINE);
Print_SetPosition(96, 4);
switch (g_Phase)
{
case PHASE_MORNING: Print_DrawText("MORNING"); break;
case PHASE_OPEN: Print_DrawText("OPEN"); break;
default: Print_DrawText("CLOSED"); break;
}
Print_SetColor(C_TEXT, C_OUTLINE);
Print_SetPosition(176, 4);
Print_DrawText("SERVED ");
Print_DrawInt(g_Save.served);
}
//-----------------------------------------------------------------------------
// Shop scene: placeholder fills standing in for the real background bitmap
void DrawScene()
{
// Wall, wood paneling, floor
VDP_CommandHMMV(0, SCENE_Y, 256, 60, PX(C_WALL)); // upper wall
VDP_CommandHMMV(0, 76, 256, 44, PX(C_WOOD_M)); // wainscot
VDP_CommandHMMV(0, 76, 256, 1, PX(C_WOOD_D)); // panel rail
VDP_CommandHMMV(0, 120, 256, 28, PX(C_WOOD_D)); // floor
VDP_CommandHMMV(0, 120, 256, 1, PX(C_OUTLINE)); // floor edge
// Window (left): frame + dusk sky gradient in slots 8-11
VDP_CommandHMMV(16, 24, 72, 48, PX(C_WOOD_D)); // frame
VDP_CommandHMMV(20, 28, 64, 10, PX(C_SKY1));
VDP_CommandHMMV(20, 38, 64, 10, PX(C_SKY2));
VDP_CommandHMMV(20, 48, 64, 10, PX(C_SKY3));
VDP_CommandHMMV(20, 58, 64, 10, PX(C_SKY4));
VDP_CommandHMMV(50, 28, 2, 40, PX(C_WOOD_D)); // mullion
VDP_CommandHMMV(20, 46, 64, 2, PX(C_WOOD_D)); // transom
// Door (right)
VDP_CommandHMMV(208, 32, 36, 88, PX(C_WOOD_D)); // frame
VDP_CommandHMMV(212, 36, 28, 82, PX(C_WOOD_M)); // door
VDP_CommandHMMV(218, 40, 16, 18, PX(C_SKY2)); // door pane
VDP_CommandHMMV(236, 76, 2, 3, PX(C_LAMP)); // knob
// Hanging lamp above the counter
VDP_CommandHMMV(139, SCENE_Y, 2, 12, PX(C_OUTLINE)); // cord
VDP_CommandHMMV(132, 28, 16, 8, PX(C_LAMP)); // shade
// Counter: top slab + front panel
VDP_CommandHMMV(8, COUNTER_TOP_Y, 192, 8, PX(C_WOOD_L));
VDP_CommandHMMV(8, COUNTER_TOP_Y + 8, 192, 34, PX(C_WOOD_D));
VDP_CommandHMMV(8, COUNTER_TOP_Y, 192, 1, PX(C_CREAM)); // top highlight
// Siphon rig on the counter (left, kept below the window): stand, globe, flask
VDP_CommandHMMV(SIPHON_X + 5, 74, 2, 24, PX(C_OUTLINE)); // stand pole
VDP_CommandHMMV(SIPHON_X - 6, 74, 24, 2, PX(C_OUTLINE)); // top arm
VDP_CommandHMMV(SIPHON_X - 6, 76, 12, 8, PX(C_CREAM)); // upper globe
VDP_CommandHMMV(SIPHON_X - 7, 86, 14, 10, PX(C_CREAM)); // lower flask
VDP_CommandWait();
}
//-----------------------------------------------------------------------------
// Coffee level in the lower flask, 0-8 px
void DrawFlask(u8 level)
{
VDP_CommandHMMV(SIPHON_X - 6, 87, 12, 8, PX(C_CREAM));
if (level > 0)
{
if (level > 8)
level = 8;
VDP_CommandHMMV(SIPHON_X - 6, (u16)(95 - level), 12, level, PX(C_COFFEE));
}
VDP_CommandWait();
}
//-----------------------------------------------------------------------------
// Burner flame under the flask (on/off)
void DrawFlame(bool on)
{
VDP_CommandHMMV(SIPHON_X - 3, 96, 6, 2, on ? PX(C_SKY4) : PX(C_WOOD_M));
VDP_CommandWait();
}
//-----------------------------------------------------------------------------
// Cup on the counter in front of the customer
void DrawCup(bool visible)
{
if (visible)
{
VDP_CommandHMMV(CUP_X, 91, 10, 7, PX(C_CREAM));
VDP_CommandHMMV(CUP_X + 1, 91, 8, 2, PX(C_COFFEE));
}
else // restore the wainscot the cup stands against, not the counter top
VDP_CommandHMMV(CUP_X, 91, 10, 7, PX(C_WOOD_M));
VDP_CommandWait();
}
//-----------------------------------------------------------------------------
// Dialogue window: dark panel with a portrait placeholder box on the left
void Dlg_Clear()
{
VDP_CommandHMMV(0, DLG_Y, 256, DLG_H, PX(C_WOOD_D)); // border
VDP_CommandHMMV(2, DLG_Y + 2, 252, DLG_H - 4, PX(C_OUTLINE)); // panel
VDP_CommandHMMV(6, 154, 52, 52, PX(C_WOOD_M)); // portrait frame
VDP_CommandHMMV(8, 156, 48, 48, PX(C_WOOD_D)); // portrait (48x48, stage 3+)
VDP_CommandWait();
}
void Dlg_Line(u8 line, const c8* text)
{
Print_SetColor(C_TEXT, C_OUTLINE);
Print_SetPosition(DLG_TEXT_X, DLG_LINE_Y(line));
Print_DrawText(text);
}
// Prompt line in warm accent color
void Dlg_Prompt(const c8* text)
{
Print_SetColor(C_LAMP, C_OUTLINE);
Print_SetPosition(DLG_TEXT_X, DLG_LINE_Y(3));
Print_DrawText(text);
}
//-----------------------------------------------------------------------------
// Brew gauge: zones repainted whole each frame, then the needle on top
void DrawGauge()
{
// Full band first: the needle sticks out 2px above and below the zones
VDP_CommandHMMV(GAUGE_X, GAUGE_Y - 2, GAUGE_W + 2, GAUGE_H + 4, PX(C_OUTLINE));
VDP_CommandHMMV(GAUGE_X, GAUGE_Y, GAUGE_W, GAUGE_H, PX(C_WOOD_D));
VDP_CommandHMMV(GAUGE_X + GAUGE_CENTER - 10, GAUGE_Y, 20, GAUGE_H, PX(C_LAMP));
VDP_CommandHMMV(GAUGE_X + GAUGE_CENTER - 3, GAUGE_Y, 6, GAUGE_H, PX(C_SKY4));
VDP_CommandHMMV((u16)(GAUGE_X + g_Needle), GAUGE_Y - 2, 2, GAUGE_H + 4, PX(C_TEXT));
VDP_CommandWait();
}
void EraseGauge()
{
VDP_CommandHMMV(GAUGE_X, GAUGE_Y - 2, GAUGE_W + 2, GAUGE_H + 4, PX(C_OUTLINE));
VDP_CommandWait();
}
//=============================================================================
// PHASE TRANSITIONS
//=============================================================================
//-----------------------------------------------------------------------------
// Morning: day title card. No prep decisions yet — those arrive with the menu
// system, if it survives the cut (PLAN.md §6).
void EnterMorning()
{
g_Phase = PHASE_MORNING;
g_DayServed = 0;
g_DayQuality = 0;
Customer_Hide();
Steam_Hide();
DrawStatusBar();
DrawScene();
Dlg_Clear();
Dlg_Line(0, "Morning. The shutters go");
Dlg_Line(1, "up, the kettle goes on.");
if (!g_HasDisk)
Dlg_Line(2, "(no save disk in drive A)");
else if (!g_SaveOk)
Dlg_Line(2, "(save failed)");
Dlg_Prompt("[SPACE] open the shop");
}
//-----------------------------------------------------------------------------
// Evening: tally, one journal line reflecting how the day went, then save.
void EnterEvening()
{
g_Phase = PHASE_EVENING;
Customer_Hide();
Steam_Hide();
DrawStatusBar();
Dlg_Clear();
Dlg_Line(0, "Closing. Cups today: ");
Print_DrawInt(g_DayServed);
// Average quality drives the journal line — no score, just a mood
u8 avg = g_DayServed ? (u8)(g_DayQuality / g_DayServed) : 0;
if (avg >= 2)
Dlg_Line(1, "A good day at the siphon.");
else if (avg >= 1)
Dlg_Line(1, "An ordinary, decent day.");
else
Dlg_Line(1, "Tomorrow, a steadier hand.");
Dlg_Prompt("[SPACE] sleep");
}
//-----------------------------------------------------------------------------
// Save *after* the day rolls over, so the file always describes the morning
// the player will wake up to. Saving during the evening instead would make a
// reload replay the day just finished and double-count its cups.
void AdvanceDay()
{
g_Save.day++;
g_SaveOk = Save_Write();
EnterMorning();
}
//=============================================================================
// STATE TRANSITIONS
//=============================================================================
void EnterWaitState()
{
g_State = STATE_WAIT;
g_Timer = 120 + Math_GetRandomMax8(180);
Dlg_Clear();
Dlg_Line(0, g_Ambient[g_Ambience]);
g_Ambience = (g_Ambience + 1) & 3;
}
void EnterArrive()
{
g_State = STATE_ARRIVE;
g_CustX = DOOR_X;
VDP_SetSpriteExUniColor(SPR_CUST_LINE, DOOR_X, CUST_Y, PAT_CUST_LINE, C_OUTLINE);
VDP_SetSpriteExUniColor(SPR_CUST_FILL, DOOR_X, CUST_Y, PAT_CUST_FILL, C_COAT);
Dlg_Clear();
Dlg_Line(0, "The door chime rings.");
}
void EnterOrder()
{
g_State = STATE_ORDER;
Dlg_Clear();
Dlg_Line(0, "\"A coffee, please.\"");
Dlg_Prompt("[SPACE] brew");
}
void EnterBrew()
{
g_State = STATE_BREW;
g_Needle = 0;
g_NeedleDir = 3;
g_Timer = 0;
Dlg_Clear();
Dlg_Line(0, "Release when the needle");
Dlg_Line(1, "crosses the amber mark!");
DrawFlame(TRUE);
}
void EnterServe()
{
g_State = STATE_SERVE;
g_Timer = 150;
EraseGauge();
DrawFlame(FALSE);
DrawFlask(0);
DrawCup(TRUE);
Dlg_Clear();
switch (g_Quality)
{
case 3:
Dlg_Line(0, "\"Ah... perfect.\"");
Dlg_Line(1, "The evening feels warmer.");
break;
case 2:
Dlg_Line(0, "\"Mm. Nice and rich.\"");
break;
case 1:
Dlg_Line(0, "\"Thank you.\"");
break;
default:
Dlg_Line(0, g_Overdone ? "\"Oh... quite strong.\""
: "\"Hm... a little thin.\"");
break;
}
}
void EnterDrink()
{
g_State = STATE_DRINK;
g_Timer = 180;
Dlg_Clear();
Dlg_Line(0, "A quiet moment passes.");
}
void EnterLeave()
{
g_State = STATE_LEAVE;
Steam_Hide();
DrawCup(FALSE);
Dlg_Clear();
Dlg_Line(0, "\"See you tomorrow.\"");
}
//=============================================================================
// STATE UPDATES (one call per frame)
//=============================================================================
void UpdateWait()
{
if (--g_Timer == 0)
EnterArrive();
}
void UpdateArrive()
{
g_CustX--;
// Small walk bob
Customer_Show(g_CustX, (g_CustX & 8) ? CUST_Y : CUST_Y - 1);
if (g_CustX <= SEAT_X)
{
Customer_Show(SEAT_X, CUST_Y);
EnterOrder();
}
}
void UpdateOrder()
{
if (g_SpaceHit)
EnterBrew();
}
void UpdateBrew()
{
// Needle sweeps back and forth
g_Needle += g_NeedleDir;
if (g_Needle <= 0) { g_Needle = 0; g_NeedleDir = 3; }
if (g_Needle >= GAUGE_W - 2){ g_Needle = GAUGE_W - 2; g_NeedleDir = -3; }
DrawGauge();
// Coffee slowly rises while the needle sweeps
g_Timer++;
if ((g_Timer & 15) == 0)
DrawFlask((u8)(g_Timer >> 4));
Steam_Show(SIPHON_X - 8, 58);
if (g_SpaceHit)
{
i16 dist = g_Needle + 1 - GAUGE_CENTER;
g_Overdone = (dist > 0);
if (dist < 0)
dist = -dist;
if (dist <= 3) g_Quality = 3;
else if (dist <= 10) g_Quality = 2;
else if (dist <= 24) g_Quality = 1;
else g_Quality = 0;
g_DayQuality += g_Quality;
EnterServe();
}
}
void UpdateServe()
{
Steam_Show(CUP_X - 4, 78);
if (--g_Timer == 0)
EnterDrink();
}
void UpdateDrink()
{
Steam_Show(CUP_X - 4, 78);
if (--g_Timer == 0)
EnterLeave();
}
void UpdateLeave()
{
g_CustX++;
Customer_Show(g_CustX, (g_CustX & 8) ? CUST_Y : CUST_Y - 1);
if (g_CustX >= DOOR_X)
{
Customer_Hide();
g_Save.served++;
g_DayServed++;
DrawStatusBar();
// Customers are the clock: the day ends when the last one leaves
if (g_DayServed >= CUSTOMERS_PER_DAY)
EnterEvening();
else
EnterWaitState();
}
}
//=============================================================================
// MAIN LOOP
//=============================================================================
//-----------------------------------------------------------------------------
// Program entry point
void main()
{
VDP_SetMode(VDP_MODE_SCREEN5);
VDP_SetColor(C_OUTLINE);
VDP_EnableVBlank(TRUE);
VDP_ClearVRAM();
// Palette (skip entry 0: transparent)
for (u8 i = 1; i < 16; i++)
VDP_SetPaletteEntry(i, g_Palette[i]);
// Sprites: 16x16, tables above page 1 (page 1 becomes the asset warehouse later)
VDP_EnableSprite(TRUE);
VDP_SetSpritePatternTable(0x17000);
VDP_SetSpriteAttributeTable(0x17A00);
VDP_SetSpriteFlag(VDP_SPRITE_SIZE_16);
VDP_LoadSpritePattern(g_SprCustFill, PAT_CUST_FILL, 4);
VDP_LoadSpritePattern(g_SprCustLine, PAT_CUST_LINE, 4);
VDP_LoadSpritePattern(g_SprSteamA, PAT_STEAM_A, 4);
VDP_LoadSpritePattern(g_SprSteamB, PAT_STEAM_B, 4);
VDP_HideAllSprites();
VDP_DisableSpritesFrom(3);
Print_SetBitmapFont(g_Font_MGL_Sample6);
// Explicit game-state init: don't rely on C initializers, the mapper
// crt0's data-segment setup has proven unreliable here.
g_Served = 0;
g_Ambience = 0;
g_Frame = 0;
g_SpaceHit = FALSE;
g_SpacePrev = 1; // treat SPACE as held at boot: no phantom first edge
// Save disk. A missing or blank disk is not an error the player has to
// deal with — it just means today is day one and nothing will persist.
Save_Reset();
Disk_Init();
Save_Load();
EnterMorning();
while (1)
{
Halt(); // Wait V-Blank
g_Frame++;
// Sample input immediately after Halt(): the BIOS ISR drives the PPI
// keyboard row selection too, so a read elsewhere in the frame can
// race with it and produce phantom edges.
// Atomic keyboard read: Keyboard_Read() is a row-select 'out' followed
// by an 'in'; if the BIOS ISR lands between them, its own matrix scan
// moves the row selection and the read returns garbage (phantom keys).
DisableInterrupt();
u8 raw = Keyboard_Read(KEY_ROW(KEY_SPACE));
EnableInterrupt();
u8 space = (raw & KEY_FLAG(KEY_SPACE)) == 0;
g_SpaceHit = space && !g_SpacePrev;
g_SpacePrev = space;
// Morning and evening are single-key card screens; only the open
// phase runs the serve loop's state machine.
if (g_Phase == PHASE_MORNING)
{
if (g_SpaceHit)
{
g_Phase = PHASE_OPEN;
DrawStatusBar();
EnterWaitState();
}
continue;
}
if (g_Phase == PHASE_EVENING)
{
if (g_SpaceHit)
AdvanceDay();
continue;
}
switch (g_State)
{
case STATE_WAIT: UpdateWait(); break;
case STATE_ARRIVE: UpdateArrive(); break;
case STATE_ORDER: UpdateOrder(); break;
case STATE_BREW: UpdateBrew(); break;
case STATE_SERVE: UpdateServe(); break;
case STATE_DRINK: UpdateDrink(); break;
case STATE_LEAVE: UpdateLeave(); break;
}
}
}