Files
mazegame/projects/kissaten_yugure/kissaten.c
T
jurjen.ladeniusandClaude Opus 5 45749775fa Scaffold Kissaten Yugure; add M2 day spine, plan and script
Stage 1 (shop scene, customer, siphon brew minigame, serve loop) and the
M2 day spine: day counter, three-phase day, event-driven clock, journal
line, and a SaveState with magic/version/checksum.

Saving to disk is NOT working yet. A cartridge ROM can reach the Disk ROM
only from RAM (both PHYDIO and CALSLT switch page 1 out from under the
caller), and ROMDelayBoot is required so the Disk ROM's INIT runs at all.
With both in place the disk is detected, but sector I/O returns carry-clear
without transferring — proven with a sentinel. Probe now verifies the
buffer actually changed, so a non-working disk degrades to "no save"
rather than corrupting anything. Full findings in the project CLAUDE.md.

Also documented: keyboard reads need interrupt protection (the BIOS ISR
scans the matrix too), and BankedCall must stay off until banked data
exists.

Docs: PLAN.md (ten milestones, per-stage requirements, decision queue),
SCRIPT.md (all four character arcs, 60 beats, checked against the
26-character box), and amendment notes in DESIGN.md.

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

924 lines
28 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"
// 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
u8 g_DiskErr = 0; // last disk error code (0 = fine)
bool g_HasDisk = FALSE; // a usable save disk was found at boot
//=============================================================================
// 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;
// One 512-byte sector staging buffer. Must live in RAM (page 3) — PHYDIO
// writes straight into it, and pages 1-2 are ROM on a cartridge target.
u8 g_Sector[512];
// Sector holding the save, derived from the disk's own BPB at boot rather
// than hardcoded, so a 360K disk works as well as the 720K one.
u16 g_SaveSector = 14;
//=============================================================================
// DISK ACCESS
//=============================================================================
// Number of disk drives, maintained by the Disk ROM at boot. Zero means there
// is no disk interface at all — and on such a machine (C-BIOS, for one) the
// PHYDIO hook is not patched and calling it is not safe.
#define M_NMBDRV_ADDR 0xF347
#define M_MASTER_ADDR 0xF348 // main Disk ROM slot, 0 if there is none
// PHYDIO trampoline, executed from RAM.
//
// PHYDIO (Main-ROM 0144h) reaches the Disk ROM through an inter-slot call, and
// the Disk ROM lives at 4000h — the same page as this cartridge. For the
// duration of the call our ROM is switched out of page 1, so the instruction
// making the call must not itself be in page 1. Copying these twelve bytes to
// RAM and calling *them* keeps the call site alive across the switch.
u8 g_DiskStub[21];
void Disk_InitStub()
{
// Calls DSKIO (Disk ROM 4010h) through CALSLT rather than the Main ROM's
// PHYDIO wrapper at 0144h: PHYDIO jumps to a RAM hook that this machine
// leaves unpatched, so it returns carry-clear — reporting success while
// never touching the buffer. Going straight at the Disk ROM avoids it.
//
// IY high byte must hold the slot ID, and MASTER lives at F348h, so a
// word load from F347h lands it in IYh exactly as CALSLT wants.
static const u8 code[21] =
{
0xF3, // di
0xFD, 0x2A, 0x47, 0xF3, // ld iy, (0xF347) ; IYh = MASTER
0xDD, 0x21, 0x10, 0x40, // ld ix, #0x4010 ; DSKIO
0xCD, 0x1C, 0x00, // call 0x001C ; CALSLT
0x38, 0x04, // jr c, err
0x2E, 0x00, // ld l, #0 ; success
0xFB, // ei
0xC9, // ret
0x6F, // err: ld l, a ; BIOS error code
0xFB, // ei
0xC9, // ret
};
Mem_Copy(code, g_DiskStub, sizeof(code));
}
//-----------------------------------------------------------------------------
// Absolute sector read/write into g_Sector.
// carry clear = read, carry set = write
// A=drive B=sector count C=media ID DE=first sector HL=buffer
// Returns the BIOS error code, or 0 on success.
u8 Disk_Access(u16 sector, u8 write) __NAKED
{
sector; // on stack
write;
__asm
push ix
ld ix, #0
add ix, sp
ld e, 4(ix) // sector low
ld d, 5(ix) // sector high
ld a, 6(ix) // write flag
ld hl, #_g_Sector
ld b, #1 // one sector
ld c, #0xF9 // media ID: 720K DS/DD
or a // test flag; also clears carry (= read)
jr z, phydio_go
scf // carry set = write
phydio_go:
ld a, #0 // drive A:. 'ld a,n' preserves flags
call _g_DiskStub // -> PHYDIO, from RAM. Returns code in L
pop ix
ret
__endasm;
}
//-----------------------------------------------------------------------------
// Read the BIOS Parameter Block and work out where the data area starts.
// Returns FALSE if no disk responded at all.
bool Disk_Probe()
{
u8 drives = *(const u8*)M_NMBDRV_ADDR;
// Both of these are set by the Disk ROM's INIT. Without them the entry
// points below are not safe to call — on C-BIOS this work area is never
// initialised at all, and calling anyway black-screens the machine.
if (drives == 0 || drives > 8)
return FALSE;
if (*(const u8*)M_MASTER_ADDR == 0)
return FALSE;
Disk_InitStub();
// Sentinel: the disk entry points can return carry-clear ("success")
// without having touched the buffer, so a clean error code is not on its
// own proof that a read happened. Verify the buffer actually changed.
Mem_Set(0xAA, g_Sector, 16);
g_DiskErr = Disk_Access(0, 0);
if (g_DiskErr != 0)
return FALSE;
if (g_Sector[0] == 0xAA && g_Sector[11] == 0xAA)
return FALSE; // nothing was read; treat the disk as unusable
u16 bytesPerSec = g_Sector[11] | ((u16)g_Sector[12] << 8);
u16 reserved = g_Sector[14] | ((u16)g_Sector[15] << 8);
u8 numFATs = g_Sector[16];
u16 rootEntries = g_Sector[17] | ((u16)g_Sector[18] << 8);
u16 secPerFAT = g_Sector[22] | ((u16)g_Sector[23] << 8);
if (bytesPerSec != 512) // anything else is not a disk we made
return FALSE;
// Root directory occupies ceil(entries * 32 / 512) sectors
u16 rootSecs = (u16)((rootEntries + 15) >> 4);
g_SaveSector = reserved + (u16)numFATs * secPerFAT + rootSecs;
return TRUE;
}
//-----------------------------------------------------------------------------
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);
}
//-----------------------------------------------------------------------------
// TRUE if a valid save was loaded; FALSE means "start a new game", which is
// also what a blank disk gives us — no error path for the player to hit.
bool Save_Load()
{
if (!g_HasDisk)
return FALSE;
g_DiskErr = Disk_Access(g_SaveSector, 0);
if (g_DiskErr != 0)
return FALSE;
SaveState* s = (SaveState*)g_Sector;
if (s->magic != SAVE_MAGIC || s->version != SAVE_VERSION)
return FALSE;
if (s->checksum != Save_Checksum(s))
return FALSE;
Mem_Copy(s, &g_Save, sizeof(SaveState));
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);
Mem_Set(0, g_Sector, sizeof(g_Sector));
Mem_Copy(&g_Save, g_Sector, sizeof(SaveState));
g_DiskErr = Disk_Access(g_SaveSector, 1);
return (g_DiskErr == 0);
}
//-----------------------------------------------------------------------------
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.");
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.");
if (Save_Write())
Dlg_Prompt("[SPACE] sleep (saved)");
else if (!g_HasDisk)
Dlg_Prompt("[SPACE] sleep (no disk)");
else
Dlg_Prompt("[SPACE] sleep (SAVE FAILED)");
}
//-----------------------------------------------------------------------------
void AdvanceDay()
{
g_Save.day++;
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
g_DiskErr = 0;
g_SaveSector = 14;
// 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();
g_HasDisk = Disk_Probe();
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;
}
}
}