phantasia

Phantasia - 2D SDL3 RPG prototype.
git clone git://git.beep.wimdupont.com/phantasia.git
Log | Files | Refs | README | LICENSE

commit b3a1ae00b01e5a973afd09e8a4b19bb2ce82e9db
parent 71de30d9640a8d2a00abf692b6e5e901f21b6db8
Author: beep <beep@wimdupont.com>
Date:   Thu, 30 Jul 2026 11:59:27 +0000

Add scalable world persistence

Diffstat:
M.gitignore | 2++
MMakefile | 1+
MREADME.adoc | 6++++++
Mconfig.def.h | 3++-
Mphantasia.6 | 3+++
Msrc/engine/area.c | 153++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/engine/area.h | 1+
Asrc/engine/save.c | 344+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/engine/save.h | 14++++++++++++++
Msrc/engine/world.c | 15+++++++++++++++
Msrc/engine/world.h | 15++++++++++++---
Msrc/game/main.c | 47++++++++++++++++++++++++++++++++++++++++-------
Msrc/game/presentation.c | 41+++++++++++++++++++++++++++++++++++++++++
Msrc/game/presentation.h | 14++++++++++++++
Msrc/tests/render.c | 22++++++++++++++++++----
Msrc/tests/smoke.c | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
16 files changed, 771 insertions(+), 25 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,5 +1,7 @@ bin/ obj/ config.h +phantasia.sav +phantasia.sav.tmp *.o *.d diff --git a/Makefile b/Makefile @@ -33,6 +33,7 @@ SRC = \ src/engine/audio.c \ src/engine/battle.c \ src/engine/render.c \ + src/engine/save.c \ src/engine/world.c \ src/game/content.c \ src/game/presentation.c \ diff --git a/README.adoc b/README.adoc @@ -93,6 +93,12 @@ With SDL3 available: man ./phantasia.6 ---- +At startup, choose Continue to restore the autosave or New Game to replace it. +The game saves atomically on area transitions and clean exit. The save path is +configured by `PH_SAVE_PATH`; static map tiles are reloaded from game data while +player progression and each visited area's mutable actors and ground items are +persisted. + == Maps Sources live in `data/maps/*.txt`. `make` compiles them with `bin/ph-mapc` into diff --git a/config.def.h b/config.def.h @@ -1,12 +1,13 @@ #ifndef CONFIG_H #define CONFIG_H -#define PH_CONFIG_VERSION 14 +#define PH_CONFIG_VERSION 15 #define PH_VIEW_W 320 #define PH_VIEW_H 240 #define PH_SCALE 3 #define PH_WINDOW_TITLE "phantasia" +#define PH_SAVE_PATH "phantasia.sav" #define PH_MAX_FRAME_TIME 0.05f #define PH_FRAME_DELAY_MS 16 #define PH_MUSIC_GAIN 0.24f diff --git a/phantasia.6 b/phantasia.6 @@ -12,6 +12,9 @@ is a small top-down RPG prototype with connected map areas, item pickup, shops, visible in-world encounters, independently roaming and pursuing monsters, and turn-based battles. +At startup, Continue restores the autosave and New Game starts fresh. The game +saves player progression and mutable area state on area transitions and clean +exit. .Sh KEYBINDINGS .Bl -tag -width "Arrow keys / WASD / HJKL" .It Arrow keys / WASD / HJKL diff --git a/src/engine/area.c b/src/engine/area.c @@ -1,6 +1,8 @@ #include "engine/area.h" #include <math.h> +#include <stdlib.h> +#include <string.h> const PhAreaDef * ph_area_catalog_area(const PhAreaCatalog *catalog, int area_id) @@ -102,25 +104,152 @@ ph_world_add_area_content(PhWorld *world, const PhArea *area, return 0; } +static PhAreaState * +ph_world_area_state(PhWorld *world, int area_id) +{ + int low = 0; + int high = world->initialized_area_count; + + while (low < high) { + int middle = low + (high - low) / 2; + if (world->area_states[middle].area_id < area_id) low = middle + 1; + else high = middle; + } + if (low < world->initialized_area_count && + world->area_states[low].area_id == area_id) return &world->area_states[low]; + return NULL; +} + +static PhAreaState * +ph_world_add_area_state(PhWorld *world, int area_id) +{ + PhAreaState *states; + int capacity; + int position = 0; + + if (ph_world_area_state(world, area_id)) return NULL; + if (world->initialized_area_count >= world->area_state_capacity) { + capacity = world->area_state_capacity ? world->area_state_capacity * 2 : 8; + states = realloc(world->area_states, (size_t)capacity * sizeof(*states)); + if (!states) return NULL; + world->area_states = states; + world->area_state_capacity = capacity; + } + while (position < world->initialized_area_count && + world->area_states[position].area_id < area_id) ++position; + memmove(&world->area_states[position + 1], &world->area_states[position], + (size_t)(world->initialized_area_count - position) * sizeof(*states)); + states = &world->area_states[position]; + ++world->initialized_area_count; + memset(states, 0, sizeof(*states)); + states->area_id = area_id; + return states; +} + +int +ph_world_capture_area(PhWorld *world) +{ + PhAreaState *state; + PhEntity *entities = NULL; + PhGroundItem *items = NULL; + int entity_count = 0; + int item_count = 0; + int i; + + if (!world) return -1; + state = ph_world_area_state(world, world->area_id); + if (!state && !(state = ph_world_add_area_state(world, world->area_id))) + return -1; + for (i = 0; i < world->entity_count; ++i) + if (i != world->player_index && world->entities[i].active && + world->entities[i].area_id == world->area_id) ++entity_count; + for (i = 0; i < world->ground_item_count; ++i) + if (world->ground_items[i].active && + world->ground_items[i].area_id == world->area_id) ++item_count; + if (entity_count && !(entities = malloc((size_t)entity_count * sizeof(*entities)))) + return -1; + if (item_count && !(items = malloc((size_t)item_count * sizeof(*items)))) { + free(entities); + return -1; + } + entity_count = 0; + item_count = 0; + for (i = 0; i < world->entity_count; ++i) + if (i != world->player_index && world->entities[i].active && + world->entities[i].area_id == world->area_id) + entities[entity_count++] = world->entities[i]; + for (i = 0; i < world->ground_item_count; ++i) + if (world->ground_items[i].active && + world->ground_items[i].area_id == world->area_id) + items[item_count++] = world->ground_items[i]; + free(state->entities); + free(state->ground_items); + state->entities = entities; + state->ground_items = items; + state->entity_count = entity_count; + state->ground_item_count = item_count; + return 0; +} + +static void +ph_world_clear_area_content(PhWorld *world, int *player) +{ + PhEntity saved_player; + int has_player = world->player_index >= 0 && + world->player_index < world->entity_count; + + if (has_player) saved_player = world->entities[world->player_index]; + memset(world->entities, 0, sizeof(world->entities)); + memset(world->ground_items, 0, sizeof(world->ground_items)); + world->entity_count = 0; + world->ground_item_count = 0; + world->player_index = -1; + *player = -1; + if (has_player) { + world->entities[0] = saved_player; + world->entity_count = 1; + world->player_index = *player = 0; + } +} + +static int +ph_world_restore_area(PhWorld *world, PhAreaState *state, int *player) +{ + if (!state || state->entity_count > PH_MAX_ENTITIES - world->entity_count || + state->ground_item_count > PH_MAX_GROUND_ITEMS) return -1; + if (state->entity_count) { + memcpy(&world->entities[world->entity_count], state->entities, + (size_t)state->entity_count * sizeof(*state->entities)); + world->entity_count += state->entity_count; + } + if (state->ground_item_count) { + memcpy(world->ground_items, state->ground_items, + (size_t)state->ground_item_count * sizeof(*state->ground_items)); + world->ground_item_count = state->ground_item_count; + } + *player = world->player_index; + return 0; +} + int ph_world_prepare_area(PhWorld *world, const PhArea *area, const PhAreaCatalog *catalog, int area_id, int *player) { + PhAreaState *state; int entity_count = world->entity_count; int ground_item_count = world->ground_item_count; int old_player = *player; - int i; - for (i = 0; i < world->initialized_area_count; ++i) - if (world->initialized_areas[i] == area_id) return 0; - if (world->initialized_area_count >= PH_MAX_AREAS || - ph_world_add_area_content(world, area, catalog, area_id, player) < 0) { + state = ph_world_area_state(world, area_id); + if (state && area_id == world->area_id) return 0; + if (state) return ph_world_restore_area(world, state, player); + if (ph_world_add_area_content(world, area, catalog, area_id, player) < 0 || + !ph_world_add_area_state(world, area_id)) { world->entity_count = entity_count; world->ground_item_count = ground_item_count; *player = old_player; return -1; } - world->initialized_areas[world->initialized_area_count++] = area_id; return 0; } @@ -155,9 +284,17 @@ ph_world_update_portal(PhWorld *world, const PhAreaCatalog *catalog) if (area.markers[j].symbol == portal->destination_symbol && arrival && arrival->kind == PH_MARKER_ARRIVAL) break; } - if (j == area.marker_count || - ph_world_prepare_area(world, &area, catalog, + if (j == area.marker_count || ph_world_capture_area(world) < 0) { + ph_area_free(&area); + return -1; + } + ph_world_clear_area_content(world, &player_index); + if (ph_world_prepare_area(world, &area, catalog, portal->destination_area_id, &player_index) < 0) { + PhAreaState *old = ph_world_area_state(world, world->area_id); + + ph_world_clear_area_content(world, &player_index); + ph_world_restore_area(world, old, &player_index); ph_area_free(&area); return -1; } diff --git a/src/engine/area.h b/src/engine/area.h @@ -53,6 +53,7 @@ int ph_world_start(PhWorld *world, const PhWorldSetup *setup, float viewport_w, float viewport_h); int ph_world_prepare_area(PhWorld *world, const PhArea *area, const PhAreaCatalog *catalog, int area_id, int *player); +int ph_world_capture_area(PhWorld *world); int ph_world_update_portal(PhWorld *world, const PhAreaCatalog *catalog); #endif diff --git a/src/engine/save.c b/src/engine/save.c @@ -0,0 +1,344 @@ +#define _POSIX_C_SOURCE 200809L + +#include "engine/save.h" + +#include <errno.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +enum { PH_SAVE_MAGIC = 0x50485356, PH_SAVE_RECORD_LIMIT = 1000000 }; + +static int +ph_write_u32(FILE *fp, uint32_t value) +{ + unsigned char bytes[4] = { + (unsigned char)value, (unsigned char)(value >> 8), + (unsigned char)(value >> 16), (unsigned char)(value >> 24), + }; + return fwrite(bytes, 1, sizeof(bytes), fp) == sizeof(bytes) ? 0 : -1; +} + +static int +ph_read_u32(FILE *fp, uint32_t *value) +{ + unsigned char bytes[4]; + + if (fread(bytes, 1, sizeof(bytes), fp) != sizeof(bytes)) return -1; + *value = (uint32_t)bytes[0] | (uint32_t)bytes[1] << 8 | + (uint32_t)bytes[2] << 16 | (uint32_t)bytes[3] << 24; + return 0; +} + +static int ph_write_int(FILE *fp, int value) { return ph_write_u32(fp, (uint32_t)value); } + +static int +ph_read_int(FILE *fp, int *value) +{ + uint32_t raw; + if (ph_read_u32(fp, &raw) < 0) return -1; + *value = (int)(int32_t)raw; + return 0; +} + +static int +ph_write_float(FILE *fp, float value) +{ + uint32_t raw; + memcpy(&raw, &value, sizeof(raw)); + return ph_write_u32(fp, raw); +} + +static int +ph_read_float(FILE *fp, float *value) +{ + uint32_t raw; + if (ph_read_u32(fp, &raw) < 0) return -1; + memcpy(value, &raw, sizeof(raw)); + return 0; +} + +static int +ph_write_vec(FILE *fp, PhVec2 value) +{ + return ph_write_float(fp, value.x) < 0 || ph_write_float(fp, value.y) < 0 ? -1 : 0; +} + +static int +ph_read_vec(FILE *fp, PhVec2 *value) +{ + return ph_read_float(fp, &value->x) < 0 || ph_read_float(fp, &value->y) < 0 ? -1 : 0; +} + +static int +ph_write_entity(FILE *fp, const PhEntity *entity) +{ + int i; + + if (ph_write_int(fp, entity->type_id) < 0 || ph_write_vec(fp, entity->pos) < 0 || + ph_write_vec(fp, entity->motion.target) < 0 || + ph_write_float(fp, entity->motion.credit) < 0 || + ph_write_float(fp, entity->motion.animation_distance) < 0 || + ph_write_int(fp, entity->motion.facing_x) < 0 || + ph_write_int(fp, entity->motion.facing_y) < 0 || + ph_write_int(fp, entity->motion.moving) < 0 || + ph_write_vec(fp, entity->behavior.home) < 0 || + ph_write_vec(fp, entity->behavior.last_seen) < 0 || + ph_write_int(fp, entity->behavior.territory_radius) < 0 || + ph_write_int(fp, entity->behavior.chase_turns) < 0 || + ph_write_int(fp, entity->vitals.hp) < 0 || + ph_write_int(fp, entity->vitals.mp) < 0 || + ph_write_int(fp, entity->xp) < 0 || ph_write_int(fp, entity->gold) < 0 || + ph_write_int(fp, entity->accuracy.value) < 0 || + ph_write_int(fp, entity->accuracy.turns) < 0) return -1; + for (i = 0; i < PH_EQUIP_COUNT; ++i) + if (ph_write_int(fp, entity->equipment[i]) < 0) return -1; + return 0; +} + +static int +ph_read_entity(FILE *fp, PhEntity *entity, int area_id) +{ + int i; + + memset(entity, 0, sizeof(*entity)); + if (ph_read_int(fp, &entity->type_id) < 0 || ph_read_vec(fp, &entity->pos) < 0 || + ph_read_vec(fp, &entity->motion.target) < 0 || + ph_read_float(fp, &entity->motion.credit) < 0 || + ph_read_float(fp, &entity->motion.animation_distance) < 0 || + ph_read_int(fp, &entity->motion.facing_x) < 0 || + ph_read_int(fp, &entity->motion.facing_y) < 0 || + ph_read_int(fp, &entity->motion.moving) < 0 || + ph_read_vec(fp, &entity->behavior.home) < 0 || + ph_read_vec(fp, &entity->behavior.last_seen) < 0 || + ph_read_int(fp, &entity->behavior.territory_radius) < 0 || + ph_read_int(fp, &entity->behavior.chase_turns) < 0 || + ph_read_int(fp, &entity->vitals.hp) < 0 || + ph_read_int(fp, &entity->vitals.mp) < 0 || + ph_read_int(fp, &entity->xp) < 0 || ph_read_int(fp, &entity->gold) < 0 || + ph_read_int(fp, &entity->accuracy.value) < 0 || + ph_read_int(fp, &entity->accuracy.turns) < 0) return -1; + for (i = 0; i < PH_EQUIP_COUNT; ++i) + if (ph_read_int(fp, &entity->equipment[i]) < 0) return -1; + entity->area_id = area_id; + entity->active = 1; + return 0; +} + +static int +ph_write_item(FILE *fp, const PhGroundItem *item) +{ + return ph_write_int(fp, item->item_id) < 0 || ph_write_vec(fp, item->pos) < 0 || + ph_write_int(fp, item->amount) < 0 ? -1 : 0; +} + +static int +ph_read_item(FILE *fp, PhGroundItem *item, int area_id) +{ + memset(item, 0, sizeof(*item)); + if (ph_read_int(fp, &item->item_id) < 0 || ph_read_vec(fp, &item->pos) < 0 || + ph_read_int(fp, &item->amount) < 0) return -1; + item->area_id = area_id; + item->active = 1; + return 0; +} + +static int +ph_save_stream(FILE *fp, PhWorld *world, const PhSpellBook *spells) +{ + const PhEntity *player = ph_world_player(world); + int inventory_count = 0; + int i; + int j; + + if (!player || ph_world_capture_area(world) < 0) return -1; + for (i = 0; i < world->content.item_count; ++i) + if (world->inventory[i] > 0) ++inventory_count; + if (ph_write_u32(fp, PH_SAVE_MAGIC) < 0 || ph_write_u32(fp, PH_SAVE_VERSION) < 0 || + ph_write_int(fp, world->area_id) < 0 || ph_write_entity(fp, player) < 0 || + ph_write_int(fp, inventory_count) < 0) return -1; + for (i = 0; i < world->content.item_count; ++i) { + if (world->inventory[i] <= 0) continue; + if (ph_write_int(fp, world->content.items[i].id) < 0 || + ph_write_int(fp, world->inventory[i]) < 0) return -1; + } + if (ph_write_int(fp, spells->learned_count) < 0) return -1; + for (i = 0; i < spells->learned_count; ++i) + if (ph_write_int(fp, spells->learned[i]) < 0) return -1; + if (ph_write_int(fp, world->initialized_area_count) < 0) return -1; + for (i = 0; i < world->initialized_area_count; ++i) { + const PhAreaState *state = &world->area_states[i]; + + if (ph_write_int(fp, state->area_id) < 0 || + ph_write_int(fp, state->entity_count) < 0 || + ph_write_int(fp, state->ground_item_count) < 0) return -1; + for (j = 0; j < state->entity_count; ++j) + if (ph_write_entity(fp, &state->entities[j]) < 0) return -1; + for (j = 0; j < state->ground_item_count; ++j) + if (ph_write_item(fp, &state->ground_items[j]) < 0) return -1; + } + return 0; +} + +int +ph_save_write(const char *path, PhWorld *world, const PhSpellBook *spells) +{ + FILE *fp; + char *temporary; + size_t length; + int result = -1; + + if (!path || !world || !spells || spells->learned_count < 0 || + spells->learned_count > PH_SPELL_MAX) return -1; + length = strlen(path); + temporary = malloc(length + 5); + if (!temporary) return -1; + memcpy(temporary, path, length); + memcpy(temporary + length, ".tmp", 5); + fp = fopen(temporary, "wb"); + if (!fp) goto done; + if (ph_save_stream(fp, world, spells) == 0 && fflush(fp) == 0 && + fsync(fileno(fp)) == 0) result = 0; + if (fclose(fp) != 0) result = -1; + fp = NULL; + if (result == 0 && rename(temporary, path) != 0) result = -1; + if (result < 0) remove(temporary); +done: + free(temporary); + return result; +} + +static int +ph_item_index(const PhWorld *world, int item_id) +{ + int i; + for (i = 0; i < world->content.item_count; ++i) + if (world->content.items[i].id == item_id) return i; + return -1; +} + +static int +ph_area_known(const PhAreaCatalog *catalog, int area_id) +{ + return ph_area_catalog_area(catalog, area_id) != NULL; +} + +static int +ph_load_stream(FILE *fp, PhWorld *world, const PhWorldSetup *setup, + PhSpellBook *spells, float viewport_w, float viewport_h) +{ + uint32_t magic; + uint32_t version; + PhArea area; + PhEntity player; + const PhEntityDef *player_def; + int area_id; + int count; + int i; + int j; + + if (ph_read_u32(fp, &magic) < 0 || ph_read_u32(fp, &version) < 0 || + magic != PH_SAVE_MAGIC || version != PH_SAVE_VERSION || + ph_read_int(fp, &area_id) < 0 || !ph_area_known(setup->areas, area_id) || + ph_read_entity(fp, &player, area_id) < 0 || + ph_area_catalog_load(&area, setup->areas, area_id) < 0) return -1; + if (ph_world_init(world, area, area_id, setup->content, viewport_w, viewport_h) < 0) { + ph_area_free(&area); + return -1; + } + player_def = ph_world_entity_def(world, player.type_id); + if (!player_def || player_def->kind != PH_ENTITY_PLAYER) goto fail; + world->entities[0] = player; + world->entity_count = 1; + world->player_index = 0; + if (ph_read_int(fp, &count) < 0 || count < 0 || count > world->content.item_count) + goto fail; + for (i = 0; i < count; ++i) { + int item_id; + int amount; + int index; + if (ph_read_int(fp, &item_id) < 0 || ph_read_int(fp, &amount) < 0 || + amount <= 0 || (index = ph_item_index(world, item_id)) < 0 || + world->inventory[index] != 0) goto fail; + world->inventory[index] = amount; + } + if (ph_read_int(fp, &count) < 0 || count < 0 || count > PH_SPELL_MAX) goto fail; + spells->learned_count = 0; + for (i = 0; i < count; ++i) { + int spell_id; + if (ph_read_int(fp, &spell_id) < 0 || + ph_spellbook_learn(spells, spell_id) < 0) goto fail; + } + if (ph_read_int(fp, &count) < 0 || count <= 0 || count > PH_SAVE_RECORD_LIMIT) + goto fail; + world->area_states = calloc((size_t)count, sizeof(*world->area_states)); + if (!world->area_states) goto fail; + world->area_state_capacity = count; + for (i = 0; i < count; ++i) { + PhAreaState *state = &world->area_states[i]; + if (ph_read_int(fp, &state->area_id) < 0 || + !ph_area_known(setup->areas, state->area_id) || + ph_read_int(fp, &state->entity_count) < 0 || state->entity_count < 0 || + state->entity_count > PH_MAX_ENTITIES - 1 || + ph_read_int(fp, &state->ground_item_count) < 0 || + state->ground_item_count < 0 || + state->ground_item_count > PH_MAX_GROUND_ITEMS) goto fail; + if (i > 0 && world->area_states[i - 1].area_id >= state->area_id) goto fail; + world->initialized_area_count = i + 1; + if (state->entity_count) { + state->entities = calloc((size_t)state->entity_count, sizeof(*state->entities)); + if (!state->entities) goto fail; + } + if (state->ground_item_count) { + state->ground_items = calloc((size_t)state->ground_item_count, + sizeof(*state->ground_items)); + if (!state->ground_items) goto fail; + } + for (j = 0; j < state->entity_count; ++j) + if (ph_read_entity(fp, &state->entities[j], state->area_id) < 0 || + !ph_world_entity_def(world, state->entities[j].type_id)) goto fail; + for (j = 0; j < state->ground_item_count; ++j) + if (ph_read_item(fp, &state->ground_items[j], state->area_id) < 0 || + !ph_world_item_def(world, state->ground_items[j].item_id) || + state->ground_items[j].amount <= 0) goto fail; + } + for (i = 0; i < world->initialized_area_count; ++i) + if (world->area_states[i].area_id == area_id) break; + if (i == world->initialized_area_count) goto fail; + if (world->area_states[i].entity_count) { + memcpy(&world->entities[1], world->area_states[i].entities, + (size_t)world->area_states[i].entity_count * sizeof(*world->entities)); + world->entity_count += world->area_states[i].entity_count; + } + if (world->area_states[i].ground_item_count) { + memcpy(world->ground_items, world->area_states[i].ground_items, + (size_t)world->area_states[i].ground_item_count * sizeof(*world->ground_items)); + world->ground_item_count = world->area_states[i].ground_item_count; + } + ph_world_set_player(world, 0); + return 0; + +fail: + ph_world_destroy(world); + return -1; +} + +int +ph_save_load(const char *path, PhWorld *world, const PhWorldSetup *setup, + PhSpellBook *spells, float viewport_w, float viewport_h) +{ + FILE *fp; + int result; + + if (!path || !world || !setup || !setup->areas || !spells) return -1; + fp = fopen(path, "rb"); + if (!fp) return errno == ENOENT ? 0 : -1; + result = ph_load_stream(fp, world, setup, spells, viewport_w, viewport_h); + if (fclose(fp) != 0) { + if (result == 0) ph_world_destroy(world); + result = -1; + } + return result < 0 ? -1 : 1; +} diff --git a/src/engine/save.h b/src/engine/save.h @@ -0,0 +1,14 @@ +#ifndef PH_ENGINE_SAVE_H +#define PH_ENGINE_SAVE_H + +#include "engine/area.h" +#include "engine/battle.h" + +enum { PH_SAVE_VERSION = 1 }; + +int ph_save_write(const char *path, PhWorld *world, const PhSpellBook *spells); +/* Returns 1 when loaded, 0 when no save exists, and -1 for an invalid save. */ +int ph_save_load(const char *path, PhWorld *world, const PhWorldSetup *setup, + PhSpellBook *spells, float viewport_w, float viewport_h); + +#endif diff --git a/src/engine/world.c b/src/engine/world.c @@ -621,6 +621,21 @@ ph_area_free(PhArea *area) memset(area, 0, sizeof(*area)); } +void +ph_world_destroy(PhWorld *world) +{ + int i; + + if (!world) return; + ph_area_free(&world->area); + for (i = 0; i < world->initialized_area_count; ++i) { + free(world->area_states[i].entities); + free(world->area_states[i].ground_items); + } + free(world->area_states); + memset(world, 0, sizeof(*world)); +} + int ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos, int territory_radius, int area_id) diff --git a/src/engine/world.h b/src/engine/world.h @@ -3,7 +3,7 @@ #include <stddef.h> -#define PH_CONFIG_API_VERSION 14 +#define PH_CONFIG_API_VERSION 15 enum { PH_MAP_MAGIC = 0x50484d50, @@ -12,7 +12,6 @@ enum { PH_TILE_SIZE = 16, PH_MAX_ENTITY_TYPES = 32, PH_MAX_ITEM_TYPES = 64, - PH_MAX_AREAS = 32, PH_MAX_ENTITIES = 128, PH_MAX_GROUND_ITEMS = 128, PH_MAX_WORLD_EVENTS = 64, @@ -203,6 +202,14 @@ typedef struct { } PhGroundItem; typedef struct { + int area_id; + PhEntity *entities; + PhGroundItem *ground_items; + int entity_count; + int ground_item_count; +} PhAreaState; + +typedef struct { unsigned char symbol; int tile_x; int tile_y; @@ -237,7 +244,8 @@ typedef struct { int entity_count; int ground_item_count; int inventory[PH_MAX_ITEM_TYPES]; - int initialized_areas[PH_MAX_AREAS]; + PhAreaState *area_states; + int area_state_capacity; int initialized_area_count; int encounter_index; int interaction_id; @@ -256,6 +264,7 @@ typedef struct { int ph_area_load(PhArea *area, const char *path); void ph_area_free(PhArea *area); +void ph_world_destroy(PhWorld *world); int ph_world_init(PhWorld *world, PhArea area, int area_id, PhWorldCatalog content, float viewport_w, float viewport_h); diff --git a/src/game/main.c b/src/game/main.c @@ -1,6 +1,7 @@ #include "engine/battle.h" #include "engine/area.h" #include "engine/audio.h" +#include "engine/save.h" #include "game/content.h" #include "game/presentation.h" @@ -27,12 +28,13 @@ ph_game_music(const PhGameContext *context) ph_game_area_music(context->world->area_id) : PH_MUSIC_BATTLE; } -static void +static int ph_update_game(PhGameContext *context, PhInput input, float dt) { PhWorld *world = context->world; PhGameMode *game = context->mode; PhBattleResult result = PH_BATTLE_CONTINUE; + int old_area = world->area_id; if (*game == PH_GAME_WORLD && context->menu == PH_MENU_NONE) { ph_world_tick(world, input, dt); @@ -51,6 +53,7 @@ ph_update_game(PhGameContext *context, PhInput input, float dt) if (result == PH_BATTLE_READY) *game = PH_GAME_BATTLE; else if (result == PH_BATTLE_VICTORY) *game = PH_GAME_VICTORY; else if (result == PH_BATTLE_DEFEAT) *game = PH_GAME_OVER; + return world->area_id != old_area; } static int @@ -132,6 +135,7 @@ ph_run_game(void) PhSpellbookUi spellbook_ui = { 0 }; PhShopUi shop_ui = { 0 }; PhOptionsUi options_ui = { .music_enabled = 1, .effects_enabled = 1 }; + PhStartUi start_ui = { 0 }; PhGameMode game = PH_GAME_WORLD; PhBattle battle = { 0 }; PhSpellBook spells = ph_player_spellbook; @@ -142,6 +146,7 @@ ph_run_game(void) .spellbook = &spellbook_ui, .shop = &shop_ui, .options = &options_ui, + .start = &start_ui, .mode = &game, .battle = &battle, .spells = &spells, @@ -149,19 +154,28 @@ ph_run_game(void) Uint64 last_ticks; int running = 1; int audio_ready = 0; - PhMenu menu = PH_MENU_NONE; + PhMenu menu = PH_MENU_START; + int save_status; if (!SDL_Init(SDL_INIT_VIDEO)) { fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); return 1; } - if (ph_world_start(&world, &ph_game_world, + save_status = ph_save_load(PH_SAVE_PATH, &world, &ph_game_world, &spells, + (float)PH_VIEW_W, (float)PH_VIEW_H); + if (save_status < 0) { + fprintf(stderr, "save is invalid; starting a new game\n"); + spells = ph_player_spellbook; + } + if (save_status <= 0 && ph_world_start(&world, &ph_game_world, (float)PH_VIEW_W, (float)PH_VIEW_H) < 0) { SDL_Quit(); return 1; } + start_ui.can_continue = save_status > 0; + start_ui.selected = start_ui.can_continue ? 0 : 1; if (ph_create_window_renderer(&window, &renderer) < 0) { - ph_area_free(&world.area); + ph_world_destroy(&world); SDL_Quit(); return 1; } @@ -169,7 +183,7 @@ ph_run_game(void) ph_asset_defs, PH_ASSET_COUNT) < 0) { SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); - ph_area_free(&world.area); + ph_world_destroy(&world); SDL_Quit(); return 1; } @@ -193,6 +207,21 @@ ph_run_game(void) if (event.type == SDL_EVENT_QUIT) { running = 0; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && + menu == PH_MENU_START) { + PhStartAction action = ph_start_key(&start_ui, event.key.scancode); + + if (action == PH_START_CONTINUE) menu = PH_MENU_NONE; + else if (action == PH_START_NEW) { + ph_world_destroy(&world); + spells = ph_player_spellbook; + if (ph_world_start(&world, &ph_game_world, + (float)PH_VIEW_W, (float)PH_VIEW_H) < 0) running = 0; + else { + remove(PH_SAVE_PATH); + menu = PH_MENU_NONE; + } + } + } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && menu == PH_MENU_MAIN) { PhOptionsAction action; @@ -305,7 +334,9 @@ ph_run_game(void) last_ticks = now_ticks; input = ph_read_input(SDL_GetKeyboardState(NULL)); context.menu = menu; - ph_update_game(&context, input, dt); + if (ph_update_game(&context, input, dt) && + ph_save_write(PH_SAVE_PATH, &world, &spells) < 0) + fprintf(stderr, "failed to autosave\n"); { int battle_effect = ph_game_battle_sound(ph_battle_take_event(&battle)); @@ -346,7 +377,9 @@ ph_run_game(void) ph_render_assets_destroy(&assets); SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); - ph_area_free(&world.area); + if (ph_save_write(PH_SAVE_PATH, &world, &spells) < 0) + fprintf(stderr, "failed to save game\n"); + ph_world_destroy(&world); SDL_Quit(); return 0; } diff --git a/src/game/presentation.c b/src/game/presentation.c @@ -189,6 +189,45 @@ ph_draw_options(SDL_Renderer *renderer, const PhOptionsUi *ui) "ESC/BKSP BACK"); } +PhStartAction +ph_start_key(PhStartUi *ui, SDL_Scancode key) +{ + int direction = key == PH_KEY_MENU_UP_1 || key == PH_KEY_MENU_UP_2 || + key == PH_KEY_MENU_DOWN_1 || key == PH_KEY_MENU_DOWN_2; + + if (!ui) return PH_START_STAY; + if (direction) ui->selected = ui->can_continue ? !ui->selected : 1; + if (key != PH_KEY_MENU_ACCEPT) return PH_START_STAY; + return ui->selected == 0 && ui->can_continue ? + PH_START_CONTINUE : PH_START_NEW; +} + +static void +ph_draw_start(SDL_Renderer *renderer, const PhStartUi *ui) +{ + SDL_FRect panel = { PH_VIEW_W * 0.5f - 82.0f, + PH_VIEW_H * 0.5f - 52.0f, 164.0f, 104.0f }; + char text[32]; + int i; + + if (!ui) return; + SDL_SetRenderDrawColor(renderer, PH_MENU_COLOR); + SDL_RenderFillRect(renderer, &panel); + SDL_SetRenderDrawColor(renderer, PH_MENU_BORDER_COLOR); + SDL_RenderRect(renderer, &panel); + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + SDL_RenderDebugText(renderer, panel.x + 42.0f, panel.y + 12.0f, "PHANTASIA"); + for (i = 0; i < 2; ++i) { + snprintf(text, sizeof(text), "%c %s", ui->selected == i ? '>' : ' ', + i == 0 ? "CONTINUE" : "NEW GAME"); + if (i == 0 && !ui->can_continue) + SDL_SetRenderDrawColor(renderer, PH_MENU_DISABLED_COLOR); + else SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + SDL_RenderDebugText(renderer, panel.x + 36.0f, + panel.y + 42.0f + (float)i * 18.0f, text); + } +} + static void ph_draw_character_sheet(SDL_Renderer *renderer, const PhRenderAssets *assets, const PhWorld *world) @@ -1123,6 +1162,8 @@ ph_present_frame(SDL_Renderer *renderer, const PhRenderAssets *assets, ph_draw_shop(renderer, world, context->spells, context->shop); } else if (context->menu == PH_MENU_MAIN) { ph_draw_options(renderer, context->options); + } else if (context->menu == PH_MENU_START) { + ph_draw_start(renderer, context->start); } else if (world->notice.seconds > 0.0f && world->notice.text[0] != '\0') { SDL_FRect notice_bg = { .x = 8.0f, diff --git a/src/game/presentation.h b/src/game/presentation.h @@ -15,9 +15,21 @@ typedef enum { PH_MENU_SPELLBOOK, PH_MENU_SHOP, PH_MENU_MAIN, + PH_MENU_START, } PhMenu; typedef enum { + PH_START_STAY, + PH_START_CONTINUE, + PH_START_NEW, +} PhStartAction; + +typedef struct { + int selected; + int can_continue; +} PhStartUi; + +typedef enum { PH_GAME_WORLD, PH_GAME_TRANSITION, PH_GAME_BATTLE, @@ -104,6 +116,7 @@ typedef struct { PhSpellbookUi *spellbook; PhShopUi *shop; PhOptionsUi *options; + PhStartUi *start; PhGameMode *mode; PhBattle *battle; PhSpellBook *spells; @@ -120,6 +133,7 @@ void ph_shop_open(PhShopUi *ui, int shop_id); PhMenu ph_interaction_open(PhShopUi *shop_ui, int interaction_id); void ph_options_open(PhOptionsUi *ui); PhOptionsAction ph_options_key(PhOptionsUi *ui, SDL_Scancode key); +PhStartAction ph_start_key(PhStartUi *ui, SDL_Scancode key); int ph_shop_key(PhShopUi *ui, PhWorld *world, PhSpellBook *spells, SDL_Scancode key); void ph_present_frame(SDL_Renderer *renderer, const PhRenderAssets *assets, diff --git a/src/tests/render.c b/src/tests/render.c @@ -40,6 +40,7 @@ main(void) PhSpellbookUi spellbook_ui; PhShopUi shop_ui = { 0 }; PhOptionsUi options_ui = { .music_enabled = 1, .effects_enabled = 1 }; + PhStartUi start_ui = { .selected = 1 }; PhGameMode game = PH_GAME_WORLD; PhBattle battle = { 0 }; PhSpellBook spells = ph_player_spellbook; @@ -49,6 +50,7 @@ main(void) .spellbook = &spellbook_ui, .shop = &shop_ui, .options = &options_ui, + .start = &start_ui, .mode = &game, .battle = &battle, .spells = &spells, @@ -90,7 +92,7 @@ main(void) } if (world.ground_item_count < 2) { fprintf(stderr, "render smoke test failed: item setup\n"); - ph_area_free(&world.area); + ph_world_destroy(&world); SDL_DestroyRenderer(renderer); SDL_DestroySurface(surface); SDL_Quit(); @@ -101,16 +103,28 @@ main(void) if (ph_render_assets_load(&assets, renderer, ph_asset_defs, PH_ASSET_COUNT) < 0) { - ph_area_free(&world.area); + ph_world_destroy(&world); SDL_DestroyRenderer(renderer); SDL_DestroySurface(surface); SDL_Quit(); return 1; } + if (ph_start_key(&start_ui, PH_KEY_MENU_ACCEPT) != PH_START_NEW) { + fprintf(stderr, "render smoke test failed: new-game selection\n"); + result = 1; + } + start_ui.can_continue = 1; + start_ui.selected = 0; + if (ph_start_key(&start_ui, PH_KEY_MENU_ACCEPT) != PH_START_CONTINUE) { + fprintf(stderr, "render smoke test failed: continue selection\n"); + result = 1; + } + context.menu = PH_MENU_START; + ph_present_frame(renderer, &assets, &context); if (ph_audio_init(&audio, ph_audio_defs, PH_AUDIO_COUNT) < 0) { fprintf(stderr, "audio smoke test failed: %s\n", SDL_GetError()); ph_render_assets_destroy(&assets); - ph_area_free(&world.area); + ph_world_destroy(&world); SDL_DestroyRenderer(renderer); SDL_DestroySurface(surface); SDL_Quit(); @@ -463,7 +477,7 @@ main(void) ph_audio_destroy(&audio); ph_render_assets_destroy(&assets); - ph_area_free(&world.area); + ph_world_destroy(&world); SDL_DestroyRenderer(renderer); SDL_DestroySurface(surface); SDL_Quit(); diff --git a/src/tests/smoke.c b/src/tests/smoke.c @@ -2,6 +2,7 @@ #include "engine/world.h" #include "engine/battle.h" #include "engine/audio.h" +#include "engine/save.h" #include "game/content.h" #include "config.h" @@ -74,6 +75,7 @@ ph_area_catalog_test(void) if (ph_world_prepare_area(&world, &area, &catalog, 7, &player) < 0 || world.entity_count != entity_count || world.ground_item_count != item_count) return 1; + ph_world_destroy(&world); return 0; } @@ -192,6 +194,39 @@ ph_movement_test(int open, int blocked) } static int +ph_area_state_scaling_test(void) +{ + enum { AREA_COUNT = 512 }; + const PhEntityDef player_def = { + .id = 1, .stats = { .max_hp = 10 }, .kind = PH_ENTITY_PLAYER, + }; + const PhWorldCatalog content = { .entities = &player_def, .entity_count = 1 }; + PhWorld world; + PhArea area = { 0 }; + int player; + int i; + int result = 1; + + if (ph_world_init(&world, area, 1, content, 32.0f, 32.0f) < 0 || + (player = ph_world_spawn_entity(&world, 1, (PhVec2){ 0 }, 0, 1)) < 0) + return 1; + ph_world_set_player(&world, player); + for (i = AREA_COUNT; i > 0; --i) { + world.area_id = i; + world.entities[world.player_index].area_id = i; + if (ph_world_capture_area(&world) < 0) goto done; + } + if (world.initialized_area_count != AREA_COUNT || + world.area_state_capacity < AREA_COUNT) goto done; + for (i = 0; i < AREA_COUNT; ++i) + if (world.area_states[i].area_id != i + 1) goto done; + result = 0; +done: + ph_world_destroy(&world); + return result; +} + +static int ph_logic_test(int open, int blocked) { enum { MAP_W = 4, MAP_H = 3, ROW = 1, LEFT_COL = 1, RIGHT_COL = 2 }; @@ -518,14 +553,82 @@ ph_area_shop_test(void) world.entities[i].area_id == PH_AREA_MEADOW && world.entities[i].active) slime_found = 1; } - ph_area_free(&world.area); + ph_world_destroy(&world); return slime_found ? 0 : 1; fail: - ph_area_free(&world.area); + ph_world_destroy(&world); return 1; } +static int +ph_save_test(void) +{ + const char *path = "obj/test-save.phsave"; + PhWorld world; + PhWorld loaded; + PhSpellBook spells = ph_player_spellbook; + PhSpellBook loaded_spells = ph_player_spellbook; + PhEntity *player; + FILE *corrupt; + int result = 1; + + remove(path); + remove("obj/test-save.phsave.tmp"); + if (ph_save_load(path, &loaded, &ph_game_world, &loaded_spells, + 320.0f, 240.0f) != 0 || + ph_world_start(&world, &ph_game_world, 320.0f, 240.0f) < 0) return 1; + player = &world.entities[world.player_index]; + player->vitals.hp = 23; + player->xp = 91; + player->gold = 137; + world.inventory[3] = 4; + if (ph_spellbook_learn(&spells, PH_SPELL_EMBER) < 0) goto done_world; + if (ph_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1 || + world.entity_count != 1) goto done_world; + if (ph_save_write(path, &world, &spells) < 0) goto done_world; + if (ph_save_load(path, &loaded, &ph_game_world, &loaded_spells, + 320.0f, 240.0f) != 1) { + fprintf(stderr, "save round trip failed to load\n"); + goto done_world; + } + player = &loaded.entities[loaded.player_index]; + if (loaded.area_id != PH_AREA_STONEHOLLOW || loaded.entity_count != 1 || + loaded.initialized_area_count != 2 || player->vitals.hp != 23 || + player->xp != 91 || player->gold != 137 || + ph_world_item_amount(&loaded, PH_ITEM_HEALING_POTION) != 4 || + !ph_spellbook_has(&loaded_spells, PH_SPELL_FLASH) || + !ph_spellbook_has(&loaded_spells, PH_SPELL_HEAL) || + !ph_spellbook_has(&loaded_spells, PH_SPELL_EMBER)) { + fprintf(stderr, "save round trip restored incorrect global state\n"); + goto done_loaded; + } + if (ph_enter_portal(&loaded, PH_AREA_MEADOW) != 1 || + loaded.entity_count != 2 || loaded.ground_item_count != 2) { + fprintf(stderr, "save round trip restored incorrect area state: area=%d entities=%d items=%d\n", + loaded.area_id, loaded.entity_count, loaded.ground_item_count); + goto done_loaded; + } + corrupt = fopen(path, "wb"); + if (!corrupt) goto done_loaded; + if (fwrite("bad", 1, 3, corrupt) != 3) { + fclose(corrupt); + goto done_loaded; + } + if (fclose(corrupt) != 0) goto done_loaded; + if (ph_save_load(path, &(PhWorld){ 0 }, &ph_game_world, &loaded_spells, + 320.0f, 240.0f) != -1) goto done_loaded; + result = 0; + +done_loaded: + ph_world_destroy(&loaded); +done_world: + ph_world_destroy(&world); + remove(path); + remove("obj/test-save.phsave.tmp"); + return result; +} + int main(void) { @@ -549,6 +652,10 @@ main(void) fprintf(stderr, "smoke test failed: world movement\n"); return 1; } + if (ph_area_state_scaling_test()) { + fprintf(stderr, "smoke test failed: scalable area state storage\n"); + return 1; + } if (ph_logic_test(open, blocked)) { fprintf(stderr, "smoke test failed: world or battle logic\n"); return 1; @@ -557,5 +664,9 @@ main(void) fprintf(stderr, "smoke test failed: area or shop logic\n"); return 1; } + if (ph_save_test()) { + fprintf(stderr, "smoke test failed: save persistence\n"); + return 1; + } return 0; }