phantasia

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

commit 71de30d9640a8d2a00abf692b6e5e901f21b6db8
parent 81bcb377a82b5ffbacbc150ef3fb17674eee9622
Author: beep <beep@wimdupont.com>
Date:   Thu, 30 Jul 2026 11:11:03 +0000

Expand sound system and add options menu

Diffstat:
MMakefile | 26++++++++++++++++----------
MREADME.adoc | 4++++
Rassets/vendor/battle-theme/SOURCE.txt -> assets/vendor/audio/battle-theme/SOURCE.txt | 0
Rassets/vendor/battle-theme/battle.wav -> assets/vendor/audio/battle-theme/battle.wav | 0
Rassets/vendor/calm-loop/SOURCE.txt -> assets/vendor/audio/calm-loop/SOURCE.txt | 0
Rassets/vendor/calm-loop/meadow.wav -> assets/vendor/audio/calm-loop/meadow.wav | 0
Rassets/vendor/peacefull-ville/SOURCE.txt -> assets/vendor/audio/peacefull-ville/SOURCE.txt | 0
Rassets/vendor/peacefull-ville/stonehollow.wav -> assets/vendor/audio/peacefull-ville/stonehollow.wav | 0
Aassets/vendor/audio/rpg-sound-pack/SOURCE.txt | 16++++++++++++++++
Rassets/vendor/rpg-sound-pack/attack.wav -> assets/vendor/audio/rpg-sound-pack/attack.wav | 0
Aassets/vendor/audio/rpg-sound-pack/drop.wav | 0
Rassets/vendor/rpg-sound-pack/hit.wav -> assets/vendor/audio/rpg-sound-pack/hit.wav | 0
Aassets/vendor/audio/rpg-sound-pack/interact.wav | 0
Aassets/vendor/audio/rpg-sound-pack/menu.wav | 0
Aassets/vendor/audio/rpg-sound-pack/pickup.wav | 0
Rassets/vendor/rpg-sound-pack/spell.wav -> assets/vendor/audio/rpg-sound-pack/spell.wav | 0
Aassets/vendor/audio/rpg-sound-pack/step.wav | 0
Aassets/vendor/audio/rpg-sound-pack/trade.wav | 0
Rassets/vendor/16x16-animated-critters/16x16babies.png -> assets/vendor/graphics/16x16-animated-critters/16x16babies.png | 0
Rassets/vendor/16x16-animated-critters/SOURCE.txt -> assets/vendor/graphics/16x16-animated-critters/SOURCE.txt | 0
Rassets/vendor/16x16-rpg-items/SOURCE.txt -> assets/vendor/graphics/16x16-rpg-items/SOURCE.txt | 0
Rassets/vendor/16x16-rpg-items/items.png -> assets/vendor/graphics/16x16-rpg-items/items.png | 0
Rassets/vendor/base-character-16x16/Hero.png -> assets/vendor/graphics/base-character-16x16/Hero.png | 0
Rassets/vendor/base-character-16x16/SOURCE.txt -> assets/vendor/graphics/base-character-16x16/SOURCE.txt | 0
Rassets/vendor/overworld-grass-biome/SOURCE.txt -> assets/vendor/graphics/overworld-grass-biome/SOURCE.txt | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass.zip -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass.zip | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/grass_biome.tsx -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/grass_biome.tsx | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/map1.png -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/map1.png | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/map1.tmx -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/map1.tmx | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/map2.png -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/map2.png | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/map2.tmx -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/map2.tmx | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.ase -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.ase | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.png -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.png | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/palette.ase -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/palette.ase | 0
Rassets/vendor/overworld-grass-biome/TilesetGrass/preview.png -> assets/vendor/graphics/overworld-grass-biome/TilesetGrass/preview.png | 0
Dassets/vendor/rpg-sound-pack/SOURCE.txt | 10----------
Mconfig.def.h | 4+++-
Mphantasia.6 | 7++++++-
Msrc/engine/audio.c | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/engine/audio.h | 13++++++++++---
Msrc/engine/battle.c | 1+
Msrc/engine/world.c | 40+++++++++++++++++++++++++++++++++++++++-
Msrc/engine/world.h | 25++++++++++++++++++++++++-
Msrc/game/content.c | 40++++++++++++++++++++++++++++++++++------
Msrc/game/content.h | 7+++++++
Msrc/game/main.c | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/game/presentation.c | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/game/presentation.h | 22++++++++++++++++++++++
Msrc/tests/render.c | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/tests/smoke.c | 30+++++++++++++++++++++++++++++-
50 files changed, 508 insertions(+), 69 deletions(-)

