commit b3a1ae00b01e5a973afd09e8a4b19bb2ce82e9db
parent 71de30d9640a8d2a00abf692b6e5e901f21b6db8
Author: beep <beep@wimdupont.com>
Date: Thu, 30 Jul 2026 11:59:27 +0000
Add scalable world persistence
Diffstat:
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;
}