diff --git a/Makefile b/Makefile @@ -6,21 +6,27 @@ OBJDIR = obj MAPDIR = $(OBJDIR)/maps MAPSRC = $(wildcard data/maps/*.txt) MAPBIN = $(MAPSRC:data/maps/%.txt=$(MAPDIR)/%.phmap) -TILESET_SRC = assets/vendor/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.png +TILESET_SRC = assets/vendor/graphics/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.png TILESET_BMP = $(OBJDIR)/assets/overworld_tileset_grass.bmp -HERO_SRC = assets/vendor/base-character-16x16/Hero.png +HERO_SRC = assets/vendor/graphics/base-character-16x16/Hero.png HERO_BMP = $(OBJDIR)/assets/hero.bmp -ITEMS_SRC = assets/vendor/16x16-rpg-items/items.png +ITEMS_SRC = assets/vendor/graphics/16x16-rpg-items/items.png ITEMS_BMP = $(OBJDIR)/assets/items.bmp -CRITTERS_SRC = assets/vendor/16x16-animated-critters/16x16babies.png +CRITTERS_SRC = assets/vendor/graphics/16x16-animated-critters/16x16babies.png CRITTERS_BMP = $(OBJDIR)/assets/critters.bmp AUDIO = \ - assets/vendor/calm-loop/meadow.wav \ - assets/vendor/peacefull-ville/stonehollow.wav \ - assets/vendor/battle-theme/battle.wav \ - assets/vendor/rpg-sound-pack/attack.wav \ - assets/vendor/rpg-sound-pack/hit.wav \ - assets/vendor/rpg-sound-pack/spell.wav + assets/vendor/audio/calm-loop/meadow.wav \ + assets/vendor/audio/peacefull-ville/stonehollow.wav \ + assets/vendor/audio/battle-theme/battle.wav \ + assets/vendor/audio/rpg-sound-pack/attack.wav \ + assets/vendor/audio/rpg-sound-pack/hit.wav \ + assets/vendor/audio/rpg-sound-pack/spell.wav \ + assets/vendor/audio/rpg-sound-pack/menu.wav \ + assets/vendor/audio/rpg-sound-pack/pickup.wav \ + assets/vendor/audio/rpg-sound-pack/drop.wav \ + assets/vendor/audio/rpg-sound-pack/step.wav \ + assets/vendor/audio/rpg-sound-pack/interact.wav \ + assets/vendor/audio/rpg-sound-pack/trade.wav SRC = \ src/engine/area.c \ diff --git a/README.adoc b/README.adoc @@ -60,6 +60,10 @@ Maps own placement, while content assigns semantics to their markers. Adding an instance should normally change only a map; adding content should change the catalog and map rather than engine code. +Vendored media is grouped by role under `assets/vendor/graphics` and +`assets/vendor/audio`. Each upstream asset directory keeps its own source and +license record. + == Build [source,sh] diff --git a/assets/vendor/battle-theme/SOURCE.txt b/assets/vendor/audio/battle-theme/SOURCE.txt diff --git a/assets/vendor/battle-theme/battle.wav b/assets/vendor/audio/battle-theme/battle.wav Binary files differ. diff --git a/assets/vendor/calm-loop/SOURCE.txt b/assets/vendor/audio/calm-loop/SOURCE.txt diff --git a/assets/vendor/calm-loop/meadow.wav b/assets/vendor/audio/calm-loop/meadow.wav Binary files differ. diff --git a/assets/vendor/peacefull-ville/SOURCE.txt b/assets/vendor/audio/peacefull-ville/SOURCE.txt diff --git a/assets/vendor/peacefull-ville/stonehollow.wav b/assets/vendor/audio/peacefull-ville/stonehollow.wav Binary files differ. diff --git a/assets/vendor/audio/rpg-sound-pack/SOURCE.txt b/assets/vendor/audio/rpg-sound-pack/SOURCE.txt @@ -0,0 +1,16 @@ +Title: RPG Sound Pack +Author: artisticdude +Source: https://opengameart.org/content/rpg-sound-pack +License: CC0 1.0 + +Selected and renamed files from the original WAV archive: + +- attack.wav: battle/swing2.wav +- hit.wav: NPC/slime/slime1.wav +- spell.wav: battle/magic1.wav +- menu.wav: interface/interface1.wav +- pickup.wav: inventory/coin3.wav +- drop.wav: inventory/wood-small.wav +- step.wav: inventory/cloth.wav +- interact.wav: interface/interface2.wav +- trade.wav: inventory/coin.wav diff --git a/assets/vendor/rpg-sound-pack/attack.wav b/assets/vendor/audio/rpg-sound-pack/attack.wav Binary files differ. diff --git a/assets/vendor/audio/rpg-sound-pack/drop.wav b/assets/vendor/audio/rpg-sound-pack/drop.wav Binary files differ. diff --git a/assets/vendor/rpg-sound-pack/hit.wav b/assets/vendor/audio/rpg-sound-pack/hit.wav Binary files differ. diff --git a/assets/vendor/audio/rpg-sound-pack/interact.wav b/assets/vendor/audio/rpg-sound-pack/interact.wav Binary files differ. diff --git a/assets/vendor/audio/rpg-sound-pack/menu.wav b/assets/vendor/audio/rpg-sound-pack/menu.wav Binary files differ. diff --git a/assets/vendor/audio/rpg-sound-pack/pickup.wav b/assets/vendor/audio/rpg-sound-pack/pickup.wav Binary files differ. diff --git a/assets/vendor/rpg-sound-pack/spell.wav b/assets/vendor/audio/rpg-sound-pack/spell.wav Binary files differ. diff --git a/assets/vendor/audio/rpg-sound-pack/step.wav b/assets/vendor/audio/rpg-sound-pack/step.wav Binary files differ. diff --git a/assets/vendor/audio/rpg-sound-pack/trade.wav b/assets/vendor/audio/rpg-sound-pack/trade.wav Binary files differ. diff --git a/assets/vendor/16x16-animated-critters/16x16babies.png b/assets/vendor/graphics/16x16-animated-critters/16x16babies.png Binary files differ. diff --git a/assets/vendor/16x16-animated-critters/SOURCE.txt b/assets/vendor/graphics/16x16-animated-critters/SOURCE.txt diff --git a/assets/vendor/16x16-rpg-items/SOURCE.txt b/assets/vendor/graphics/16x16-rpg-items/SOURCE.txt diff --git a/assets/vendor/16x16-rpg-items/items.png b/assets/vendor/graphics/16x16-rpg-items/items.png Binary files differ. diff --git a/assets/vendor/base-character-16x16/Hero.png b/assets/vendor/graphics/base-character-16x16/Hero.png Binary files differ. diff --git a/assets/vendor/base-character-16x16/SOURCE.txt b/assets/vendor/graphics/base-character-16x16/SOURCE.txt diff --git a/assets/vendor/overworld-grass-biome/SOURCE.txt b/assets/vendor/graphics/overworld-grass-biome/SOURCE.txt diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass.zip b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass.zip Binary files differ. diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/grass_biome.tsx b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/grass_biome.tsx diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/map1.png b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/map1.png Binary files differ. diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/map1.tmx b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/map1.tmx diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/map2.png b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/map2.png Binary files differ. diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/map2.tmx b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/map2.tmx diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.ase b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.ase Binary files differ. diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.png b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.png Binary files differ. diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/palette.ase b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/palette.ase Binary files differ. diff --git a/assets/vendor/overworld-grass-biome/TilesetGrass/preview.png b/assets/vendor/graphics/overworld-grass-biome/TilesetGrass/preview.png Binary files differ. diff --git a/assets/vendor/rpg-sound-pack/SOURCE.txt b/assets/vendor/rpg-sound-pack/SOURCE.txt @@ -1,10 +0,0 @@ -Title: RPG Sound Pack -Author: artisticdude -Source: https://opengameart.org/content/rpg-sound-pack -License: CC0 1.0 - -Selected and renamed files from the original WAV archive: - -- attack.wav: battle/swing2.wav -- hit.wav: NPC/slime/slime1.wav -- spell.wav: battle/magic1.wav diff --git a/config.def.h b/config.def.h @@ -1,7 +1,7 @@ #ifndef CONFIG_H #define CONFIG_H -#define PH_CONFIG_VERSION 12 +#define PH_CONFIG_VERSION 14 #define PH_VIEW_W 320 #define PH_VIEW_H 240 @@ -11,6 +11,7 @@ #define PH_FRAME_DELAY_MS 16 #define PH_MUSIC_GAIN 0.24f #define PH_EFFECT_GAIN 0.55f +#define PH_SPATIAL_SOUND_DISTANCE 160.0f #define PH_ENTITY_BLOCK_RADIUS 10 #define PH_INTERACT_RADIUS 20.0f @@ -77,6 +78,7 @@ #define PH_KEY_MENU_RIGHT_2 SDL_SCANCODE_L #define PH_KEY_MENU_ACCEPT SDL_SCANCODE_RETURN #define PH_KEY_MENU_CANCEL SDL_SCANCODE_ESCAPE +#define PH_KEY_MENU_BACK SDL_SCANCODE_BACKSPACE #endif #endif diff --git a/phantasia.6 b/phantasia.6 @@ -32,7 +32,12 @@ Open or confirm the selected inventory action, shop choice, battle command, or spell action or target. .It Escape Return one level from an inventory, magick book, or battle submenu, or close an -open character sheet or top-level menu. +open character sheet. In the overworld or at the root of combat, open the main +options menu. Its Sound submenu independently enables music and sound effects; +Exit Game quits the game. +.It Backspace +Return one menu level or close the current menu without opening the main options +menu. At the overworld or combat root, do nothing. .It Window close button Quit the game. .El diff --git a/src/engine/audio.c b/src/engine/audio.c @@ -1,5 +1,23 @@ #include "engine/audio.h" +#include <math.h> + +float +ph_audio_distance_gain(float listener_x, float listener_y, + float source_x, float source_y, float max_distance) +{ + float dx; + float dy; + float distance; + + if (max_distance <= 0.0f) return 0.0f; + dx = source_x - listener_x; + dy = source_y - listener_y; + distance = sqrtf(dx * dx + dy * dy); + if (distance >= max_distance) return 0.0f; + return 1.0f - distance / max_distance; +} + #if PH_USE_SDL #include <string.h> @@ -24,6 +42,8 @@ ph_audio_init(PhAudio *audio, const PhAudioDef *defs, int count) if (!audio || !defs || count <= 1 || count > PH_AUDIO_CLIP_MAX) return -1; memset(audio, 0, sizeof(*audio)); audio->clip_count = count; + audio->music_enabled = 1; + audio->effects_enabled = 1; for (i = 1; i < count; ++i) { audio->clips[i].gain = defs[i].gain; if (!defs[i].path || !SDL_LoadWAV(defs[i].path, &audio->clips[i].spec, @@ -34,11 +54,14 @@ ph_audio_init(PhAudio *audio, const PhAudioDef *defs, int count) &audio->device_spec, NULL)) goto fail; audio->music_stream = SDL_CreateAudioStream(&audio->clips[1].spec, &audio->device_spec); - audio->effect_stream = SDL_CreateAudioStream(&audio->clips[1].spec, - &audio->device_spec); - if (!audio->music_stream || !audio->effect_stream || - !SDL_BindAudioStream(audio->device, audio->music_stream) || - !SDL_BindAudioStream(audio->device, audio->effect_stream)) goto fail; + if (!audio->music_stream || + !SDL_BindAudioStream(audio->device, audio->music_stream)) goto fail; + for (i = 0; i < PH_AUDIO_EFFECT_VOICES; ++i) { + audio->effect_streams[i] = SDL_CreateAudioStream(&audio->clips[1].spec, + &audio->device_spec); + if (!audio->effect_streams[i] || !SDL_BindAudioStream(audio->device, + audio->effect_streams[i])) goto fail; + } return 0; fail: @@ -54,14 +77,43 @@ ph_audio_music(PhAudio *audio, int id) if (ph_audio_stream_clip(audio->music_stream, &audio->clips[id], &audio->device_spec) < 0) return; audio->music_id = id; + if (!audio->music_enabled) SDL_SetAudioStreamGain(audio->music_stream, 0.0f); } void -ph_audio_effect(PhAudio *audio, int id) +ph_audio_effect(PhAudio *audio, int id, float gain) { - if (!audio || id <= 0 || id >= audio->clip_count) return; - ph_audio_stream_clip(audio->effect_stream, &audio->clips[id], - &audio->device_spec); + SDL_AudioStream *stream; + + if (!audio || !audio->effects_enabled || + id <= 0 || id >= audio->clip_count || gain <= 0.0f) return; + stream = audio->effect_streams[audio->next_effect_voice]; + audio->next_effect_voice = + (audio->next_effect_voice + 1) % PH_AUDIO_EFFECT_VOICES; + if (ph_audio_stream_clip(stream, &audio->clips[id], + &audio->device_spec) == 0) + SDL_SetAudioStreamGain(stream, audio->clips[id].gain * gain); +} + +void +ph_audio_set_enabled(PhAudio *audio, int music, int effects) +{ + if (!audio) return; + music = !!music; + effects = !!effects; + if (audio->music_enabled != music) { + audio->music_enabled = music; + SDL_SetAudioStreamGain(audio->music_stream, + music && audio->music_id > 0 ? audio->clips[audio->music_id].gain : 0.0f); + } + if (audio->effects_enabled != effects) { + int i; + + audio->effects_enabled = effects; + if (!effects) + for (i = 0; i < PH_AUDIO_EFFECT_VOICES; ++i) + SDL_ClearAudioStream(audio->effect_streams[i]); + } } void @@ -83,7 +135,9 @@ ph_audio_destroy(PhAudio *audio) if (!audio) return; if (audio->music_stream) SDL_DestroyAudioStream(audio->music_stream); - if (audio->effect_stream) SDL_DestroyAudioStream(audio->effect_stream); + for (i = 0; i < PH_AUDIO_EFFECT_VOICES; ++i) + if (audio->effect_streams[i]) + SDL_DestroyAudioStream(audio->effect_streams[i]); if (audio->device) SDL_CloseAudioDevice(audio->device); for (i = 0; i < audio->clip_count; ++i) SDL_free(audio->clips[i].data); memset(audio, 0, sizeof(*audio)); diff --git a/src/engine/audio.h b/src/engine/audio.h @@ -6,10 +6,13 @@ typedef struct { float gain; } PhAudioDef; +float ph_audio_distance_gain(float listener_x, float listener_y, + float source_x, float source_y, float max_distance); + #if PH_USE_SDL #include <SDL3/SDL.h> -enum { PH_AUDIO_CLIP_MAX = 16 }; +enum { PH_AUDIO_CLIP_MAX = 64, PH_AUDIO_EFFECT_VOICES = 8 }; typedef struct { SDL_AudioSpec spec; @@ -21,16 +24,20 @@ typedef struct { typedef struct { SDL_AudioDeviceID device; SDL_AudioStream *music_stream; - SDL_AudioStream *effect_stream; + SDL_AudioStream *effect_streams[PH_AUDIO_EFFECT_VOICES]; SDL_AudioSpec device_spec; PhAudioClip clips[PH_AUDIO_CLIP_MAX]; int clip_count; int music_id; + int music_enabled; + int effects_enabled; + int next_effect_voice; } PhAudio; int ph_audio_init(PhAudio *audio, const PhAudioDef *defs, int count); void ph_audio_music(PhAudio *audio, int id); -void ph_audio_effect(PhAudio *audio, int id); +void ph_audio_effect(PhAudio *audio, int id, float gain); +void ph_audio_set_enabled(PhAudio *audio, int music, int effects); void ph_audio_update(PhAudio *audio); void ph_audio_destroy(PhAudio *audio); #endif diff --git a/src/engine/battle.c b/src/engine/battle.c @@ -125,6 +125,7 @@ ph_spellbook_buy(PhSpellBook *spells, PhWorld *world, int entity_index, if (!entity->active || spell->buy_value < 0 || entity->gold < spell->buy_value || ph_spellbook_learn(spells, spell_id) < 0) return -1; entity->gold -= spell->buy_value; + ph_world_emit_event(world, PH_WORLD_EVENT_BUY, entity->pos, entity_index); return 0; } diff --git a/src/engine/world.c b/src/engine/world.c @@ -121,6 +121,30 @@ ph_world_set_notice(PhWorld *world, const char *text) world->notice.seconds = PH_NOTICE_SECONDS; } +int +ph_world_emit_event(PhWorld *world, PhWorldEventKind kind, PhVec2 pos, + int entity_index) +{ + int tail; + + if (!world || kind <= PH_WORLD_EVENT_NONE || + world->event_count >= PH_MAX_WORLD_EVENTS) return -1; + tail = (world->event_head + world->event_count) % PH_MAX_WORLD_EVENTS; + world->events[tail] = (PhWorldEvent){ kind, pos, entity_index }; + ++world->event_count; + return 0; +} + +int +ph_world_take_event(PhWorld *world, PhWorldEvent *event) +{ + if (!world || !event || world->event_count <= 0) return 0; + *event = world->events[world->event_head]; + world->event_head = (world->event_head + 1) % PH_MAX_WORLD_EVENTS; + --world->event_count; + return 1; +} + static const PhEntity * ph_world_blocking_entity(const PhWorld *world, int self_index, PhVec2 pos) { @@ -221,7 +245,11 @@ ph_try_pickup(PhWorld *world, PhEntity *entity) } item_index = ph_item_def_index(world, drop->item_id); - if (item_index >= 0) world->inventory[item_index] += drop->amount; + if (item_index >= 0) { + world->inventory[item_index] += drop->amount; + ph_world_emit_event(world, PH_WORLD_EVENT_ITEM_PICKUP, + drop->pos, world->player_index); + } drop->active = 0; } } @@ -234,6 +262,7 @@ ph_try_interact(PhWorld *world, const PhEntity *player) player->pos.y + (float)(player->motion.facing_y * PH_TILE_SIZE), }; float closest = PH_INTERACT_RADIUS * PH_INTERACT_RADIUS; + PhVec2 interaction_pos = player->pos; int i; for (i = 0; i < world->entity_count; ++i) { @@ -249,7 +278,11 @@ ph_try_interact(PhWorld *world, const PhEntity *player) if (distance > closest) continue; closest = distance; world->interaction_id = def->interaction_id; + interaction_pos = entity->pos; } + if (world->interaction_id) + ph_world_emit_event(world, PH_WORLD_EVENT_INTERACT, interaction_pos, + world->player_index); if (!world->interaction_id) { int tx = (int)floorf(target.x / (float)PH_TILE_SIZE); int ty = (int)floorf(target.y / (float)PH_TILE_SIZE); @@ -327,6 +360,7 @@ ph_entity_begin_step(PhWorld *world, int entity_index, int dx, int dy, if (ph_entity_step_blocked(world, entity_index, target)) return 0; entity->motion.target = target; entity->motion.moving = 1; + ph_world_emit_event(world, PH_WORLD_EVENT_MOVE, entity->pos, entity_index); return 1; } @@ -748,6 +782,8 @@ ph_world_drop_inventory_item(PhWorld *world, int entity_index, int item_id) world->area_id) < 0) return -1; --world->inventory[item_index]; + ph_world_emit_event(world, PH_WORLD_EVENT_ITEM_DROP, + world->entities[entity_index].pos, entity_index); return 0; } @@ -766,6 +802,7 @@ ph_world_buy_item(PhWorld *world, int entity_index, int item_id) if (entity->gold < item->buy_value) return -1; entity->gold -= item->buy_value; ++world->inventory[item_index]; + ph_world_emit_event(world, PH_WORLD_EVENT_BUY, entity->pos, entity_index); return 0; } @@ -784,6 +821,7 @@ ph_world_sell_item(PhWorld *world, int entity_index, int item_id) entity = &world->entities[entity_index]; --world->inventory[item_index]; entity->gold += item->sell_value; + ph_world_emit_event(world, PH_WORLD_EVENT_SELL, entity->pos, entity_index); return 0; } diff --git a/src/engine/world.h b/src/engine/world.h @@ -3,7 +3,7 @@ #include <stddef.h> -#define PH_CONFIG_API_VERSION 12 +#define PH_CONFIG_API_VERSION 14 enum { PH_MAP_MAGIC = 0x50484d50, @@ -15,6 +15,7 @@ enum { PH_MAX_AREAS = 32, PH_MAX_ENTITIES = 128, PH_MAX_GROUND_ITEMS = 128, + PH_MAX_WORLD_EVENTS = 64, PH_NOTICE_SIZE = 96, PH_ANIMATION_FRAMES = 4, }; @@ -36,6 +37,16 @@ typedef enum { } PhItemUse; typedef enum { + PH_WORLD_EVENT_NONE, + PH_WORLD_EVENT_MOVE, + PH_WORLD_EVENT_INTERACT, + PH_WORLD_EVENT_ITEM_PICKUP, + PH_WORLD_EVENT_ITEM_DROP, + PH_WORLD_EVENT_BUY, + PH_WORLD_EVENT_SELL, +} PhWorldEventKind; + +typedef enum { PH_ENTITY_PLAYER = 1, PH_ENTITY_MONSTER, PH_ENTITY_NPC, @@ -54,6 +65,12 @@ typedef struct { } PhVec2; typedef struct { + PhWorldEventKind kind; + PhVec2 pos; + int entity_index; +} PhWorldEvent; + +typedef struct { int max_hp; int max_mp; int strength; @@ -226,6 +243,9 @@ typedef struct { int interaction_id; unsigned int rng_state; PhNotice notice; + PhWorldEvent events[PH_MAX_WORLD_EVENTS]; + int event_head; + int event_count; } PhWorld; typedef struct { @@ -250,6 +270,9 @@ int ph_world_equip_item(PhWorld *world, int entity_index, int item_id); int ph_world_equip_inventory_item(PhWorld *world, int entity_index, int item_id); int ph_world_use_inventory_item(PhWorld *world, int entity_index, int item_id); int ph_world_drop_inventory_item(PhWorld *world, int entity_index, int item_id); +int ph_world_emit_event(PhWorld *world, PhWorldEventKind kind, PhVec2 pos, + int entity_index); +int ph_world_take_event(PhWorld *world, PhWorldEvent *event); int ph_world_buy_item(PhWorld *world, int entity_index, int item_id); int ph_world_sell_item(PhWorld *world, int entity_index, int item_id); int ph_world_physical_attack(PhWorld *world, int attacker_index, int target_index); diff --git a/src/game/content.c b/src/game/content.c @@ -4,17 +4,29 @@ const PhAudioDef ph_audio_defs[PH_AUDIO_COUNT] = { [PH_MUSIC_MEADOW] = { - "assets/vendor/calm-loop/meadow.wav", PH_MUSIC_GAIN * 0.85f }, + "assets/vendor/audio/calm-loop/meadow.wav", PH_MUSIC_GAIN * 0.85f }, [PH_MUSIC_TOWN] = { - "assets/vendor/peacefull-ville/stonehollow.wav", PH_MUSIC_GAIN }, + "assets/vendor/audio/peacefull-ville/stonehollow.wav", PH_MUSIC_GAIN }, [PH_MUSIC_BATTLE] = { - "assets/vendor/battle-theme/battle.wav", PH_MUSIC_GAIN * 0.80f }, + "assets/vendor/audio/battle-theme/battle.wav", PH_MUSIC_GAIN * 0.80f }, [PH_SOUND_ATTACK] = { - "assets/vendor/rpg-sound-pack/attack.wav", PH_EFFECT_GAIN }, + "assets/vendor/audio/rpg-sound-pack/attack.wav", PH_EFFECT_GAIN }, [PH_SOUND_HIT] = { - "assets/vendor/rpg-sound-pack/hit.wav", PH_EFFECT_GAIN }, + "assets/vendor/audio/rpg-sound-pack/hit.wav", PH_EFFECT_GAIN }, [PH_SOUND_SPELL] = { - "assets/vendor/rpg-sound-pack/spell.wav", PH_EFFECT_GAIN }, + "assets/vendor/audio/rpg-sound-pack/spell.wav", PH_EFFECT_GAIN }, + [PH_SOUND_MENU] = { + "assets/vendor/audio/rpg-sound-pack/menu.wav", PH_EFFECT_GAIN * 0.60f }, + [PH_SOUND_PICKUP] = { + "assets/vendor/audio/rpg-sound-pack/pickup.wav", PH_EFFECT_GAIN * 0.80f }, + [PH_SOUND_DROP] = { + "assets/vendor/audio/rpg-sound-pack/drop.wav", PH_EFFECT_GAIN * 0.80f }, + [PH_SOUND_STEP] = { + "assets/vendor/audio/rpg-sound-pack/step.wav", PH_EFFECT_GAIN * 0.32f }, + [PH_SOUND_INTERACT] = { + "assets/vendor/audio/rpg-sound-pack/interact.wav", PH_EFFECT_GAIN * 0.65f }, + [PH_SOUND_TRADE] = { + "assets/vendor/audio/rpg-sound-pack/trade.wav", PH_EFFECT_GAIN * 0.75f }, }; const PhAssetDef ph_asset_defs[PH_ASSET_COUNT] = { @@ -415,3 +427,19 @@ ph_game_battle_sound(PhBattleEvent event) default: return PH_AUDIO_NONE; } } + +int +ph_game_world_sound(const PhWorld *world, const PhWorldEvent *event) +{ + (void)world; + if (!event) return PH_AUDIO_NONE; + switch (event->kind) { + case PH_WORLD_EVENT_MOVE: return PH_SOUND_STEP; + case PH_WORLD_EVENT_INTERACT: return PH_SOUND_INTERACT; + case PH_WORLD_EVENT_ITEM_PICKUP: return PH_SOUND_PICKUP; + case PH_WORLD_EVENT_ITEM_DROP: return PH_SOUND_DROP; + case PH_WORLD_EVENT_BUY: + case PH_WORLD_EVENT_SELL: return PH_SOUND_TRADE; + default: return PH_AUDIO_NONE; + } +} diff --git a/src/game/content.h b/src/game/content.h @@ -42,6 +42,12 @@ enum { PH_SOUND_ATTACK, PH_SOUND_HIT, PH_SOUND_SPELL, + PH_SOUND_MENU, + PH_SOUND_PICKUP, + PH_SOUND_DROP, + PH_SOUND_STEP, + PH_SOUND_INTERACT, + PH_SOUND_TRADE, PH_AUDIO_COUNT, }; @@ -125,5 +131,6 @@ const PhShopDef *ph_game_shop_def(int shop_id); const PhInteractionDef *ph_game_interaction_def(int interaction_id); int ph_game_area_music(int area_id); int ph_game_battle_sound(PhBattleEvent event); +int ph_game_world_sound(const PhWorld *world, const PhWorldEvent *event); #endif diff --git a/src/game/main.c b/src/game/main.c @@ -44,7 +44,8 @@ ph_update_game(PhGameContext *context, PhInput input, float dt) *game = PH_GAME_TRANSITION; } } - if (*game == PH_GAME_TRANSITION || *game == PH_GAME_BATTLE) + if ((*game == PH_GAME_TRANSITION || *game == PH_GAME_BATTLE) && + context->menu == PH_MENU_NONE) result = ph_battle_update(context->battle, world, dt, *game == PH_GAME_TRANSITION); if (result == PH_BATTLE_READY) *game = PH_GAME_BATTLE; @@ -52,6 +53,31 @@ ph_update_game(PhGameContext *context, PhInput input, float dt) else if (result == PH_BATTLE_DEFEAT) *game = PH_GAME_OVER; } +static int +ph_menu_direction(SDL_Scancode key) +{ + return key == PH_KEY_MENU_UP_1 || key == PH_KEY_MENU_UP_2 || + key == PH_KEY_MENU_DOWN_1 || key == PH_KEY_MENU_DOWN_2 || + key == PH_KEY_MENU_LEFT_1 || key == PH_KEY_MENU_LEFT_2 || + key == PH_KEY_MENU_RIGHT_1 || key == PH_KEY_MENU_RIGHT_2; +} + +static int +ph_menu_back(SDL_Scancode key) +{ + return key == PH_KEY_MENU_CANCEL || key == PH_KEY_MENU_BACK; +} + +static float +ph_world_sound_gain(const PhWorld *world, const PhWorldEvent *event) +{ + const PhEntity *player = ph_world_player(world); + + if (!player || !event || PH_SPATIAL_SOUND_DISTANCE <= 0.0f) return 0.0f; + return ph_audio_distance_gain(player->pos.x, player->pos.y, + event->pos.x, event->pos.y, PH_SPATIAL_SOUND_DISTANCE); +} + static PhInput ph_read_input(const bool *keys) { @@ -105,6 +131,7 @@ ph_run_game(void) PhInventoryUi inventory_ui = { 0 }; PhSpellbookUi spellbook_ui = { 0 }; PhShopUi shop_ui = { 0 }; + PhOptionsUi options_ui = { .music_enabled = 1, .effects_enabled = 1 }; PhGameMode game = PH_GAME_WORLD; PhBattle battle = { 0 }; PhSpellBook spells = ph_player_spellbook; @@ -114,6 +141,7 @@ ph_run_game(void) .inventory = &inventory_ui, .spellbook = &spellbook_ui, .shop = &shop_ui, + .options = &options_ui, .mode = &game, .battle = &battle, .spells = &spells, @@ -159,12 +187,30 @@ ph_run_game(void) Uint64 now_ticks; float dt; PhInput input; + int effect = PH_AUDIO_NONE; while (SDL_PollEvent(&event)) { if (event.type == SDL_EVENT_QUIT) { running = 0; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && - game == PH_GAME_BATTLE && (battle.phase == PH_BATTLE_COMMAND || + menu == PH_MENU_MAIN) { + PhOptionsAction action; + + if (ph_menu_direction(event.key.scancode)) effect = PH_SOUND_MENU; + action = ph_options_key(&options_ui, event.key.scancode); + if (action == PH_OPTIONS_CLOSE) menu = PH_MENU_NONE; + else if (action == PH_OPTIONS_EXIT) running = 0; + } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && + menu == PH_MENU_NONE && event.key.scancode == PH_KEY_MENU_CANCEL && + (game == PH_GAME_WORLD || game == PH_GAME_VICTORY || + game == PH_GAME_OVER || (game == PH_GAME_BATTLE && + battle.phase != PH_BATTLE_MAGIC && + battle.phase != PH_BATTLE_TARGET))) { + ph_options_open(&options_ui); + menu = PH_MENU_MAIN; + } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && + menu == PH_MENU_NONE && game == PH_GAME_BATTLE && + (battle.phase == PH_BATTLE_COMMAND || battle.phase == PH_BATTLE_MAGIC || battle.phase == PH_BATTLE_TARGET)) { SDL_Scancode key = event.key.scancode; @@ -174,33 +220,38 @@ ph_run_game(void) key == PH_KEY_MENU_RIGHT_1 || key == PH_KEY_MENU_RIGHT_2; if (battle.phase == PH_BATTLE_TARGET) { - if (previous || next) - ph_battle_target_step(&battle, &world, previous ? -1 : 1); + if (previous || next) { + if (ph_battle_target_step(&battle, &world, + previous ? -1 : 1) >= 0) effect = PH_SOUND_MENU; + } else if (key == PH_KEY_MENU_ACCEPT) ph_battle_confirm_target(&battle, &world); - else if (key == PH_KEY_MENU_CANCEL) + else if (ph_menu_back(key)) ph_battle_cancel(&battle); } else if (battle.phase == PH_BATTLE_MAGIC) { - if ((previous || next) && battle.spells.learned_count > 0) - ph_battle_spell_step(&battle, previous ? -1 : 1); + if ((previous || next) && battle.spells.learned_count > 0) { + if (ph_battle_spell_step(&battle, previous ? -1 : 1) >= 0) + effect = PH_SOUND_MENU; + } else if (key == PH_KEY_MENU_ACCEPT) ph_battle_cast(&battle, &world); - else if (key == PH_KEY_MENU_CANCEL) + else if (ph_menu_back(key)) ph_battle_cancel(&battle); } else if (previous || next) { battle.choice.command = (PhBattleCommand)((battle.choice.command + (previous ? PH_BATTLE_COMMAND_COUNT - 1 : 1)) % PH_BATTLE_COMMAND_COUNT); + effect = PH_SOUND_MENU; } else if (key == PH_KEY_MENU_ACCEPT) { ph_battle_select(&battle, &world); } } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && - game == PH_GAME_VICTORY && + menu == PH_MENU_NONE && game == PH_GAME_VICTORY && event.key.scancode == PH_KEY_MENU_ACCEPT) { game = PH_GAME_WORLD; menu = PH_MENU_NONE; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && game == PH_GAME_WORLD && menu == PH_MENU_CHARACTER && - event.key.scancode == PH_KEY_MENU_CANCEL) { + ph_menu_back(event.key.scancode)) { menu = PH_MENU_NONE; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && game == PH_GAME_WORLD && @@ -218,6 +269,9 @@ ph_run_game(void) } } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && game == PH_GAME_WORLD && menu == PH_MENU_INVENTORY) { + if (ph_menu_direction(event.key.scancode) && + inventory_ui.mode != PH_INVENTORY_EXAMINE) + effect = PH_SOUND_MENU; if (ph_inventory_key(&inventory_ui, &world, event.key.scancode)) menu = PH_MENU_NONE; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && @@ -231,10 +285,15 @@ ph_run_game(void) } } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && game == PH_GAME_WORLD && menu == PH_MENU_SPELLBOOK) { + if (ph_menu_direction(event.key.scancode) && + spellbook_ui.mode != PH_SPELLBOOK_EXAMINE) + effect = PH_SOUND_MENU; if (ph_spellbook_key(&spellbook_ui, &world, &spells, event.key.scancode)) menu = PH_MENU_NONE; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && game == PH_GAME_WORLD && menu == PH_MENU_SHOP) { + if (ph_menu_direction(event.key.scancode) && + shop_ui.mode != PH_SHOP_TALK) effect = PH_SOUND_MENU; if (ph_shop_key(&shop_ui, &world, &spells, event.key.scancode)) menu = PH_MENU_NONE; } @@ -247,17 +306,32 @@ ph_run_game(void) input = ph_read_input(SDL_GetKeyboardState(NULL)); context.menu = menu; ph_update_game(&context, input, dt); - if (audio_ready) { - int effect = ph_game_battle_sound(ph_battle_take_event(&battle)); + { + int battle_effect = ph_game_battle_sound(ph_battle_take_event(&battle)); + if (battle_effect != PH_AUDIO_NONE) effect = battle_effect; if (spellbook_ui.spell_used) effect = PH_SOUND_SPELL; spellbook_ui.spell_used = 0; + } + if (audio_ready) { + PhWorldEvent world_event; + ph_audio_music(&audio, ph_game_music(&context)); - if (effect != PH_AUDIO_NONE) ph_audio_effect(&audio, effect); + ph_audio_set_enabled(&audio, options_ui.music_enabled, + options_ui.effects_enabled); + if (effect != PH_AUDIO_NONE) ph_audio_effect(&audio, effect, 1.0f); + while (ph_world_take_event(&world, &world_event)) { + int world_effect = ph_game_world_sound(&world, &world_event); + + if (world_effect != PH_AUDIO_NONE) + ph_audio_effect(&audio, world_effect, + ph_world_sound_gain(&world, &world_event)); + } ph_audio_update(&audio); } else { - ph_battle_take_event(&battle); - spellbook_ui.spell_used = 0; + PhWorldEvent world_event; + + while (ph_world_take_event(&world, &world_event)) { } } ph_present_frame(renderer, &assets, &context); if (game == PH_GAME_WORLD && menu == PH_MENU_NONE && diff --git a/src/game/presentation.c b/src/game/presentation.c @@ -10,6 +10,12 @@ #if PH_USE_SDL enum { PH_UI_FONT_W = 8 }; +static int +ph_menu_back(SDL_Scancode key) +{ + return key == PH_KEY_MENU_CANCEL || key == PH_KEY_MENU_BACK; +} + static float ph_ui_right(float inset) { @@ -103,12 +109,86 @@ ph_draw_menu(SDL_Renderer *renderer, const char *title, SDL_Scancode key) SDL_RenderRect(renderer, &panel); SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); SDL_RenderDebugText(renderer, 24.0f, 22.0f, title); - snprintf(text, sizeof(text), "CLOSE: %s", SDL_GetScancodeName(key)); + snprintf(text, sizeof(text), "CLOSE: %s / %s", SDL_GetScancodeName(key), + SDL_GetScancodeName(PH_KEY_MENU_BACK)); x = ph_ui_right(20.0f) - (float)strlen(text) * PH_UI_FONT_W; if (x < 24.0f) x = 24.0f; SDL_RenderDebugText(renderer, x, (float)(PH_VIEW_H - 22), text); } +void +ph_options_open(PhOptionsUi *ui) +{ + if (!ui) return; + ui->selected = 0; + ui->mode = PH_OPTIONS_ROOT; +} + +PhOptionsAction +ph_options_key(PhOptionsUi *ui, SDL_Scancode key) +{ + int previous = key == PH_KEY_MENU_UP_1 || key == PH_KEY_MENU_UP_2 || + key == PH_KEY_MENU_LEFT_1 || key == PH_KEY_MENU_LEFT_2; + int next = key == PH_KEY_MENU_DOWN_1 || key == PH_KEY_MENU_DOWN_2 || + key == PH_KEY_MENU_RIGHT_1 || key == PH_KEY_MENU_RIGHT_2; + + if (!ui) return PH_OPTIONS_CLOSE; + if (ph_menu_back(key)) { + if (ui->mode == PH_OPTIONS_SOUND) { + ui->mode = PH_OPTIONS_ROOT; + ui->selected = 0; + return PH_OPTIONS_STAY; + } + return PH_OPTIONS_CLOSE; + } + if (previous || next) { + ui->selected = (ui->selected + 1) % 2; + return PH_OPTIONS_STAY; + } + if (key != PH_KEY_MENU_ACCEPT) return PH_OPTIONS_STAY; + if (ui->mode == PH_OPTIONS_ROOT) { + if (ui->selected == 1) return PH_OPTIONS_EXIT; + ui->mode = PH_OPTIONS_SOUND; + ui->selected = 0; + } else if (ui->selected == 0) { + ui->music_enabled = !ui->music_enabled; + } else { + ui->effects_enabled = !ui->effects_enabled; + } + return PH_OPTIONS_STAY; +} + +static void +ph_draw_options(SDL_Renderer *renderer, const PhOptionsUi *ui) +{ + static const char *root_options[] = { "SOUND", "EXIT GAME" }; + SDL_FRect panel = { PH_VIEW_W * 0.5f - 84.0f, + PH_VIEW_H * 0.5f - 58.0f, 168.0f, 116.0f }; + char text[48]; + 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 + 12.0f, panel.y + 10.0f, + ui->mode == PH_OPTIONS_SOUND ? "SOUND" : "OPTIONS"); + for (i = 0; i < 2; ++i) { + if (ui->mode == PH_OPTIONS_SOUND) + snprintf(text, sizeof(text), "%c %-8s %s", + ui->selected == i ? '>' : ' ', i == 0 ? "MUSIC" : "EFFECTS", + (i == 0 ? ui->music_enabled : ui->effects_enabled) ? "ON" : "OFF"); + else snprintf(text, sizeof(text), "%c %s", + ui->selected == i ? '>' : ' ', root_options[i]); + SDL_RenderDebugText(renderer, panel.x + 20.0f, + panel.y + 38.0f + (float)i * 20.0f, text); + } + SDL_RenderDebugText(renderer, panel.x + 12.0f, panel.y + 94.0f, + "ESC/BKSP BACK"); +} + static void ph_draw_character_sheet(SDL_Renderer *renderer, const PhRenderAssets *assets, const PhWorld *world) @@ -422,7 +502,7 @@ ph_inventory_key(PhInventoryUi *ui, PhWorld *world, SDL_Scancode key) int next = key == PH_KEY_MENU_DOWN_1 || key == PH_KEY_MENU_DOWN_2 || key == PH_KEY_MENU_RIGHT_1 || key == PH_KEY_MENU_RIGHT_2; - if (key == PH_KEY_MENU_CANCEL) { + if (ph_menu_back(key)) { if (ui->mode == PH_INVENTORY_EXAMINE) ui->mode = PH_INVENTORY_ACTIONS; else if (ui->mode == PH_INVENTORY_ACTIONS) @@ -608,7 +688,7 @@ ph_spellbook_key(PhSpellbookUi *ui, PhWorld *world, PhSpellBook *spells, ui->selected < spells->learned_count ? ph_spellbook_def(spells, spells->learned[ui->selected]) : NULL; - if (key == PH_KEY_MENU_CANCEL) { + if (ph_menu_back(key)) { if (ui->mode == PH_SPELLBOOK_EXAMINE) ui->mode = PH_SPELLBOOK_ACTIONS; else if (ui->mode == PH_SPELLBOOK_ACTIONS) @@ -673,7 +753,7 @@ ph_shop_key(PhShopUi *ui, PhWorld *world, PhSpellBook *spells, SDL_Scancode key) key == PH_KEY_MENU_RIGHT_1 || key == PH_KEY_MENU_RIGHT_2; if (!shop) return 1; - if (key == PH_KEY_MENU_CANCEL) { + if (ph_menu_back(key)) { if (ui->mode == PH_SHOP_ROOT) return 1; ui->mode = PH_SHOP_ROOT; ui->selected = 0; @@ -1017,11 +1097,15 @@ ph_present_frame(SDL_Renderer *renderer, const PhRenderAssets *assets, if (game == PH_GAME_BATTLE) { ph_draw_battle(renderer, assets, world, battle); + if (context->menu == PH_MENU_MAIN) + ph_draw_options(renderer, context->options); SDL_RenderPresent(renderer); return; } if (game == PH_GAME_VICTORY || game == PH_GAME_OVER) { ph_draw_battle_end(renderer, world, battle, game); + if (context->menu == PH_MENU_MAIN) + ph_draw_options(renderer, context->options); SDL_RenderPresent(renderer); return; } @@ -1037,6 +1121,8 @@ ph_present_frame(SDL_Renderer *renderer, const PhRenderAssets *assets, ph_draw_spellbook(renderer, world, context->spells, context->spellbook); } else if (context->menu == PH_MENU_SHOP) { ph_draw_shop(renderer, world, context->spells, context->shop); + } else if (context->menu == PH_MENU_MAIN) { + ph_draw_options(renderer, context->options); } 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 @@ -14,6 +14,7 @@ typedef enum { PH_MENU_INVENTORY, PH_MENU_SPELLBOOK, PH_MENU_SHOP, + PH_MENU_MAIN, } PhMenu; typedef enum { @@ -79,11 +80,30 @@ typedef struct { char status[64]; } PhShopUi; +typedef enum { + PH_OPTIONS_ROOT, + PH_OPTIONS_SOUND, +} PhOptionsMode; + +typedef enum { + PH_OPTIONS_STAY, + PH_OPTIONS_CLOSE, + PH_OPTIONS_EXIT, +} PhOptionsAction; + +typedef struct { + int selected; + int music_enabled; + int effects_enabled; + PhOptionsMode mode; +} PhOptionsUi; + typedef struct { PhWorld *world; PhInventoryUi *inventory; PhSpellbookUi *spellbook; PhShopUi *shop; + PhOptionsUi *options; PhGameMode *mode; PhBattle *battle; PhSpellBook *spells; @@ -98,6 +118,8 @@ int ph_spellbook_key(PhSpellbookUi *ui, PhWorld *world, PhSpellBook *spells, SDL_Scancode key); 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); 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 @@ -39,6 +39,7 @@ main(void) PhInventoryUi inventory_ui; PhSpellbookUi spellbook_ui; PhShopUi shop_ui = { 0 }; + PhOptionsUi options_ui = { .music_enabled = 1, .effects_enabled = 1 }; PhGameMode game = PH_GAME_WORLD; PhBattle battle = { 0 }; PhSpellBook spells = ph_player_spellbook; @@ -47,6 +48,7 @@ main(void) .inventory = &inventory_ui, .spellbook = &spellbook_ui, .shop = &shop_ui, + .options = &options_ui, .mode = &game, .battle = &battle, .spells = &spells, @@ -116,9 +118,18 @@ main(void) } ph_audio_music(&audio, PH_MUSIC_MEADOW); ph_audio_update(&audio); - ph_audio_effect(&audio, PH_SOUND_ATTACK); - ph_audio_effect(&audio, PH_SOUND_HIT); - ph_audio_effect(&audio, PH_SOUND_SPELL); + ph_audio_effect(&audio, PH_SOUND_ATTACK, 1.0f); + ph_audio_effect(&audio, PH_SOUND_HIT, 1.0f); + ph_audio_effect(&audio, PH_SOUND_SPELL, 1.0f); + ph_audio_effect(&audio, PH_SOUND_MENU, 0.5f); + ph_audio_set_enabled(&audio, 0, 0); + if (audio.music_enabled || audio.effects_enabled || + ph_audio_distance_gain(0.0f, 0.0f, 160.0f, 0.0f, 160.0f) != 0.0f || + ph_audio_distance_gain(0.0f, 0.0f, 80.0f, 0.0f, 160.0f) != 0.5f) { + fprintf(stderr, "audio smoke test failed: controls or distance\n"); + result = 1; + } + ph_audio_set_enabled(&audio, 1, 1); ph_audio_music(&audio, PH_MUSIC_TOWN); ph_audio_music(&audio, PH_MUSIC_BATTLE); @@ -139,6 +150,39 @@ main(void) result = 1; } ph_inventory_open(&inventory_ui, &world); + ph_options_open(&options_ui); + if (ph_options_key(&options_ui, PH_KEY_MENU_DOWN_1) != PH_OPTIONS_STAY || + options_ui.selected != 1 || + ph_options_key(&options_ui, PH_KEY_MENU_UP_1) != PH_OPTIONS_STAY || + options_ui.selected != 0 || + ph_options_key(&options_ui, PH_KEY_MENU_ACCEPT) != PH_OPTIONS_STAY || + options_ui.mode != PH_OPTIONS_SOUND) { + fprintf(stderr, "render smoke test failed: options navigation\n"); + result = 1; + } + ph_options_key(&options_ui, PH_KEY_MENU_ACCEPT); + ph_options_key(&options_ui, PH_KEY_MENU_DOWN_1); + ph_options_key(&options_ui, PH_KEY_MENU_ACCEPT); + if (options_ui.music_enabled || options_ui.effects_enabled) { + fprintf(stderr, "render smoke test failed: sound toggles\n"); + result = 1; + } + context.menu = PH_MENU_MAIN; + ph_present_frame(renderer, &assets, &context); + ph_options_key(&options_ui, PH_KEY_MENU_BACK); + if (ph_options_key(&options_ui, PH_KEY_MENU_BACK) != PH_OPTIONS_CLOSE) { + fprintf(stderr, "render smoke test failed: options cancel\n"); + result = 1; + } + ph_options_open(&options_ui); + ph_options_key(&options_ui, PH_KEY_MENU_DOWN_1); + if (ph_options_key(&options_ui, PH_KEY_MENU_ACCEPT) != PH_OPTIONS_EXIT) { + fprintf(stderr, "render smoke test failed: exit option\n"); + result = 1; + } + options_ui.music_enabled = 1; + options_ui.effects_enabled = 1; + context.menu = PH_MENU_NONE; ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_ACCEPT); if (inventory_ui.option != PH_INVENTORY_EQUIP) result = 1; ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1); @@ -153,11 +197,11 @@ main(void) result = 1; } inventory_ui.mode = PH_INVENTORY_EXAMINE; - if (ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL) || + if (ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_BACK) || inventory_ui.mode != PH_INVENTORY_ACTIONS || - ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL) || + ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_BACK) || inventory_ui.mode != PH_INVENTORY_BROWSE || - !ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL)) { + !ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_BACK)) { fprintf(stderr, "render smoke test failed: inventory cancel\n"); result = 1; } @@ -237,6 +281,10 @@ main(void) ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS * 0.5f, 1); ph_present_frame(renderer, &assets, &context); game = PH_GAME_BATTLE; + ph_options_open(&options_ui); + context.menu = PH_MENU_MAIN; + ph_present_frame(renderer, &assets, &context); + context.menu = PH_MENU_NONE; battle.phase = PH_BATTLE_ENEMY_ACTION; battle.timer = PH_BATTLE_ACTION_SECONDS; battle.resolved = 0; diff --git a/src/tests/smoke.c b/src/tests/smoke.c @@ -1,6 +1,7 @@ #include "engine/area.h" #include "engine/world.h" #include "engine/battle.h" +#include "engine/audio.h" #include "game/content.h" #include "config.h" @@ -9,6 +10,16 @@ #define LEN(a) (sizeof(a) / sizeof(0[a])) +static unsigned int +ph_world_event_mask(PhWorld *world) +{ + PhWorldEvent event; + unsigned int mask = 0; + + while (ph_world_take_event(world, &event)) mask |= 1u << event.kind; + return mask; +} + static int ph_area_catalog_test(void) { @@ -98,6 +109,7 @@ ph_movement_test(int open, int blocked) PhVec2 mob_start = { PH_TILE_CENTER(MOB_COL), PH_TILE_CENTER(ROW) }; float action_seconds = (float)PH_WORLD_ACTION_MILLISECONDS / 1000.0f; PhWorld world; + PhWorldEvent event; PhEntity *mob; int player_index; int mob_index; @@ -121,13 +133,18 @@ ph_movement_test(int open, int blocked) mob = &world.entities[mob_index]; ph_world_tick(&world, (PhInput){ 0 }, action_seconds); if (!mob->motion.moving || mob->motion.target.x != PH_TILE_CENTER(MOB_COL - 1) || - mob->motion.target.y != mob_start.y) return 1; + mob->motion.target.y != mob_start.y || + !ph_world_take_event(&world, &event) || + event.kind != PH_WORLD_EVENT_MOVE || event.entity_index != mob_index) + return 1; ph_world_tick(&world, (PhInput){ 0 }, PH_MAX_FRAME_TIME); if (mob->pos.x >= mob_start.x) return 1; ph_world_tick(&world, (PhInput){ .move_x = 1 }, PH_MAX_FRAME_TIME); if (!world.entities[player_index].motion.moving || world.entities[player_index].motion.target.x != PH_TILE_CENTER(PLAYER_COL + 1) || + !ph_world_take_event(&world, &event) || + event.kind != PH_WORLD_EVENT_MOVE || event.entity_index != player_index || ph_entity_animation_frame(&world.content.entities[0], &world.entities[player_index]) != 0) return 1; ph_world_tick(&world, (PhInput){ 0 }, PH_MAX_FRAME_TIME); @@ -220,6 +237,7 @@ ph_logic_test(int open, int blocked) int player_index; int enemy_index; int ally_index; + unsigned int events; memset(tiles, blocked, sizeof(tiles)); tiles[ROW * area.width + LEFT_COL] = (unsigned char)open; @@ -242,6 +260,9 @@ ph_logic_test(int open, int blocked) world.entities[player_index].vitals.hp = 1; if (ph_world_use_inventory_item(&world, player_index, 2) < 0 || ph_world_drop_inventory_item(&world, player_index, 2) < 0) return 1; + events = ph_world_event_mask(&world); + if (!(events & (1u << PH_WORLD_EVENT_ITEM_PICKUP)) || + !(events & (1u << PH_WORLD_EVENT_ITEM_DROP))) return 1; ph_world_tick(&world, (PhInput){ .move_x = 1 }, crossing_seconds); ph_world_tick(&world, (PhInput){ .move_x = 1, .move_y = 1 }, crossing_seconds); @@ -351,6 +372,7 @@ ph_area_shop_test(void) int slime_found = 0; int shop_id = -1; int i; + unsigned int events; if (ph_world_start(&world, &ph_game_world, 320.0f, 240.0f) < 0) return 1; player = &world.entities[world.player_index]; @@ -420,6 +442,7 @@ ph_area_shop_test(void) } } if (i == world.entity_count) goto fail; + ph_world_event_mask(&world); ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); { const PhInteractionDef *interaction = @@ -428,12 +451,17 @@ ph_area_shop_test(void) if (!interaction || interaction->kind != PH_INTERACTION_SHOP || interaction->content_id != shop_id) goto fail; } + events = ph_world_event_mask(&world); + if (!(events & (1u << PH_WORLD_EVENT_INTERACT))) goto fail; potion_before = ph_world_item_amount(&world, potion->id); if (ph_world_buy_item(&world, world.player_index, potion->id) < 0 || player->gold != 40 - potion->buy_value || ph_world_item_amount(&world, potion->id) != potion_before + 1 || ph_world_sell_item(&world, world.player_index, potion->id) < 0 || player->gold != 40 - potion->buy_value + potion->sell_value) goto fail; + events = ph_world_event_mask(&world); + if (!(events & (1u << PH_WORLD_EVENT_BUY)) || + !(events & (1u << PH_WORLD_EVENT_SELL))) goto fail; if (ph_world_buy_item(&world, world.player_index, ether->id) < 0) goto fail; player->vitals.mp = 0; if (ph_world_use_inventory_item(&world, world.player_index, ether->id) < 0 ||