commit 6a0c9e8d464ec9fb99399767c2589b7e54b111fc Author: Zed A. Shaw Date: Sun Mar 22 10:37:45 2026 -0400 First cut of pulling out the relevant parts of my original game to make a little framework. diff --git a/.gdbinit b/.gdbinit new file mode 100644 index 0000000..d86a368 --- /dev/null +++ b/.gdbinit @@ -0,0 +1,10 @@ +set confirm off +set breakpoint pending on +set logging on +set logging overwrite on +set print pretty on +set pagination off +break abort +#break _invalid_parameter_noinfo +#break _invalid_parameter +catch throw diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2391fca --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# ---> Vim +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +subprojects +builddir +ttassets +backup +*.exe +*.dll +*.world +coverage +coverage/* +.venv +*.log +gdb.txt +releases +WORK +tmp diff --git a/.vimrc_proj b/.vimrc_proj new file mode 100644 index 0000000..1d04d48 --- /dev/null +++ b/.vimrc_proj @@ -0,0 +1 @@ +set makeprg=make\ -f\ ../Makefile\ build diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..27ba792 --- /dev/null +++ b/Makefile @@ -0,0 +1,84 @@ +ROOT_DIR := $(dir $(realpath $(lastword $(MAKEFILE_LIST)))) + +all: build + +reset: +ifeq '$(OS)' 'Windows_NT' + powershell -executionpolicy bypass .\scripts\reset_build.ps1 +else + sh -x ./scripts/reset_build.sh +endif + +%.cpp : %.rl + ragel -I $(ROOT_DIR) -G1 -o $@ $< + +%.dot: %.rl + ragel -Vp -I $(ROOT_DIR) -o $@ $< + +%.png: %.dot + dot -Tpng $< -o $@ + +build: + meson compile -j 10 -C $(ROOT_DIR)/builddir + +asset_build: + ./builddir/icongen + +release_build: + meson --wipe builddir -Db_ndebug=true --buildtype release + meson compile -j 10 -C builddir + +debug_build: + meson setup --wipe builddir -Db_ndebug=true --buildtype debugoptimized + meson compile -j 10 -C builddir + +tracy_build: + meson setup --wipe builddir --buildtype debugoptimized -Dtracy_enable=true -Dtracy:on_demand=true + meson compile -j 10 -C builddir + +test: build + ./builddir/runtests -d yes + +run: build test +ifeq '$(OS)' 'Windows_NT' + powershell "cp ./builddir/zedcaster.exe ." + ./zedcaster +else + ./builddir/zedcaster +endif + +debug: build + gdb --nx -x .gdbinit --ex run --args builddir/runtests + +debug_run: build + gdb --nx -x .gdbinit --batch --ex run --ex bt --ex q --args builddir/zedcaster + +debug_walk: build test + gdb --nx -x .gdbinit --batch --ex run --ex bt --ex q --args builddir/zedcaster t + +clean: + meson compile --clean -C builddir + +debug_test: build + gdb --nx -x .gdbinit --ex run --ex bt --ex q --args builddir/runtests + +win_installer: + powershell 'start "C:\Program Files (x86)\solicus\InstallForge\bin\ifbuilderenvx86.exe" scripts\win_installer.ifp' + +coverage_report: + powershell 'scripts/coverage_report.ps1' + +money: + scc --exclude-dir subprojects --exclude-dir .git --exclude-dir wraps --exclude-dir scripts + +arena: + gdb --nx -x .gdbinit --batch --ex run --ex bt --ex q --args ./builddir/arena + +story: + gdb --nx -x .gdbinit --batch --ex run --ex bt --ex q --args ./builddir/storyboard + +debug_animator: + gdb --nx -x .gdbinit --batch --ex run --ex bt --ex q --args ./builddir/animator -s "rat_king_boss" -a "rat_king_boss" -b "test_background" + +animator: + ./builddir/animator.exe -s "rat_king_boss" -a "rat_king_boss" -b "test_background" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f69ad35 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# A Game Engine?! But That's Illegal! + +I took some of the code from my dungeon crawler game and turned into a rough engine so I could enter +the 2026 Dungeon Crawler game jam. diff --git a/assets/ai.json b/assets/ai.json new file mode 100644 index 0000000..b398d2a --- /dev/null +++ b/assets/ai.json @@ -0,0 +1,140 @@ +{ + "profile": { + "enemy_found": 0, + "enemy_dead": 1, + "health_good": 2, + "no_more_items": 3, + "no_more_enemies": 4, + "in_combat": 5, + "have_item": 6, + "have_healing": 7, + "detect_enemy": 8, + "tough_personality": 9, + "cant_move": 10 + }, + "actions": [ + { + "name": "find_enemy", + "cost": 5, + "needs": { + "detect_enemy": true, + "in_combat": false, + "no_more_enemies": false, + "enemy_found": false + }, + "effects": { + "in_combat": true, + "enemy_found": true + } + }, + { + "name": "run_away", + "cost": 0, + "needs": { + "tough_personality": false, + "in_combat": true, + "have_healing": false, + "health_good": false, + "cant_move": false + }, + "effects": { + "in_combat": false + } + }, + { + "name": "kill_enemy", + "cost": 10, + "needs": { + "no_more_enemies": false, + "in_combat": true, + "enemy_found": true, + "enemy_dead": false + }, + + "effects": { + "enemy_dead": true + } + }, + { + "name": "collect_items", + "cost": 5, + "needs": { + "no_more_enemies": true, + "no_more_items": false + }, + "effects": { + "no_more_items": true + } + }, + { + "name": "find_healing", + "cost": 2, + "needs": { + "have_healing": false, + "in_combat": false, + "health_good": false + }, + "effects": { + "health_good": true + } + }, + { + "name": "use_healing", + "cost": 1, + "needs": { + "have_healing": true, + "health_good": false + }, + "effects": { + "health_good": true + } + } + ], + "states": { + "Host::initial_state": { + "enemy_found": false, + "enemy_dead": false, + "health_good": true, + "no_more_items": false, + "no_more_enemies": false, + "in_combat": false, + "have_item": false, + "have_healing": false, + "detect_enemy": true, + "tough_personality": true + }, + "Host::final_state": { + "enemy_found": true, + "enemy_dead": true, + "health_good": true, + "no_more_items": true, + "in_combat": false, + "no_more_enemies": true + }, + "Enemy::initial_state": { + "detect_enemy": false, + "tough_personality": true, + "enemy_found": false, + "enemy_dead": false, + "health_good": true, + "in_combat": false + }, + "Enemy::final_state": { + "detect_enemy": true, + "enemy_found": true, + "enemy_dead": true, + "health_good": true + } + }, + "scripts": { + "Host::actions": + ["find_enemy", + "kill_enemy", + "collect_items", + "find_healing", + "run_away", + "use_healing"], + "Enemy::actions": + ["find_enemy", "run_away", "kill_enemy", "use_healing"] + } +} diff --git a/assets/animation.json b/assets/animation.json new file mode 100644 index 0000000..9098a9f --- /dev/null +++ b/assets/animation.json @@ -0,0 +1,132 @@ +{ + "burning_animation": { + "sheet": { + "frames": 3, + "frame_width": 256, + "frame_height": 256 + }, + "sequences": { + "idle": {"frames": [0,1,2,1,0], "durations": [5,5,5,5,5] } + }, + "transforms": { + "basic": { + "min_x": 1.0, + "min_y": 1.0, + "max_x": 1.0, + "max_y": 1.0, + "flipped": false, + "scaled": true, + "toggled": false, + "looped": false, + "relative": true, + "easing": "none", + "motion": "move_none" + } + }, + "forms": { + "idle": ["idle", "basic"] + }, + "sounds": { + "idle": [], + "open": [], + "close": [] + } + }, + "lightning_animation": { + "sheet": { + "frames": 5, + "frame_width": 256, + "frame_height": 256 + }, + "sequences": { + "idle": {"frames": [0,1,2,3,4], "durations": [5,5,5,5,5] } + }, + "transforms": { + "basic": { + "min_x": 1.0, + "min_y": 1.0, + "max_x": 1.0, + "max_y": 1.0, + "flipped": false, + "scaled": true, + "toggled": false, + "looped": false, + "relative": false, + "easing": "none", + "motion": "move_none" + } + }, + "forms": { + "idle": ["idle", "basic"] + }, + "sounds": { + "idle": [], + "open": [], + "close": [] + } + }, + "rat_with_sword": { + "sheet": { + "frames": 1, + "frame_width": 256, + "frame_height": 256 + }, + "sequences": { + "idle": {"frames": [0], "durations": [50] } + }, + "transforms": { + "basic": { + "min_x": 1.0, + "min_y": 1.0, + "max_x": 1.22, + "max_y": 1.22, + "flipped": false, + "scaled": true, + "toggled": false, + "looped": false, + "relative": false, + "easing": "sine", + "motion": "scale_both" + } + }, + "forms": { + "idle": ["idle", "basic"] + }, + "sounds": { + "idle": [], + "open": [], + "close": [] + } + }, + "female_hand": { + "sheet": { + "frames": 3, + "frame_width": 900, + "frame_height": 600 + }, + "sequences": { + "idle": {"frames": [0, 1, 2], "durations": [10, 10, 20] } + }, + "transforms": { + "basic": { + "min_x": 1.0, + "min_y": 1.0, + "max_x": 1.0, + "max_y": 1.0, + "flipped": false, + "scaled": false, + "toggled": true, + "looped": false, + "relative": false, + "easing": "none", + "motion": "move_none" + } + }, + "forms": { + "idle": ["idle", "basic"] + }, + "sounds": { + "idle": [] + } + } +} diff --git a/assets/cameras.json b/assets/cameras.json new file mode 100644 index 0000000..8ea577f --- /dev/null +++ b/assets/cameras.json @@ -0,0 +1,163 @@ +{ + "scene": { + "sheet": { + "frames": 1, + "frame_width": 1024, + "frame_height": 768 + }, + "sequences": { + "idle": {"frames": [0], "durations": [60] }, + "pan": {"frames": [0], "durations": [60] }, + "shake": {"frames": [0], "durations": [60] }, + "dolly": {"frames": [0], "durations": [60] }, + "bounce": {"frames": [0], "durations": [60] } + }, + "transforms": { + "pan": { + "min_x": 0.0, + "min_y": 0.0, + "max_x": 0.0, + "max_y": 0.0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": true, + "easing": "linear", + "relative": false, + "motion": "move_slide" + }, + "shake": { + "min_x": -10.0, + "min_y": -10.0, + "max_x": 10.0, + "max_y": 10.0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": true, + "relative": true, + "easing": "normal_dist", + "motion": "move_shake" + }, + "dolly": { + "min_x": 0.8, + "min_y": 0.8, + "max_x": 1.0, + "max_y": 1.0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": true, + "easing": "sine", + "relative": true, + "motion": "move_rush" + }, + "bounce": { + "min_x": 0, + "min_y": -20, + "max_x": 0, + "max_y": 0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": true, + "relative": true, + "easing": "in_out_back", + "motion": "move_bounce" + } + }, + "forms": { + "idle": ["idle", "idle"], + "pan": ["pan", "pan"], + "dolly": ["dolly", "dolly"], + "shake": ["shake", "shake"], + "bounce": ["bounce", "bounce"] + }, + "sounds": { + "idle": [], + "pan": [], + "dolly": [], + "shake": [], + "bounce": [] + } + }, + "story": { + "sheet": { + "frames": 1, + "frame_width": 1024, + "frame_height": 768 + }, + "sequences": {}, + "transforms": { + "pan": { + "min_x": 0.0, + "min_y": 0.0, + "max_x": 0.0, + "max_y": 0.0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": false, + "easing": "linear", + "relative": false, + "motion": "move_slide" + }, + "shake": { + "min_x": -10.0, + "min_y": -10.0, + "max_x": 10.0, + "max_y": 10.0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": false, + "relative": true, + "easing": "normal_dist", + "motion": "move_shake" + }, + "dolly": { + "min_x": 0.8, + "min_y": 0.8, + "max_x": 1.0, + "max_y": 1.0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": false, + "easing": "sine", + "relative": true, + "motion": "move_rush" + }, + "bounce": { + "min_x": 0, + "min_y": -20, + "max_x": 0, + "max_y": 0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": false, + "relative": true, + "easing": "in_out_back", + "motion": "move_bounce" + }, + "pause": { + "min_x": 0, + "min_y": 0, + "max_x": 0, + "max_y": 0, + "flipped": false, + "scaled": false, + "toggled": false, + "looped": false, + "relative": false, + "easing": "none", + "motion": "move_none" + } + }, + "forms": {}, + "sounds": { + "idle": [] + } + } +} diff --git a/assets/config.json b/assets/config.json new file mode 100644 index 0000000..00ecef8 --- /dev/null +++ b/assets/config.json @@ -0,0 +1,117 @@ +{ + "sounds": { + "Sword_Hit_1": "assets/sounds/Creature_Sounds-Sword_Hit_1.ogg", + "Evil_Eye_Sound_1": "assets/sounds/Creature_Sounds-Evil_Eye_Sound_1.ogg", + "Evil_Eye_Sound_2": "assets/sounds/Creature_Sounds-Evil_Eye_Sound_2.ogg", + "Giant_Voice_1": "assets/sounds/Creature_Sounds-Giant_Voice_1.ogg", + "Medium_Rat": "assets/sounds/Creature_Sounds-Medium_Rat.ogg", + "Ranger_1": "assets/sounds/Creature_Sounds-Ranger_1.ogg", + "Small_Rat": "assets/sounds/Creature_Sounds-Small_Rat.ogg", + "Spider_1": "assets/sounds/Creature_Sounds-Spider_1.ogg", + "Spider_2": "assets/sounds/Creature_Sounds-Spider_2.ogg", + "Sword_Hit_1": "assets/sounds/Creature_Sounds-Sword_Hit_1.ogg", + "Sword_Hit_2": "assets/sounds/Creature_Sounds-Sword_Hit_2.ogg", + "walk": "assets/sounds/Creature_Sounds-Walk.ogg", + "Creature_Death_1": "assets/sounds/Creature_Sounds-Creature_Death_1.ogg", + "Humanoid_Death_1": "assets/sounds/Creature_Sounds-Humanoid_Death_1.ogg", + "Marmot_Scream_1": "assets/sounds/Creature_Sounds-Marmot_Scream_1.ogg", + "blank": "assets/sounds/blank.ogg", + "pickup": "assets/sounds/pickup.ogg", + "ambient_1": "assets/sounds/ambient_1.ogg", + "ui_click": "assets/sounds/ui_click.ogg", + "ui_hover": "assets/sounds/ui_hover.ogg", + "punch_cartoony": "assets/sounds/punch_cartoony.ogg", + "electric_shock_01": "assets/sounds/electric_shock_01.ogg", + "fireball_01": "assets/sounds/fireball_01.ogg", + "hp_status_80": "assets/sounds/hp_status_80.ogg", + "hp_status_60": "assets/sounds/hp_status_60.ogg", + "hp_status_30": "assets/sounds/hp_status_30.ogg", + "hp_status_10": "assets/sounds/hp_status_10.ogg", + "hp_status_00": "assets/sounds/hp_status_00.ogg" + }, + "sprites": { + "rat_with_sword": + {"path": "assets/sprites/rat_with_sword.png", + "frame_width": 256, + "frame_height": 256 + }, + "torch_crappy": + {"path": "assets/items/torch_crappy.png", + "frame_width": 256, + "frame_height": 256 + }, + "torch_horizontal_floor": + {"path": "assets/items/torch_horizontal_floor.png", + "frame_width": 256, + "frame_height": 256 + }, + "peasant_girl": + {"path": "assets/sprites/peasant_girl_2.png", + "frame_width": 256, + "frame_height": 256 + }, + "healing_potion_small": + {"path": "assets/items/healing_potion_small.png", + "frame_width": 256, + "frame_height": 256 + }, + "well_down": + {"path": "assets/sprites/well_down.png", + "frame_width": 256, + "frame_height": 256 + }, + "dead_body": + {"path": "assets/sprites/dead_body.png", + "frame_width": 256, + "frame_height": 256 + }, + "door_plain": + {"path": "assets/doors/door_plain.png", + "frame_width": 256, + "frame_height": 256 + }, + "dead_body_lootable": + {"path": "assets/sprites/dead_body_lootable.png", + "frame_width": 256, + "frame_height": 256 + }, + "peasant_girl": + {"path": "assets/sprites/peasant_girl_2.png", + "frame_width": 256, + "frame_height": 256 + }, + "female_hand": + {"path": "assets/hands/female_hand.png", + "frame_width": 900, + "frame_height": 600 + } + }, + "worldgen": { + "enemy_probability": 50, + "device_probability": 10 + }, + "graphics": { + "smooth_textures": false + }, + "compass": { + "N": 65514, + "NE": 8663, + "E": 8594, + "SE": 8600, + "S": 65516, + "SW": 8665, + "W": 8592, + "NW": 8598 + }, + "theme": { + "NOTE": "colors are in assets/palette.json", + "padding": 3, + "border_px": 1, + "text_size": 20, + "label_size": 20, + "font_file_name": "assets/text.otf" + }, + "player": { + "hands": "female_hand" + } +} diff --git a/assets/devices.json b/assets/devices.json new file mode 100644 index 0000000..0f52e02 --- /dev/null +++ b/assets/devices.json @@ -0,0 +1,46 @@ +{ + "STAIRS_DOWN": { + "id": "STAIRS_DOWN", + "name": "Stairs Down", + "placement": "fixed", + "description": "Stairs that go down further into the dungeon.", + "inventory_count": 0, + "randomized": false, + "components": [ + {"_type": "Tile", "display": 6105, + "foreground": "devices/fg:stairs_down", + "background": "devices/bg:stairs_down" + }, + {"_type": "Device", + "config": {}, + "events": ["STAIRS_DOWN"]}, + {"_type": "Sprite", "name": "well_down", "width": 256, "height": 256, "scale": 1.0} + ] + }, + "DEAD_BODY_LOOTABLE": { + "id": "DEAD_BODY_LOOTABLE", + "name": "Grave Stone", + "description": "Something died here. Was this your doing?", + "components": [ + {"_type": "Tile", "display": 1890, + "foreground": "devices/fg:dead_body_lootable", + "background": "devices/bg:dead_body_lootable" + }, + {"_type": "Device", "config": {}, "events": ["LOOT_CONTAINER"]}, + {"_type": "Sprite", "name": "dead_body_lootable", "width": 256, "height": 256, "scale": 1.0}, + {"_type": "Sound", "attack": "pickup", "death": "blank"} + ] + }, + "DEAD_BODY": { + "id": "DEAD_BODY", + "name": "Something Dead", + "description": "You can't loot this, weirdo.", + "components": [ + {"_type": "Tile", "display": 1939, + "foreground": "devices/fg:dead_body", + "background": "devices/bg:dead_body" + }, + {"_type": "Sprite", "name": "dead_body", "width": 256, "height": 256, "scale": 1.0} + ] + } +} diff --git a/assets/enemies.json b/assets/enemies.json new file mode 100644 index 0000000..4018979 --- /dev/null +++ b/assets/enemies.json @@ -0,0 +1,32 @@ +{ + "PLAYER_TILE": { + "placement": "fixed", + "components": [ + {"_type": "Tile", "display": 10733, + "foreground": "enemies/fg:player", + "background": "color:transparent" + }, + {"_type": "Combat", "hp": 200, "max_hp": 200, "ap": 0, "max_ap": 12, "ap_delta": 6, "damage": 50, "dead": false}, + {"_type": "Motion", "dx": 0, "dy": 0, "random": false}, + {"_type": "Collision", "has": true}, + {"_type": "EnemyConfig", "ai_script": "Host::actions", "ai_start_name": "Host::initial_state", "ai_goal_name": "Host::final_state"}, + {"_type": "Personality", "hearing_distance": 5, "tough": false}, + {"_type": "LightSource", "strength": 35, "radius": 2.0} + ] + }, + "RAT_GIANT": { + "components": [ + {"_type": "Tile", "display": 2220, + "foreground": "enemies/fg:rat_giant", + "background": "color:transparent" + }, + {"_type": "Combat", "hp": 50, "max_hp": 50, "ap": 0, "max_ap": 12, "ap_delta": 6,"damage": 2, "dead": false}, + {"_type": "Collision", "has": true}, + {"_type": "Motion", "dx": 0, "dy": 0, "random": false}, + {"_type": "EnemyConfig", "ai_script": "Enemy::actions", "ai_start_name": "Enemy::initial_state", "ai_goal_name": "Enemy::final_state"}, + {"_type": "Personality", "hearing_distance": 5, "tough": false}, + {"_type": "Sprite", "name": "rat_with_sword", "scale": 1.0}, + {"_type": "Sound", "attack": "Small_Rat", "death": "Creature_Death_1"} + ] + } +} diff --git a/assets/icons.json b/assets/icons.json new file mode 100644 index 0000000..904cd97 --- /dev/null +++ b/assets/icons.json @@ -0,0 +1,12 @@ +{ + "healing_potion_small": + {"path": "assets/icons/healing_potion_small.png", + "frame_width": 96, + "frame_height": 96 + }, + "torch_horizontal_floor": + {"path": "assets/icons/torch_horizontal_floor.png", + "frame_width": 96, + "frame_height": 96 + } +} diff --git a/assets/items.json b/assets/items.json new file mode 100644 index 0000000..5c25db3 --- /dev/null +++ b/assets/items.json @@ -0,0 +1,32 @@ +{ + "TORCH_BAD": { + "id": "TORCH_BAD", + "name": "Crappy Torch", + "description": "A torch that barely lights the way. You wonder if it'd be better to not see the person who murders you.", + "inventory_count": 1, + "components": [ + {"_type": "LightSource", "strength": 50, "radius": 2.5}, + {"_type": "Tile", "display": 3848, + "foreground": "items/fg:flame", + "background": "color:transparent" + }, + {"_type": "Sprite", "name": "torch_horizontal_floor", "width": 256, "height": 256, "scale": 1.0}, + {"_type": "Sound", "attack": "pickup", "death": "blank"} + ] + }, + "POTION_HEALING_SMALL": { + "id": "POTION_HEALING_SMALL", + "name": "Small Healing Potion", + "description": "A small healing potion.", + "inventory_count": 1, + "components": [ + {"_type": "Tile", "display": 1003, + "foreground": "items/fg:potion", + "background": "color:transparent" + }, + {"_type": "Curative", "hp": 20}, + {"_type": "Sprite", "name": "healing_potion_small", "width": 256, "height": 256, "scale": 1.0}, + {"_type": "Sound", "attack": "pickup", "death": "blank"} + ] + } +} diff --git a/assets/map_tiles.json b/assets/map_tiles.json new file mode 100644 index 0000000..af61b68 --- /dev/null +++ b/assets/map_tiles.json @@ -0,0 +1,146 @@ +[ + { + "centered": false, + "display": 35, + "x": 0, + "y": 0 + }, + { + "centered": false, + "display": 8284, + "x": 64, + "y": 0 + }, + { + "centered": false, + "display": 11590, + "x": 128, + "y": 0 + }, + { + "centered": false, + "display": 10899, + "x": 192, + "y": 0 + }, + { + "centered": false, + "display": 9256, + "x": 256, + "y": 0 + }, + { + "centered": false, + "display": 9608, + "x": 320, + "y": 0 + }, + { + "centered": false, + "display": 10747, + "x": 384, + "y": 0 + }, + { + "centered": false, + "display": 8285, + "x": 448, + "y": 0 + }, + { + "centered": true, + "display": 1003, + "x": 512, + "y": 0 + }, + { + "centered": true, + "display": 3848, + "x": 576, + "y": 0 + }, + { + "centered": true, + "display": 85, + "x": 0, + "y": 64 + }, + { + "centered": true, + "display": 1939, + "x": 64, + "y": 64 + }, + { + "centered": true, + "display": 1890, + "x": 128, + "y": 64 + }, + { + "centered": true, + "display": 8687, + "x": 192, + "y": 64 + }, + { + "centered": true, + "display": 6105, + "x": 256, + "y": 64 + }, + { + "centered": true, + "display": 8793, + "x": 320, + "y": 64 + }, + { + "centered": true, + "display": 95, + "x": 384, + "y": 64 + }, + { + "centered": true, + "display": 1898, + "x": 448, + "y": 64 + }, + { + "centered": true, + "display": 42586, + "x": 512, + "y": 64 + }, + { + "centered": true, + "display": 2216, + "x": 576, + "y": 64 + }, + { + "centered": true, + "display": 10733, + "x": 0, + "y": 128 + }, + { + "centered": true, + "display": 2220, + "x": 64, + "y": 128 + }, + { + "centered": true, + "display": 1218, + "x": 128, + "y": 128 + }, + { + "centered": true, + "display": 1087, + "x": 128, + "y": 128 + } +] diff --git a/assets/palette.json b/assets/palette.json new file mode 100644 index 0000000..e3c68e1 --- /dev/null +++ b/assets/palette.json @@ -0,0 +1,61 @@ +{ + "color": { + "transparent": [100, 100, 100, 100], + "BAD": [255, 0, 0] + }, + "gui/theme": { + "black": [0, 0, 0, 255], + "dark_dark": [10, 10, 10, 255], + "dark_mid": [30, 30, 30, 255], + "dark_light": [60, 60, 60, 255], + "mid": [100, 100, 100, 255], + "light_dark": [150, 150, 150, 255], + "light_mid": [200, 200, 200, 255], + "light_light": [230, 230, 230, 255], + "white": [255, 255, 255, 255], + "fill_color": "gui/theme:dark_mid", + "text_color": "gui/theme:light_light", + "bg_color": "gui/theme:mid", + "border_color": "gui/theme:dark_dark", + "bg_color_dark": "gui/theme:black" + }, + "map/theme": { + "black": [0, 0, 0, 255], + "dark_dark": [10, 10, 10, 255], + "dark_mid": [30, 30, 30, 255], + "dark_light": [60, 60, 60, 255], + "mid": [100, 100, 100, 255], + "light_dark": [150, 150, 150, 255], + "light_mid": [200, 200, 200, 255], + "light_light": [230, 230, 230, 255], + "white": [255, 255, 255, 255] + }, + "items/fg": { + "flame": "map/theme:white", + "potion": "map/theme:white" + }, + "enemies/fg": { + "player": "map/theme:white", + "rat_giant": "map/theme:white" + }, + "tiles/fg": { + "floor_tile": "map/theme:mid", + "wall_plain": "map/theme:dark_mid", + "ceiling_black": "color:transparent" + }, + "tiles/bg": { + "floor_tile": "map/theme:dark_dark", + "wall_plain": "map/theme:dark_dark", + "ceiling_black": "color:transparent" + }, + "devices/fg": { + "stairs_down": [24, 205, 189], + "dead_body": [32, 123, 164], + "dead_body_lootable": [32, 123, 164] + }, + "devices/bg": { + "stairs_down": [24, 205, 189], + "dead_body": [24, 205, 189], + "dead_body_lootable": [24, 205, 189] + } +} diff --git a/assets/room_themes.json b/assets/room_themes.json new file mode 100644 index 0000000..d5d570d --- /dev/null +++ b/assets/room_themes.json @@ -0,0 +1,7 @@ +[ + { + "name": "Plain", + "floor": "floor_tile", + "walls": "wall_plain" + } +] diff --git a/assets/shaders.json b/assets/shaders.json new file mode 100644 index 0000000..4e13d67 --- /dev/null +++ b/assets/shaders.json @@ -0,0 +1,26 @@ +{ + "ui_shader": { + "file_name": "assets/shaders/ui_shader.frag", + "type": "fragment" + }, + "ERROR": { + "file_name": "assets/shaders/ui_error.frag", + "type": "fragment" + }, + "rayview_sprites": { + "file_name": "assets/shaders/rayview_sprites.frag", + "type": "fragment" + }, + "flame": { + "file_name": "assets/shaders/flame_trash.frag", + "type": "fragment" + }, + "lightning": { + "file_name": "assets/shaders/lightning_attack.frag", + "type": "fragment" + }, + "boss_hit": { + "file_name": "assets/shaders/flame_trash.frag", + "type": "fragment" + } +} diff --git a/assets/shaders/flame_trash.frag b/assets/shaders/flame_trash.frag new file mode 100644 index 0000000..092e4fd --- /dev/null +++ b/assets/shaders/flame_trash.frag @@ -0,0 +1,79 @@ +#version 120 +uniform vec2 u_resolution; +uniform float u_time; +uniform sampler2D source; +uniform float u_mouse; +uniform float value = 0.2; +uniform int octaves=8; + +float random (in vec2 st) { + return fract(sin(dot(st.xy, + vec2(12.9898,78.233)))* + 43758.5453123); +} + +float noise(in vec2 st) { + vec2 i = floor(st); + vec2 f = fract(st); + + float a = random(i); + float b = random(i + vec2(1.0, 0.0)); + float c = random(i + vec2(0.0, 1.0)); + float d = random(i + vec2(1.0, 1.0)); + + vec2 u = f * f * (3.0 - 2.0 * f); + + return mix(a, b, u.x) + + (c - a) * u.y * (1.0 - u.x) + + (d - b) * u.x * u.y; +} + +float fbm(in vec2 st) { + float v = 0.0; + float a = 0.5; + vec2 shift = vec2(100.0); + mat2 rot = mat2(cos(0.5), sin(0.5), + -sin(0.5), cos(0.5)); + + for(int i = 0; i < octaves; i++) { + v += a * noise(st); + st = rot * st * 2.0 + shift; + a *= 0.5; + } + + return v; +} + +void main() { + vec2 st = gl_FragCoord.xy/u_resolution.xy * 3.0; + vec3 color = vec3(0.0); + + float speed = u_time * 10.0; + float value = 0.8; // cos(u_time) * cos(u_time); + + vec2 q = vec2(0.0); + q.x = fbm(st + 0.00 * speed); + q.y = fbm(st + vec2(1.0)); + + vec2 r = vec2(0,0); + r.x += fbm( st + 1.0*q + vec2(1.0, 0.0)+ 0.15* speed ); + r.y += fbm( st + 1.0*q + vec2(-1.0, 0.0)+ 0.126* speed); + + float f = fbm(st * r); + + color = mix(vec3(0.666667,0.619608, 0.122777), + vec3(0.666667,0.666667,0.498039), + clamp((f*f)*4.0,0.0,1.0)); + + color = mix(color, + vec3(0.666667, 0.122222, 0.0666667), + clamp(length(r.x), 0.0, 1.0)); + + color *= (f*f*f+0.5*f*f+0.6*f) * value; + + vec4 pixel = texture2D(source, gl_TexCoord[0].xy); + + float mask = color.r * pixel.a; + + gl_FragColor = gl_Color * vec4(color, mask) + pixel; +} diff --git a/assets/shaders/lightning_attack.frag b/assets/shaders/lightning_attack.frag new file mode 100644 index 0000000..eb41295 --- /dev/null +++ b/assets/shaders/lightning_attack.frag @@ -0,0 +1,79 @@ +#version 120 +uniform vec2 u_resolution; +uniform float u_time; +uniform sampler2D source; +uniform float u_mouse; +uniform float value = 0.2; +uniform int octaves=8; + +float random (in vec2 st) { + return fract(sin(dot(st.xy, + vec2(12.9898,78.233)))* + 43758.5453123); +} + +float noise(in vec2 st) { + vec2 i = floor(st); + vec2 f = fract(st); + + float a = random(i); + float b = random(i + vec2(1.0, 0.0)); + float c = random(i + vec2(0.0, 1.0)); + float d = random(i + vec2(1.0, 1.0)); + + vec2 u = f * f * (3.0 - 2.0 * f); + + return mix(a, b, u.x) + + (c - a) * u.y * (1.0 - u.x) + + (d - b) * u.x * u.y; +} + +float fbm(in vec2 st) { + float v = 0.0; + float a = 0.5; + vec2 shift = vec2(100.0); + mat2 rot = mat2(cos(0.5), sin(0.5), + -sin(0.5), cos(0.5)); + + for(int i = 0; i < octaves; i++) { + v += a * noise(st); + st = rot * st * 2.0 + shift; + a *= 0.5; + } + + return v; +} + +void main() { + vec2 st = gl_FragCoord.xy/u_resolution.xy * 3.0; + vec3 color = vec3(0.0); + + float speed = u_time * 40.0; + float value = cos(u_time) * cos(u_time); + + vec2 q = vec2(0.0); + q.x = fbm(st + 0.00 * speed); + q.y = fbm(st + vec2(1.0)); + + vec2 r = vec2(0,0); + r.x += fbm( st + 1.0*q + vec2(1.0, 0.0)+ 0.15* speed ); + r.y += fbm( st + 1.0*q + vec2(-1.0, 0.0)+ 0.126* speed); + + float f = fbm(st / r); + + color = mix(vec3(0.122777,0.619608, 0.666667), + vec3(0.498039,0.666667,0.666667), + clamp((f*f)*4.0,0.0,1.0)); + + color = mix(color, + vec3(0.0666667, 0.122222, 0.666667), + clamp(length(r.x), 0.0, 1.0)); + + color *= (f*f*f+0.5*f*f+0.6*f) * value; + + vec4 pixel = texture2D(source, gl_TexCoord[0].xy); + + float mask = color.r * pixel.a; + + gl_FragColor = gl_Color * vec4(color, mask) + pixel; +} diff --git a/assets/shaders/rayview_sprites.frag b/assets/shaders/rayview_sprites.frag new file mode 100644 index 0000000..fcffabf --- /dev/null +++ b/assets/shaders/rayview_sprites.frag @@ -0,0 +1,25 @@ +uniform sampler2D source; +uniform sampler2D bloom; +uniform vec2 offsetFactor; +uniform float darkness; + +void main() +{ + vec2 textureCoordinates = gl_TexCoord[0].xy; + vec4 color = vec4(0.0); + color += texture2D(source, textureCoordinates - 4.0 * offsetFactor) * 0.0162162162; + color += texture2D(source, textureCoordinates - 3.0 * offsetFactor) * 0.0540540541; + color += texture2D(source, textureCoordinates - 2.0 * offsetFactor) * 0.1216216216; + color += texture2D(source, textureCoordinates - offsetFactor) * 0.1945945946; + color += texture2D(source, textureCoordinates) * 0.2270270270; + color += texture2D(source, textureCoordinates + offsetFactor) * 0.1945945946; + color += texture2D(source, textureCoordinates + 2.0 * offsetFactor) * 0.1216216216; + color += texture2D(source, textureCoordinates + 3.0 * offsetFactor) * 0.0540540541; + color += texture2D(source, textureCoordinates + 4.0 * offsetFactor) * 0.0162162162; + + vec4 sourceFragment = texture2D(source, gl_TexCoord[0].xy); + vec4 bloomFragment = texture2D(bloom, gl_TexCoord[0].xy); + float alpha = color.a; + gl_FragColor = (color + sourceFragment - bloomFragment) * darkness; + gl_FragColor.a = alpha; +} diff --git a/assets/shaders/ui_error.frag b/assets/shaders/ui_error.frag new file mode 100644 index 0000000..29ccc8c --- /dev/null +++ b/assets/shaders/ui_error.frag @@ -0,0 +1,18 @@ +uniform vec2 u_resolution; +uniform vec2 u_mouse; +uniform float u_duration; +uniform float u_time; +uniform float u_time_end; +uniform sampler2D texture; +uniform bool is_shape; + +void main() { + if(is_shape) { + vec4 color = vec4(1.0, 0.0, 0.0, 1.0); + gl_FragColor = gl_Color * color; + } else { + vec4 pixel = texture2D(texture, gl_TexCoord[0].xy); + vec4 color = vec4(1.0, 0.0, 0.0, 1.0); + gl_FragColor = gl_Color * color * pixel; + } +} diff --git a/assets/shaders/ui_shader.frag b/assets/shaders/ui_shader.frag new file mode 100644 index 0000000..73b77b4 --- /dev/null +++ b/assets/shaders/ui_shader.frag @@ -0,0 +1,29 @@ +uniform vec2 u_resolution; +uniform vec2 u_mouse; +uniform float u_duration; +uniform float u_time; +uniform float u_time_end; +uniform sampler2D texture; +uniform bool is_shape; +uniform bool hover; + +vec4 blink() { + if(hover) { + return vec4(0.95, 0.95, 1.0, 1.0); + } else { + float tick = (u_time_end - u_time) / u_duration; + float blink = mix(0.5, 1.0, tick); + return vec4(blink, blink, blink, 1.0); + } +} + +void main() { + vec4 color = blink(); + + if(!is_shape) { + vec4 pixel = texture2D(texture, gl_TexCoord[0].xy); + color *= pixel; + } + + gl_FragColor = gl_Color * color; +} diff --git a/assets/shaders/ui_shape_shader.frag b/assets/shaders/ui_shape_shader.frag new file mode 100644 index 0000000..c16d6ea --- /dev/null +++ b/assets/shaders/ui_shape_shader.frag @@ -0,0 +1,12 @@ +uniform vec2 u_resolution; +uniform vec2 u_mouse; +uniform float u_duration; +uniform float u_time; +uniform float u_time_end; + +void main() { + float tick = (u_time_end - u_time) / u_duration; + float blink = smoothstep(1.0, 0.5, tick); + vec4 color = vec4(blink, blink, blink, 1.0); + gl_FragColor = gl_Color * color; +} diff --git a/assets/tiles.json b/assets/tiles.json new file mode 100644 index 0000000..5dbae51 --- /dev/null +++ b/assets/tiles.json @@ -0,0 +1,36 @@ +{ + "floor_tile": { + "texture": "assets/textures/floor_gray_stone.png", + "display": 8284, + "ceiling": "ceiling_black", + "light": 0, + "foreground": "tiles/fg:floor_tile", + "background": "tiles/bg:floor_tile", + "id": 0 + }, + "wall_plain": { + "texture": "assets/textures/wall_plain.png", + "display": 9608, + "light": 0, + "door": "door_plain", + "foreground": "tiles/fg:wall_plain", + "background": "tiles/bg:wall_plain", + "id": 1 + }, + "ceiling_black": { + "texture": "assets/textures/ceiling_black.png", + "display": 35, + "light": 0, + "foreground": "tiles/fg:ceiling_black", + "background": "tiles/bg:ceiling_black", + "id": 2 + }, + "door_plain": { + "texture": "assets/doors/door_plain.png", + "display": 1087, + "light": 10, + "foreground": "tiles/fg:wall_plain", + "background": "tiles/bg:wall_plain", + "id": 8 + } +} diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..2919e79 --- /dev/null +++ b/meson.build @@ -0,0 +1,142 @@ + +project('raycaster', 'cpp', + version: '0.1.0', + default_options: [ + 'cpp_std=c++23', + 'cpp_args=-D_GLIBCXX_DEBUG=1 -D_GLIBCXX_DEBUG_PEDANTIC=1', + ]) + +# use this for common options only for our executables +cpp_args=[ + '-Wno-unused-parameter', + '-Wno-unused-function', + '-Wno-unused-variable', + '-Wno-unused-but-set-variable', + '-Wno-deprecated-declarations', +] +link_args=[] +# these are passed as override_defaults +exe_defaults = [ 'warning_level=2' ] + +cc = meson.get_compiler('cpp') +dependencies = [] + +if build_machine.system() == 'windows' + add_global_link_arguments( + '-static-libgcc', + '-static-libstdc++', + '-static', + '-flto', + language: 'cpp', + ) + + sfml_main = subproject('sfml').get_variable('sfml_main_dep') + opengl32 = cc.find_library('opengl32', required: true) + winmm = cc.find_library('winmm', required: true) + gdi32 = cc.find_library('gdi32', required: true) + + dependencies += [ + opengl32, winmm, gdi32, sfml_main + ] + exe_defaults += ['werror=true'] + +elif build_machine.system() == 'darwin' + add_global_link_arguments( + language: 'cpp', + ) + + opengl = dependency('OpenGL') + corefoundation = dependency('CoreFoundation') + carbon = dependency('Carbon') + cocoa = dependency('Cocoa') + iokit = dependency('IOKit') + corevideo = dependency('CoreVideo') + + link_args += ['-ObjC'] + exe_defaults += ['werror=false'] + dependencies += [ + opengl, corefoundation, carbon, cocoa, iokit, corevideo + ] +endif + +catch2 = subproject('catch2').get_variable('catch2_with_main_dep') +fmt = subproject('fmt').get_variable('fmt_dep') +json = subproject('nlohmann_json').get_variable('nlohmann_json_dep') +freetype2 = subproject('freetype2').get_variable('freetype_dep') + +flac = subproject('flac').get_variable('flac_dep') +ogg = subproject('ogg').get_variable('libogg_dep') +vorbis = subproject('vorbis').get_variable('vorbis_dep') +vorbisfile = subproject('vorbis').get_variable('vorbisfile_dep') +vorbisenc = subproject('vorbis').get_variable('vorbisenc_dep') +sfml_audio = subproject('sfml').get_variable('sfml_audio_dep') +sfml_graphics = subproject('sfml').get_variable('sfml_graphics_dep') +sfml_network = subproject('sfml').get_variable('sfml_network_dep') +sfml_system = subproject('sfml').get_variable('sfml_system_dep') +sfml_window = subproject('sfml').get_variable('sfml_window_dep') +lel_guecs = subproject('lel-guecs').get_variable('lel_guecs_dep') + +inc_dirs = include_directories('src') + +subdir('src') +subdir('tests') + +dependencies += [ + fmt, json, freetype2, + flac, ogg, vorbis, vorbisfile, vorbisenc, + sfml_audio, sfml_graphics, + sfml_network, sfml_system, + sfml_window, lel_guecs +] + +caster_lib = static_library('caster', + sources, + cpp_args: cpp_args, + include_directories: inc_dirs, + override_options: exe_defaults, + dependencies: dependencies) + +caster_dep = declare_dependency( + link_with: caster_lib, + include_directories: inc_dirs) + +dependencies += [ caster_dep ] + +executable('runtests', sources + tests, + cpp_args: cpp_args, + link_args: link_args, + include_directories: inc_dirs, + override_options: exe_defaults, + dependencies: dependencies + [catch2]) + +executable('zedcaster', + [ 'src/main.cpp' ], + cpp_args: cpp_args, + link_args: link_args, + include_directories: inc_dirs, + override_options: exe_defaults, + dependencies: dependencies) + +executable('animator', + [ 'tools/animator.cpp' ], + cpp_args: cpp_args, + link_args: link_args, + include_directories: inc_dirs, + override_options: exe_defaults, + dependencies: dependencies) + +executable('icongen', + [ 'tools/icongen.cpp' ], + cpp_args: cpp_args, + link_args: link_args, + include_directories: inc_dirs, + override_options: exe_defaults, + dependencies: dependencies) + +executable('fragviewer', + [ 'tools/fragviewer.cpp' ], + cpp_args: cpp_args, + link_args: link_args, + include_directories: inc_dirs, + override_options: exe_defaults, + dependencies: dependencies) diff --git a/scripts/animation_thing.ps1 b/scripts/animation_thing.ps1 new file mode 100644 index 0000000..db53d51 --- /dev/null +++ b/scripts/animation_thing.ps1 @@ -0,0 +1 @@ +magick montage -tile 3x1 -geometry +0+0 -background transparent .\assets\animations\torch_fixture_*.png assets/fixtures/torch_fixture.png diff --git a/scripts/build_assets.ps1 b/scripts/build_assets.ps1 new file mode 100644 index 0000000..fb87c47 --- /dev/null +++ b/scripts/build_assets.ps1 @@ -0,0 +1,41 @@ +Param ( + [string]$Colors=16, + [string]$Size="256x256" + ) + + +function Build-Images { + param ( + [string]$source, + [string]$pixel_count + ) + + $files = Get-ChildItem -Path "C:\Users\lcthw\Pictures\Games\Renders\Raycaster\$source" + $out_dir = ".\assets\" + $source.ToLower() + mkdir -force $out_dir + + foreach($file in $files) { + $in_name = $file.Name + $out_file = "$out_dir\$in_name" + Write-Output "In file: $in_name" + Write-Output "Out file: $out_file" + Write-Output "Size: $Size" + + .\scripts\pixelize.ps1 -InFile $file.FullName -OutFile $out_file -Colors $Colors -Pixel $pixel_count -Size $Size + } +} + +# Build-Images -Source "Textures" -pixel_count 12 +# Build-Images -Source "Sprites" -pixel_count 6 +# Build-Images -Source "Items" -pixel_count 2 +# Build-Images -Source "Animations" -pixel_count 24 +# Build-Images -Source "Hands" -pixel_count 6 +Build-Images -Source "Boss2" -pixel_count 4 +# Build-Images -Source "Fixtures" -pixel_count 24 + + +#magick montage -tile 3x1 -geometry +0+0 -background transparent .\assets\hands\female_hand_*.png .\assets\hands\female_hand.png + +#magick montage -tile 3x1 -geometry +0+0 -background transparent .\assets\hands\male_hand_*.png .\assets\hands\male_hand.png + +#cp -recurse -force C:\Users\lcthw\Pictures\Games\Renders\Raycaster\UI assets\ui diff --git a/scripts/coverage_report.ps1 b/scripts/coverage_report.ps1 new file mode 100644 index 0000000..f31b229 --- /dev/null +++ b/scripts/coverage_report.ps1 @@ -0,0 +1,13 @@ +rm -recurse -force coverage/* +cp *.cpp,*.hpp,*.rl builddir + +. .venv/Scripts/activate + +rm -recurse -force coverage +cp scripts\gcovr_patched_coverage.py .venv\Lib\site-packages\gcovr\coverage.py + +gcovr -o coverage/ --html --html-details --html-theme github.dark-blue --gcov-ignore-errors all --gcov-ignore-parse-errors negative_hits.warn_once_per_file -e builddir/subprojects -e builddir -e subprojects -e scratchpad -e tools -j 10 . + +rm *.gcov.json.gz + +start .\coverage\coverage_details.html diff --git a/scripts/coverage_reset.ps1 b/scripts/coverage_reset.ps1 new file mode 100644 index 0000000..4799ae6 --- /dev/null +++ b/scripts/coverage_reset.ps1 @@ -0,0 +1,7 @@ +mv .\subprojects\packagecache . +rm -recurse -force .\subprojects\,.\builddir\ +mkdir subprojects +mv .\packagecache .\subprojects\ +mkdir builddir +cp wraps\*.wrap subprojects\ +meson setup --default-library=static --prefer-static -Db_coverage=true builddir diff --git a/scripts/coverage_reset.sh b/scripts/coverage_reset.sh new file mode 100644 index 0000000..9970738 --- /dev/null +++ b/scripts/coverage_reset.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -e + +mv -f ./subprojects/packagecache . +rm -rf subprojects builddir +mkdir subprojects +mv packagecache ./subprojects/ +mkdir builddir +cp wraps/*.wrap subprojects/ +# on OSX you can't do this with static +meson setup -Db_coverage=true builddir diff --git a/scripts/gcovr_patched_coverage.py b/scripts/gcovr_patched_coverage.py new file mode 100644 index 0000000..7baecca --- /dev/null +++ b/scripts/gcovr_patched_coverage.py @@ -0,0 +1,1020 @@ +# -*- coding:utf-8 -*- + +# ************************** Copyrights and license *************************** +# +# This file is part of gcovr 8.3, a parsing and reporting tool for gcov. +# https://gcovr.com/en/8.3 +# +# _____________________________________________________________________________ +# +# Copyright (c) 2013-2025 the gcovr authors +# Copyright (c) 2013 Sandia Corporation. +# Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation, +# the U.S. Government retains certain rights in this software. +# +# This software is distributed under the 3-clause BSD License. +# For more information, see the README.rst file. +# +# **************************************************************************** + +""" +The gcovr coverage data model. + +This module represents the core data structures +and should not have dependencies on any other gcovr module, +also not on the gcovr.utils module. + +The data model should contain the exact same information +as the JSON input/output format. + +The types ending with ``*Coverage`` +contain per-project/-line/-decision/-branch coverage. + +The types ``SummarizedStats``, ``CoverageStat``, and ``DecisionCoverageStat`` +report aggregated metrics/percentages. +""" + +from __future__ import annotations +import logging +import os +import re +from typing import ( + ItemsView, + Iterator, + Iterable, + Optional, + TypeVar, + Union, + Literal, + ValuesView, +) +from dataclasses import dataclass + +from .utils import commonpath, force_unix_separator + +LOGGER = logging.getLogger("gcovr") + +_T = TypeVar("_T") + + +def sort_coverage( + covdata: Union[ + dict[str, FileCoverage], + dict[str, Union[FileCoverage, CoverageContainerDirectory]], + ], + sort_key: Literal["filename", "uncovered-number", "uncovered-percent"], + sort_reverse: bool, + by_metric: Literal["line", "branch", "decision"], + filename_uses_relative_pathname: bool = False, +) -> list[str]: + """Sort a coverage dict. + + covdata (dict): the coverage dictionary + sort_key ("filename", "uncovered-number", "uncovered-percent"): the values to sort by + sort_reverse (bool): reverse order if True + by_metric ("line", "branch", "decision"): select the metric to sort + filename_uses_relative_pathname (bool): for html, we break down a pathname to the + relative path, but not for other formats. + + returns: the sorted keys + """ + + basedir = commonpath(list(covdata.keys())) + + def key_filename(key: str) -> list[Union[int, str]]: + def convert_to_int_if_possible(text: str) -> Union[int, str]: + return int(text) if text.isdigit() else text + + key = ( + force_unix_separator( + os.path.relpath(os.path.realpath(key), os.path.realpath(basedir)) + ) + if filename_uses_relative_pathname + else key + ).casefold() + + return [convert_to_int_if_possible(part) for part in re.split(r"([0-9]+)", key)] + + def coverage_stat(key: str) -> CoverageStat: + cov = covdata[key] + if by_metric == "branch": + return cov.branch_coverage() + if by_metric == "decision": + return cov.decision_coverage().to_coverage_stat + return cov.line_coverage() + + def key_num_uncovered(key: str) -> int: + stat = coverage_stat(key) + uncovered = stat.total - stat.covered + return uncovered + + def key_percent_uncovered(key: str) -> float: + stat = coverage_stat(key) + covered = stat.covered + total = stat.total + + # No branches are always put directly after (or before when reversed) + # files with 100% coverage (by assigning such files 110% coverage) + return covered / total if total > 0 else 1.1 + + if sort_key == "uncovered-number": + # First sort filename alphabetical and then by the requested key + return sorted( + sorted(covdata, key=key_filename), + key=key_num_uncovered, + reverse=sort_reverse, + ) + if sort_key == "uncovered-percent": + # First sort filename alphabetical and then by the requested key + return sorted( + sorted(covdata, key=key_filename), + key=key_percent_uncovered, + reverse=sort_reverse, + ) + + # By default, we sort by filename alphabetically + return sorted(covdata, key=key_filename, reverse=sort_reverse) + + +class BranchCoverage: + r"""Represent coverage information about a branch. + + Args: + source_block_id (int): + The block number. + count (int): + Number of times this branch was followed. + fallthrough (bool, optional): + Whether this is a fallthrough branch. False if unknown. + throw (bool, optional): + Whether this is an exception-handling branch. False if unknown. + destination_block_id (int, optional): + The destination block of the branch. None if unknown. + excluded (bool, optional): + Whether the branch is excluded. + """ + + first_undefined_source_block_id: bool = True + + __slots__ = ( + "source_block_id", + "count", + "fallthrough", + "throw", + "destination_block_id", + "excluded", + ) + + def __init__( + self, + source_block_id: Optional[int], + count: int, + fallthrough: bool = False, + throw: bool = False, + destination_block_id: Optional[int] = None, + excluded: Optional[bool] = None, + ) -> None: + if count < 0: + raise AssertionError("count must not be a negative value.") + + self.source_block_id = source_block_id + self.count = count + self.fallthrough = fallthrough + self.throw = throw + self.destination_block_id = destination_block_id + self.excluded = excluded + + @property + def source_block_id_or_0(self) -> int: + """Get a valid block number (0) if there was no definition in GCOV file.""" + if self.source_block_id is None: + self.source_block_id = 0 + if BranchCoverage.first_undefined_source_block_id: + BranchCoverage.first_undefined_source_block_id = False + LOGGER.info("No block number defined, assuming 0 for all undefined") + + return self.source_block_id + + @property + def is_excluded(self) -> bool: + """Return True if the branch is excluded.""" + return False if self.excluded is None else self.excluded + + @property + def is_reportable(self) -> bool: + """Return True if the branch is reportable.""" + return not self.excluded + + @property + def is_covered(self) -> bool: + """Return True if the branch is covered.""" + return self.is_reportable and self.count > 0 + + +class CallCoverage: + r"""Represent coverage information about a call. + + Args: + callno (int): + The number of the call. + covered (bool): + Whether the call was performed. + excluded (bool, optional): + Whether the call is excluded. + """ + + __slots__ = "callno", "covered", "excluded" + + def __init__( + self, + callno: int, + covered: bool, + excluded: Optional[bool] = False, + ) -> None: + self.callno = callno + self.covered = covered + self.excluded = excluded + + @property + def is_reportable(self) -> bool: + """Return True if the call is reportable.""" + return not self.excluded + + @property + def is_covered(self) -> bool: + """Return True if the call is covered.""" + return self.is_reportable and self.covered + + +class ConditionCoverage: + r"""Represent coverage information about a condition. + + Args: + count (int): + The number of the call. + covered (int): + Whether the call was performed. + not_covered_true list[int]: + The conditions which were not true. + not_covered_false list[int]: + The conditions which were not false. + excluded (bool, optional): + Whether the condition is excluded. + """ + + __slots__ = "count", "covered", "not_covered_true", "not_covered_false", "excluded" + + def __init__( + self, + count: int, + covered: int, + not_covered_true: list[int], + not_covered_false: list[int], + excluded: Optional[bool] = False, + ) -> None: + if count < 0: + raise AssertionError("count must not be a negative value.") + if count < covered: + raise AssertionError("count must not be less than covered.") + self.count = count + self.covered = covered + self.not_covered_true = not_covered_true + self.not_covered_false = not_covered_false + self.excluded = excluded + + +class DecisionCoverageUncheckable: + r"""Represent coverage information about a decision.""" + + __slots__ = () + + def __init__(self) -> None: + pass + + +class DecisionCoverageConditional: + r"""Represent coverage information about a decision. + + Args: + count_true (int): + Number of times this decision was made. + + count_false (int): + Number of times this decision was made. + + """ + + __slots__ = "count_true", "count_false" + + def __init__(self, count_true: int, count_false: int) -> None: + if count_true < 0: + raise AssertionError("count_true must not be a negative value.") + self.count_true = count_true + if count_false < 0: + raise AssertionError("count_true must not be a negative value.") + self.count_false = count_false + + +class DecisionCoverageSwitch: + r"""Represent coverage information about a decision. + + Args: + count (int): + Number of times this decision was made. + """ + + __slots__ = ("count",) + + def __init__(self, count: int) -> None: + if count < 0: + raise AssertionError("count must not be a negative value.") + self.count = count + + +DecisionCoverage = Union[ + DecisionCoverageConditional, + DecisionCoverageSwitch, + DecisionCoverageUncheckable, +] + + +class FunctionCoverage: + r"""Represent coverage information about a function. + + The counter is stored as dictionary with the line as key to be able + to merge function coverage in different ways + + Args: + name (str): + The mangled name of the function, None if not available. + demangled_name (str): + The demangled name (signature) of the functions. + lineno (int): + The line number. + count (int): + How often this function was executed. + blocks (float): + Block coverage of function. + start ((int, int)), optional): + Tuple with function start line and column. + end ((int, int)), optional): + Tuple with function end line and column. + excluded (bool, optional): + Whether this line is excluded by a marker. + """ + + __slots__ = ( + "name", + "demangled_name", + "count", + "blocks", + "start", + "end", + "excluded", + ) + + def __init__( + self, + name: Optional[str], + demangled_name: str, + *, + lineno: int, + count: int, + blocks: float, + start: Optional[tuple[int, int]] = None, + end: Optional[tuple[int, int]] = None, + excluded: bool = False, + ) -> None: + if count < 0: count = 0 + self.name = name + self.demangled_name = demangled_name + self.count = dict[int, int]({lineno: count}) + self.blocks = dict[int, float]({lineno: blocks}) + self.excluded = dict[int, bool]({lineno: excluded}) + self.start: Optional[dict[int, tuple[int, int]]] = ( + None if start is None else {lineno: start} + ) + self.end: Optional[dict[int, tuple[int, int]]] = ( + None if end is None else {lineno: end} + ) + + +class LineCoverage: + r"""Represent coverage information about a line. + + Each line is either *excluded* or *reportable*. + + A *reportable* line is either *covered* or *uncovered*. + + The default state of a line is *coverable*/*reportable*/*uncovered*. + + Args: + lineno (int): + The line number. + count (int): + How often this line was executed at least partially. + function_name (str, optional): + Mangled name of the function the line belongs to. + block_ids (*int, optional): + List of block ids in this line + excluded (bool, optional): + Whether this line is excluded by a marker. + md5 (str, optional): + The md5 checksum of the source code line. + """ + + __slots__ = ( + "lineno", + "count", + "function_name", + "block_ids", + "excluded", + "md5", + "branches", + "conditions", + "decision", + "calls", + ) + + def __init__( + self, + lineno: int, + count: int, + function_name: Optional[str] = None, + block_ids: Optional[list[int]] = None, + md5: Optional[str] = None, + excluded: bool = False, + ) -> None: + if lineno <= 0: + raise AssertionError("Line number must be a positive value.") + if count < 0: + raise AssertionError("count must not be a negative value.") + + self.lineno: int = lineno + self.count: int = count + self.function_name: Optional[str] = function_name + self.block_ids: Optional[list[int]] = block_ids + self.md5: Optional[str] = md5 + self.excluded: bool = excluded + self.branches = dict[int, BranchCoverage]() + self.conditions = dict[int, ConditionCoverage]() + self.decision: Optional[DecisionCoverage] = None + self.calls = dict[int, CallCoverage]() + + @property + def is_excluded(self) -> bool: + """Return True if the line is excluded.""" + return self.excluded + + @property + def is_reportable(self) -> bool: + """Return True if the line is reportable.""" + return not self.excluded + + @property + def is_covered(self) -> bool: + """Return True if the line is covered.""" + return self.is_reportable and self.count > 0 + + @property + def is_uncovered(self) -> bool: + """Return True if the line is uncovered.""" + return self.is_reportable and self.count == 0 + + @property + def has_uncovered_branch(self) -> bool: + """Return True if the line has a uncovered branches.""" + return not all( + branchcov.is_covered or branchcov.is_excluded + for branchcov in self.branches.values() + ) + + @property + def has_uncovered_decision(self) -> bool: + """Return True if the line has a uncovered decision.""" + if self.decision is None: + return False + + if isinstance(self.decision, DecisionCoverageUncheckable): + return False + + if isinstance(self.decision, DecisionCoverageConditional): + return self.decision.count_true == 0 or self.decision.count_false == 0 + + if isinstance(self.decision, DecisionCoverageSwitch): + return self.decision.count == 0 + + raise AssertionError(f"Unknown decision type: {self.decision!r}") + + def exclude(self) -> None: + """Exclude line from coverage statistic.""" + self.excluded = True + self.count = 0 + self.branches.clear() + self.conditions.clear() + self.decision = None + self.calls.clear() + + def branch_coverage(self) -> CoverageStat: + """Return the branch coverage statistic of the line.""" + total = 0 + covered = 0 + for branchcov in self.branches.values(): + if branchcov.is_reportable: + total += 1 + if branchcov.is_covered: + covered += 1 + return CoverageStat(covered=covered, total=total) + + def condition_coverage(self) -> CoverageStat: + """Return the condition coverage statistic of the line.""" + total = 0 + covered = 0 + for condition in self.conditions.values(): + total += condition.count + covered += condition.covered + return CoverageStat(covered=covered, total=total) + + def decision_coverage(self) -> DecisionCoverageStat: + """Return the decision coverage statistic of the line.""" + if self.decision is None: + return DecisionCoverageStat(0, 0, 0) + + if isinstance(self.decision, DecisionCoverageUncheckable): + return DecisionCoverageStat(0, 1, 2) # TODO should it be uncheckable=2? + + if isinstance(self.decision, DecisionCoverageConditional): + covered = 0 + if self.decision.count_true > 0: + covered += 1 + if self.decision.count_false > 0: + covered += 1 + return DecisionCoverageStat(covered, 0, 2) + + if isinstance(self.decision, DecisionCoverageSwitch): + covered = 0 + if self.decision.count > 0: + covered += 1 + return DecisionCoverageStat(covered, 0, 1) + + raise AssertionError(f"Unknown decision type: {self.decision!r}") + + +class FileCoverage: + """Represent coverage information about a file.""" + + __slots__ = "filename", "functions", "lines", "data_sources" + + def __init__( + self, filename: str, data_source: Optional[Union[str, set[str]]] + ) -> None: + self.filename: str = filename + self.functions = dict[str, FunctionCoverage]() + self.lines = dict[int, LineCoverage]() + self.data_sources = ( + set[str]() + if data_source is None + else set[str]( + [data_source] if isinstance(data_source, str) else data_source + ) + ) + + def filter_for_function(self, functioncov: FunctionCoverage) -> FileCoverage: + """Get a file coverage object reduced to a single function""" + if functioncov.name not in self.functions: + raise AssertionError( + f"Function {functioncov.name} must be in filtered file coverage object." + ) + if functioncov.name is None: + raise AssertionError( + "Data for filtering is missing. Need supported GCOV JSON format to get the information." + ) + filecov = FileCoverage(self.filename, self.data_sources) + filecov.functions[functioncov.name] = functioncov + + filecov.lines = { + lineno: linecov + for lineno, linecov in self.lines.items() + if linecov.function_name == functioncov.name + } + + return filecov + + @property + def stats(self) -> SummarizedStats: + """Create a coverage statistic of a file coverage object.""" + return SummarizedStats( + line=self.line_coverage(), + branch=self.branch_coverage(), + condition=self.condition_coverage(), + decision=self.decision_coverage(), + function=self.function_coverage(), + call=self.call_coverage(), + ) + + def function_coverage(self) -> CoverageStat: + """Return the function coverage statistic of the file.""" + total = 0 + covered = 0 + + for functioncov in self.functions.values(): + for lineno, excluded in functioncov.excluded.items(): + if not excluded: + total += 1 + if functioncov.count[lineno] > 0: + covered += 1 + + return CoverageStat(covered, total) + + def line_coverage(self) -> CoverageStat: + """Return the line coverage statistic of the file.""" + total = 0 + covered = 0 + + for linecov in self.lines.values(): + if linecov.is_reportable: + total += 1 + if linecov.is_covered: + covered += 1 + + return CoverageStat(covered, total) + + def branch_coverage(self) -> CoverageStat: + """Return the branch coverage statistic of the file.""" + stat = CoverageStat.new_empty() + + for linecov in self.lines.values(): + if linecov.is_reportable: + stat += linecov.branch_coverage() + + return stat + + def condition_coverage(self) -> CoverageStat: + """Return the condition coverage statistic of the file.""" + stat = CoverageStat.new_empty() + + for linecov in self.lines.values(): + if linecov.is_reportable: + stat += linecov.condition_coverage() + + return stat + + def decision_coverage(self) -> DecisionCoverageStat: + """Return the decision coverage statistic of the file.""" + stat = DecisionCoverageStat.new_empty() + + for linecov in self.lines.values(): + if linecov.is_reportable: + stat += linecov.decision_coverage() + + return stat + + def call_coverage(self) -> CoverageStat: + """Return the call coverage statistic of the file.""" + covered = 0 + total = 0 + + for linecov in self.lines.values(): + if linecov.is_reportable and len(linecov.calls) > 0: + for callcov in linecov.calls.values(): + if callcov.is_reportable: + total += 1 + if callcov.is_covered: + covered += 1 + + return CoverageStat(covered, total) + + +class CoverageContainer: + """Coverage container holding all the coverage data.""" + + def __init__(self) -> None: + self.data = dict[str, FileCoverage]() + self.directories = list[CoverageContainerDirectory]() + + def __getitem__(self, key: str) -> FileCoverage: + return self.data[key] + + def __len__(self) -> int: + return len(self.data) + + def __contains__(self, key: str) -> bool: + return key in self.data + + def __iter__(self) -> Iterator[str]: + return iter(self.data) + + def values(self) -> ValuesView[FileCoverage]: + """Get the file coverage data objects.""" + return self.data.values() + + def items(self) -> ItemsView[str, FileCoverage]: + """Get the file coverage data items.""" + return self.data.items() + + @property + def stats(self) -> SummarizedStats: + """Create a coverage statistic from a coverage data object.""" + stats = SummarizedStats.new_empty() + for filecov in self.values(): + stats += filecov.stats + return stats + + def sort_coverage( + self, + sort_key: Literal["filename", "uncovered-number", "uncovered-percent"], + sort_reverse: bool, + by_metric: Literal["line", "branch", "decision"], + filename_uses_relative_pathname: bool = False, + ) -> list[str]: + """Sort the coverage data""" + return sort_coverage( + self.data, + sort_key, + sort_reverse, + by_metric, + filename_uses_relative_pathname, + ) + + @staticmethod + def _get_dirname(filename: str) -> Optional[str]: + """Get the directory name with a trailing path separator. + + >>> import os + >>> CoverageContainer._get_dirname("bar/foobar.cpp".replace("/", os.sep)).replace(os.sep, "/") + 'bar/' + >>> CoverageContainer._get_dirname("/foo/bar/A/B.cpp".replace("/", os.sep)).replace(os.sep, "/") + '/foo/bar/A/' + >>> CoverageContainer._get_dirname(os.sep) is None + True + """ + if filename == os.sep: + return None + return str(os.path.dirname(filename.rstrip(os.sep))) + os.sep + + def populate_directories( + self, sorted_keys: Iterable[str], root_filter: re.Pattern[str] + ) -> None: + r"""Populate the list of directories and add accumulated stats. + + This function will accumulate statistics such that every directory + above it will know the statistics associated with all files deep within a + directory structure. + + Args: + sorted_keys: The sorted keys for covdata + root_filter: Information about the filter used with the root directory + """ + + # Get the directory coverage + subdirs = dict[str, CoverageContainerDirectory]() + for key in sorted_keys: + filecov = self[key] + dircov: Optional[CoverageContainerDirectory] = None + dirname: Optional[str] = ( + os.path.dirname(filecov.filename) + .replace("\\", os.sep) + .replace("/", os.sep) + .rstrip(os.sep) + ) + os.sep + while dirname is not None and root_filter.search(dirname + os.sep): + if dirname not in subdirs: + subdirs[dirname] = CoverageContainerDirectory(dirname) + if dircov is None: + subdirs[dirname][filecov.filename] = filecov + else: + subdirs[dirname].data[dircov.filename] = dircov + subdirs[dircov.filename].parent_dirname = dirname + subdirs[dirname].stats += filecov.stats + dircov = subdirs[dirname] + dirname = CoverageContainer._get_dirname(dirname) + + # Replace directories where only one sub container is available + # with the content this sub container + LOGGER.debug( + "Replace directories with only one sub element with the content of this." + ) + subdirs_to_remove = set() + for dirname, covdata_dir in subdirs.items(): + # There is exact one element, replace current element with referenced element + if len(covdata_dir) == 1: + # Get the orphan item + orphan_key, orphan_value = next(iter(covdata_dir.items())) + # The only child is a File object + if isinstance(orphan_value, FileCoverage): + # Replace the reference to ourself with our content + if covdata_dir.parent_dirname is not None: + LOGGER.debug( + f"Move {orphan_key} to {covdata_dir.parent_dirname}." + ) + parent_covdata_dir = subdirs[covdata_dir.parent_dirname] + parent_covdata_dir[orphan_key] = orphan_value + del parent_covdata_dir[dirname] + subdirs_to_remove.add(dirname) + else: + LOGGER.debug( + f"Move content of {orphan_value.dirname} to {dirname}." + ) + # Replace the children with the orphan ones + covdata_dir.data = orphan_value.data + # Change the parent key of each new child element + for new_child_value in covdata_dir.values(): + if isinstance(new_child_value, CoverageContainerDirectory): + new_child_value.parent_dirname = dirname + # Mark the key for removal. + subdirs_to_remove.add(orphan_key) + + for dirname in subdirs_to_remove: + del subdirs[dirname] + + self.directories = list(subdirs.values()) + + +class CoverageContainerDirectory: + """Represent coverage information about a directory.""" + + __slots__ = "dirname", "parent_dirname", "data", "stats" + + def __init__(self, dirname: str) -> None: + super().__init__() + self.dirname: str = dirname + self.parent_dirname: Optional[str] = None + self.data = dict[str, Union[FileCoverage, CoverageContainerDirectory]]() + self.stats: SummarizedStats = SummarizedStats.new_empty() + + def __setitem__( + self, key: str, item: Union[FileCoverage, CoverageContainerDirectory] + ) -> None: + self.data[key] = item + + def __getitem__(self, key: str) -> Union[FileCoverage, CoverageContainerDirectory]: + return self.data[key] + + def __delitem__(self, key: str) -> None: + del self.data[key] + + def __len__(self) -> int: + return len(self.data) + + def values(self) -> ValuesView[Union[FileCoverage, CoverageContainerDirectory]]: + """Get the file coverage data objects.""" + return self.data.values() + + def items(self) -> ItemsView[str, Union[FileCoverage, CoverageContainerDirectory]]: + """Get the file coverage data items.""" + return self.data.items() + + @property + def filename(self) -> str: + """Helpful function for when we use this DirectoryCoverage in a union with FileCoverage""" + return self.dirname + + def sort_coverage( + self, + sort_key: Literal["filename", "uncovered-number", "uncovered-percent"], + sort_reverse: bool, + by_metric: Literal["line", "branch", "decision"], + filename_uses_relative_pathname: bool = False, + ) -> list[str]: + """Sort the coverage data""" + return sort_coverage( + self.data, + sort_key, + sort_reverse, + by_metric, + filename_uses_relative_pathname, + ) + + def line_coverage(self) -> CoverageStat: + """A simple wrapper function necessary for sort_coverage().""" + return self.stats.line + + def branch_coverage(self) -> CoverageStat: + """A simple wrapper function necessary for sort_coverage().""" + return self.stats.branch + + def decision_coverage(self) -> DecisionCoverageStat: + """A simple wrapper function necessary for sort_coverage().""" + return self.stats.decision + + +@dataclass +class SummarizedStats: + """Data class for the summarized coverage statistics.""" + + line: CoverageStat + branch: CoverageStat + condition: CoverageStat + decision: DecisionCoverageStat + function: CoverageStat + call: CoverageStat + + @staticmethod + def new_empty() -> SummarizedStats: + """Create a empty coverage statistic.""" + return SummarizedStats( + line=CoverageStat.new_empty(), + branch=CoverageStat.new_empty(), + condition=CoverageStat.new_empty(), + decision=DecisionCoverageStat.new_empty(), + function=CoverageStat.new_empty(), + call=CoverageStat.new_empty(), + ) + + def __iadd__(self, other: SummarizedStats) -> SummarizedStats: + self.line += other.line + self.branch += other.branch + self.condition += other.condition + self.decision += other.decision + self.function += other.function + self.call += other.call + return self + + +@dataclass +class CoverageStat: + """A single coverage metric, e.g. the line coverage percentage of a file.""" + + covered: int + """How many elements were covered.""" + + total: int + """How many elements there were in total.""" + + @staticmethod + def new_empty() -> CoverageStat: + """Create a empty coverage statistic.""" + return CoverageStat(0, 0) + + @property + def percent(self) -> Optional[float]: + """Percentage of covered elements, equivalent to ``self.percent_or(None)``""" + return self.percent_or(None) + + def percent_or(self, default: _T) -> Union[float, _T]: + """Percentage of covered elements. + + Coverage is truncated to one decimal: + >>> CoverageStat(1234, 10000).percent_or("default") + 12.3 + + Coverage is capped at 99.9% unless everything is covered: + >>> CoverageStat(9999, 10000).percent_or("default") + 99.9 + >>> CoverageStat(10000, 10000).percent_or("default") + 100.0 + + If there are no elements, percentage is NaN and the default will be returned: + >>> CoverageStat(0, 0).percent_or("default") + 'default' + """ + if not self.total: + return default + + # Return 100% only if covered == total. + if self.covered == self.total: + return 100.0 + + # There is at least one uncovered item. + # Round to 1 decimal and clamp to max 99.9%. + ratio = self.covered / self.total + return min(99.9, round(ratio * 100.0, 1)) + + def __iadd__(self, other: CoverageStat) -> CoverageStat: + self.covered += other.covered + self.total += other.total + return self + + +@dataclass +class DecisionCoverageStat: + """A CoverageStat for decision coverage (accounts for Uncheckable cases).""" + + covered: int + uncheckable: int + total: int + + @classmethod + def new_empty(cls) -> DecisionCoverageStat: + """Create a empty decision coverage statistic.""" + return cls(0, 0, 0) + + @property + def to_coverage_stat(self) -> CoverageStat: + """Convert a decision coverage statistic to a coverage statistic.""" + return CoverageStat(covered=self.covered, total=self.total) + + @property + def percent(self) -> Optional[float]: + """Return the percent value of the coverage.""" + return self.to_coverage_stat.percent + + def percent_or(self, default: _T) -> Union[float, _T]: + """Return the percent value of the coverage or the given default if no coverage is present.""" + return self.to_coverage_stat.percent_or(default) + + def __iadd__(self, other: DecisionCoverageStat) -> DecisionCoverageStat: + self.covered += other.covered + self.uncheckable += other.uncheckable + self.total += other.total + return self diff --git a/scripts/magick/pixelize b/scripts/magick/pixelize new file mode 100644 index 0000000..8d1fc71 --- /dev/null +++ b/scripts/magick/pixelize @@ -0,0 +1,188 @@ +#!/bin/bash +# +# Developed by Fred Weinhaus 5/8/2008 .......... revised 4/25/2015 +# +# ------------------------------------------------------------------------------ +# +# Licensing: +# +# Copyright © Fred Weinhaus +# +# My scripts are available free of charge for non-commercial use, ONLY. +# +# For use of my scripts in commercial (for-profit) environments or +# non-free applications, please contact me (Fred Weinhaus) for +# licensing arrangements. My email address is fmw at alink dot net. +# +# If you: 1) redistribute, 2) incorporate any of these scripts into other +# free applications or 3) reprogram them in another scripting language, +# then you must contact me for permission, especially if the result might +# be used in a commercial or for-profit environment. +# +# My scripts are also subject, in a subordinate manner, to the ImageMagick +# license, which can be found at: http://www.imagemagick.org/script/license.php +# +# ------------------------------------------------------------------------------ +# +#### +# +# USAGE: pixelize [-s size] [-m mode] infile outfile +# USAGE: pixelize [-h or -help] +# +# OPTIONS: +# +# -s size pixelization size; size>0; default=3 +# -m mode mode of minimizing; 1=resize; 2=sample; default=1 +# +### +# +# NAME: PIXELIZE +# +# PURPOSE: To create a pixelized or blocky effect in an image. +# +# DESCRIPTION: PIXELIZE creates a pixelized or blocky effect in an +# image where more pixelization (larger sizes) create larger blocky +# effects. +# +# OPTIONS: +# +# -s size ... SIZE is the pixelization (block) size. Values are greater +# than 0. The default is 3. +# +# -m mode ... MODE is the mode of minimizing. Choices are 1 for -resize +# and 2 for -sample. The default=1 +# +# CAVEAT: No guarantee that this script will work on all platforms, +# nor that trapping of inconsistent parameters is complete and +# foolproof. Use At Your Own Risk. +# +###### +# + +# set default values +size=3 +mode=1 + +# set directory for temporary files +dir="." # suggestions are dir="." or dir="/tmp" + +# set up functions to report Usage and Usage with Description +PROGNAME=`type $0 | awk '{print $3}'` # search for executable on path +PROGDIR=`dirname $PROGNAME` # extract directory of program +PROGNAME=`basename $PROGNAME` # base name of program +usage1() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^###/g; /^#/!q; s/^#//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } +usage2() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^######/g; /^#/!q; s/^#*//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } + + +# function to report error messages +errMsg() + { + echo "" + echo $1 + echo "" + usage1 + exit 1 + } + + +# function to test for minus at start of value of second part of option 1 or 2 +checkMinus() + { + test=`echo "$1" | grep -c '^-.*$'` # returns 1 if match; 0 otherwise + [ $test -eq 1 ] && errMsg "$errorMsg" + } + +# test for correct number of arguments and get values +if [ $# -eq 0 ] + then + # help information + echo "" + usage2 + exit 0 +elif [ $# -gt 6 ] + then + errMsg "--- TOO MANY ARGUMENTS WERE PROVIDED ---" +else + while [ $# -gt 0 ] + do + # get parameter values + case "$1" in + -h|-help) # help information + echo "" + usage2 + exit 0 + ;; + -s) # get size + shift # to get the next parameter - scale + # test if parameter starts with minus sign + errorMsg="--- INVALID SIZE SPECIFICATION ---" + checkMinus "$1" + size=`expr "$1" : '\([0-9]*\)'` + [ "$size" = "" ] && errMsg "SIZE=$size MUST BE AN INTEGER" + sizetest=`echo "$size <= 0" | bc` + [ $sizetest -eq 1 ] && errMsg "--- SIZE=$size MUST BE A POSITIVE INTEGER ---" + ;; + -m) # get mode + shift # to get the next parameter - mode + # test if parameter starts with minus sign + errorMsg="--- INVALID MODE SPECIFICATION ---" + checkMinus "$1" + mode=`expr "$1" : '\([0-9]*\)'` + [ "$mode" = "" ] && errMsg "MODE=$mode MUST BE AN INTEGER" + [ $mode -ne 1 -a $mode -ne 2 ] && errMsg "--- MODE=$mode MUST BE EITHER 1 OR 2 ---" + ;; + -) # STDIN and end of arguments + break + ;; + -*) # any other - argument + errMsg "--- UNKNOWN OPTION ---" + ;; + *) # end of arguments + break + ;; + esac + shift # next option + done + # + # get infile and outfile + infile="$1" + outfile="$2" +fi + +# test that infile provided +[ "$infile" = "" ] && errMsg "NO INPUT FILE SPECIFIED" + +# test that outfile provided +[ "$outfile" = "" ] && errMsg "NO OUTPUT FILE SPECIFIED" + + +# test if image an ordinary, readable and non-zero size +if [ -f $infile -a -r $infile -a -s $infile ] + then + : 'Do Nothing' +else + errMsg "--- FILE $infile DOES NOT EXIST OR IS NOT AN ORDINARY FILE, NOT READABLE OR HAS ZERO SIZE ---" +fi + +# get parameters +w=`convert $infile -format "%[fx:w]" info:` +h=`convert $infile -format "%[fx:h]" info:` +minify=`convert xc: -format "%[fx:100/$size]" info:` + +# process image +if [ $mode -eq 1 ]; then + convert $infile -resize $minify% -scale ${w}x${h}! "$outfile" +elif [ $mode -eq 2 ]; then + convert $infile -sample $minify% -scale ${w}x${h}! "$outfile" +fi +exit 0 diff --git a/scripts/magick/position b/scripts/magick/position new file mode 100644 index 0000000..f739427 --- /dev/null +++ b/scripts/magick/position @@ -0,0 +1,372 @@ +#!/bin/bash +# +# Developed by Fred Weinhaus 2/24/2022 .......... revised 2/24/2022 +# +# ------------------------------------------------------------------------------ +# +# Licensing: +# +# Copyright © Fred Weinhaus +# +# My scripts are available free of charge for non-commercial use, ONLY. +# +# For use of my scripts in commercial (for-profit) environments or +# non-free applications, please contact me (Fred Weinhaus) for +# licensing arrangements. My email address is fmw at alink dot net. +# +# If you: 1) redistribute, 2) incorporate any of these scripts into other +# free applications or 3) reprogram them in another scripting language, +# then you must contact me for permission, especially if the result might +# be used in a commercial or for-profit environment. +# +# My scripts are also subject, in a subordinate manner, to the ImageMagick +# license, which can be found at: http://www.imagemagick.org/script/license.php +# +# ------------------------------------------------------------------------------ +# +#### +# +# USAGE: position [-m method] [-d direction] [-o offset] [-l leftpt] +# [-r rightpt] [-b bcolor] [-f format] [-T trim] infile1 infile2 outfile +# +# USAGE: position [-h or -help] +# +# OPTIONS: +# +# -m method positioning method; choices are: offset or cpoints; +# default=offset +# -d direction positioning direction; choices are: horizontal or +# vertical; default=horizontal +# -o offset offset +X+Y values for left/ope edge of second image +# relative to right/bottom edge of first image. Used when +# method=offset; positive or negative offsets are allowed; +# default=+0+0 +# -l leftpt left (first) image control x,y point; default=0,0 +# -r rightpt right (second) image control x,y point; default=0,0 +# -b bcolor background color to fill empty spaces +# -f format output color format; choices are: RG, GB, BR or RGB; +# default=RGB +# -T trim trim output to remove any background fill areas +# +### +# +# NAME: POSITION +# +# PURPOSE: To position one image relative to another image. +# +# DESCRIPTION: POSITION aligns or offsets one image relative to a another +# image. The second image is positioned relative to the first image either +# horizontally or vertically. Positioning can be done using X and Y offsets +# or by specifying one controll point for each image. +# +# OPTIONS: +# +# -m method ... positioning METHOD. The choices are: offset (o) or cpoints (c). +# The default=offset. +# +# -d direction ... positioning DIRECTION. The choices are: horizontal (h) or +# vertical (v). The default=horizontal. +# +# -o offset ... OFFSET +X+Y values for left/top edge of second image relative +# to right/bottom edge of first image. This is used when method=offset. +# Position X and Y offsets may be either positive or negative. The default=+0+0 +# +# -l leftpt ... LEFT (first) image control x,y POINT. Values are integers>0. +# The default=0,0 +# +# -r rightpt ... RIGHT (second) image control x,y POINT. Values are integers>0. +# The default=0,0 +# +# -b bcolor ... BGCOLOR is the background color to fill empty spaces. Any +# Imagemagick color is allowed. The default=none (transparent) +# +# -f format ... output color FORMAT; The choices are: RG, GB, BR or RGB. +# RGB is the normal color image. RG, for example, is first image in Red and +# second image in Green and any overlay will show in yellow (mix of Red and +# Green). The default=RGB. +# +# -T trim ... TRIM output to remove any background fill areas. +# Choices are: yes (y) or no (n). The default=no. Background color must be +# unique in the image for the trim to work properly. +# +# LIMITATIONS: TRIM option only works for Imagemagick 7.0.9-0 or higher. +# +# CAVEAT: No guarantee that this script will work on all platforms, +# nor that trapping of inconsistent parameters is complete and +# foolproof. Use At Your Own Risk. +# +###### +# + +# set default values +method="offset" # offset or cpoints +direction="horizontal" # horizontal or vertical +offset=+0+0 # offset +#offset=-90-40 # offset +leftpt="0,0" # left image single control point +rightpt="0,0" # right image single control point +#leftpt="287,49" # left image single control point +#rightpt="76,89" # right image single control point +bcolor=none # background color +format="RGB" # RG or GB or BR or RGB output color format +trim="no" # trim output; yes or no + +# set directory for temporary files +tmpdir="/tmp" + +# set up functions to report Usage and Usage with Description +PROGNAME=`type $0 | awk '{print $3}'` # search for executable on path +PROGDIR=`dirname $PROGNAME` # extract directory of program +PROGNAME=`basename $PROGNAME` # base name of program +usage1() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^###/g; /^#/!q; s/^#//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } +usage2() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^######/g; /^#/!q; s/^#*//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } + + +# function to report error messages +errMsg() + { + echo "" + echo $1 + echo "" + usage1 + exit 1 + } + + +# function to test for minus at start of value of second part of option 1 or 2 +checkMinus() + { + test=`echo "$1" | grep -c '^-.*$'` # returns 1 if match; 0 otherwise + [ $test -eq 1 ] && errMsg "$errorMsg" + } + +# test for correct number of arguments and get values +if [ $# -eq 0 ] + then + # help information + echo "" + usage2 + exit 0 +elif [ $# -gt 19 ] + then + errMsg "--- TOO MANY ARGUMENTS WERE PROVIDED ---" +else + while [ $# -gt 0 ] + do + # get parameter values + case "$1" in + -h|-help) # help information + echo "" + usage2 + exit 0 + ;; + -m) # method + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID METHOD SPECIFICATION ---" + checkMinus "$1" + method=`echo "$1" | tr "[:upper:]" "[:lower:]"` + case "$method" in + offset|o) method="offset" ;; + cpoints|c) method="cpoints" ;; + *) errMsg "--- METHOD=$method IS AN INVALID VALUE ---" + esac + ;; + -d) # direction + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID DIRECTION SPECIFICATION ---" + checkMinus "$1" + direction=`echo "$1" | tr "[:upper:]" "[:lower:]"` + case "$direction" in + horizontal|h) direction="horizontal" ;; + vertical|v) direction="vertical" ;; + *) errMsg "--- DIRECTION=$direction IS AN INVALID VALUE ---" + esac + ;; + -o) # offset + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID OFFSET SPECIFICATION ---" + #checkMinus "$1" + offset=`expr "$1" : '\([-+][0-9]*[-+][0-9]*\)'` + [ "$offset" = "" ] && errMsg "--- OFFSET=$offset IS INVALID ---" + ;; + -l) # leftpt + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID LEFTPT SPECIFICATION ---" + checkMinus "$1" + leftpt=`expr "$1" : '\([0-9]*,[0-9]*\)'` + [ "$leftpt" = "" ] && errMsg "--- LEFTPT=$leftpt IS INVALID ---" + ;; + -r) # rightpt + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID RIGHTPT SPECIFICATION ---" + checkMinus "$1" + rightpt=`expr "$1" : '\([0-9]*,[0-9]*\)'` + [ "$rightpt" = "" ] && errMsg "--- RIGHTPT=$rightpt IS INVALID ---" + ;; + -b) # bcolor + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID BCOLOR SPECIFICATION ---" + checkMinus "$1" + bcolor="$1" + ;; + -f) # format + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID FORMAT SPECIFICATION ---" + checkMinus "$1" + format=`echo "$1" | tr "[:lower:]" "[:upper:]"` + case "$format" in + RG) ;; + GB) ;; + BR) ;; + RGB) ;; + *) errMsg "--- FORMAT=$format IS AN INVALID VALUE ---" + esac + ;; + -T) # trim + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID TRIM SPECIFICATION ---" + checkMinus "$1" + trim=`echo "$1" | tr "[:upper:]" "[:lower:]"` + case "$trim" in + yes) ;; + no) ;; + *) errMsg "--- TRIM=$trim IS AN INVALID VALUE ---" + esac + ;; + -) # STDIN and end of arguments + break + ;; + -*) # any other - argument + errMsg "--- UNKNOWN OPTION ---" + ;; + *) # end of arguments + break + ;; + esac + shift # next option + done + # + # get infiles and outfile + infile1="$1" + infile2="$2" + outfile="$3" +fi + +# test that infile1 provided +[ "$infile1" = "" ] && errMsg "--- NO INPUT FILE 1 SPECIFIED ---" + +# test that infile2 provided +[ "$infile2" = "" ] && errMsg "--- NO INPUT FILE 2 SPECIFIED ---" + +# test that outfile provided +[ "$outfile" = "" ] && errMsg "--- NO OUTPUT FILE SPECIFIED ---" + + +dir="$tmpdir/POSITION.$$" + +mkdir "$dir" || echo "--- FAILED TO CREATE TEMPORARY FILE DIRECTORY ---" +trap "rm -rf $dir; exit 0" 0 +trap "rm -rf $dir; exit 1" 1 2 3 15 + +# read input images +# test if infile exists, is readable and is not zero size +convert -quiet "$infile1" +repage $dir/tmpI1.mpc || + echo "--- FILE $infile1 DOES NOT EXIST OR IS NOT AN ORDINARY FILE, NOT READABLE OR HAS ZERO SIZE ---" + +convert -quiet "$infile2" +repage $dir/tmpI2.mpc || + echo "--- FILE $infile2 DOES NOT EXIST OR IS NOT AN ORDINARY FILE, NOT READABLE OR HAS ZERO SIZE ---" + +# get image1 dimensions +ww=`convert $dir/tmpI1.mpc -format "%w" info:` +hh=`convert $dir/tmpI1.mpc -format "%h" info:` +#echo "ww=$ww; hh=$hh;" + +# get page values for second image +if [ "$method" = "cpoints" ]; then + lx=`echo "$leftpt" | cut -d, -f1` + ly=`echo "$leftpt" | cut -d, -f2` + rx=`echo "$rightpt" | cut -d, -f1` + ry=`echo "$rightpt" | cut -d, -f2` + pagex=$((lx-rx)) + pagey=$((ly-ry)) + +else # offsets + xoff=`echo $offset | sed -n 's/^\([+-].*\)[+-].*$/\1/p'` + yoff=`echo $offset | sed -n 's/^[+-].*\([+-].*\)$/\1/p'` + if [ "$direction" = "horizontal" ]; then + pagex=$((ww+xoff)) + pagey=$((yoff)) + else + # vertical + pagex=$((xoff)) + pagey=$((hh+yoff)) + fi +fi +#echo "ww=$ww; hh=$hh; xoff=$xoff; yoff=$yoff; lx=$lx; ly=$ly; rx=$rx; ry=$ry; pagex=$pagex; pagey=$pagey;" + +# set up for trim +[ "$trim" = "yes" ] && trimming="-background $bcolor -define trim:percent-background=0% -trim +repage" + +# align the two images +if [ "$format" = "RG" ]; then + convert \ + \( $dir/tmpI1.mpc -colorspace gray -set page +0+0 -write mpr:img1 +delete \) \ + \( $dir/tmpI2.mpc -colorspace gray -set page +${pagex}+${pagey} -write mpr:img2 +delete \) \ + \( \( mpr:img2 -background black -colorize 100 \) \( mpr:img1 +level-colors "black,red" \) \ + -background "$bcolor" -layers merge +repage \) \ + \( \( mpr:img1 -background black -colorize 100 \) \( mpr:img2 +level-colors "black,green1" \) \ + -background "$bcolor" -layers merge +repage \) \ + -compose over -compose blend -composite $trimming \ + "$outfile" + +elif [ "$format" = "GB" ]; then + convert \ + \( $dir/tmpI1.mpc -colorspace gray -set page +0+0 -write mpr:img1 +delete \) \ + \( $dir/tmpI2.mpc -colorspace gray -set page +${pagex}+${pagey} -write mpr:img2 +delete \) \ + \( \( mpr:img2 -background black -colorize 100 \) \( mpr:img1 +level-colors "black,green1" \) \ + -background "$bcolor" -layers merge +repage \) \ + \( \( mpr:img1 -background black -colorize 100 \) \( mpr:img2 +level-colors "black,blue" \) \ + -background "$bcolor" -layers merge +repage \) \ + -compose over -compose blend -composite $trimming \ + "$outfile" + +elif [ "$format" = "BR" ]; then + convert \ + \( $dir/tmpI1.mpc -colorspace gray -set page +0+0 -write mpr:img1 +delete \) \ + \( $dir/tmpI2.mpc -colorspace gray -set page +${pagex}+${pagey} -write mpr:img2 +delete \) \ + \( \( mpr:img2 -background black -colorize 100 \) \( mpr:img1 +level-colors "black,blue" \) \ + -background "$bcolor" -layers merge +repage \) \ + \( \( mpr:img1 -background black -colorize 100 \) \( mpr:img2 +level-colors "black,red" \) \ + -background "$bcolor" -layers merge +repage \) \ + -compose over -compose blend -composite $trimming \ + "$outfile" + +else # RGB + convert \ + \( $dir/tmpI1.mpc -set page +0+0 \) \ + \( $dir/tmpI2.mpc -set page +${pagex}+${pagey} \) \ + -background "$bcolor" -layers merge +repage $trimming \ + "$outfile" + + +fi + +exit 0 diff --git a/scripts/magick/splitcrop b/scripts/magick/splitcrop new file mode 100644 index 0000000..af5e25a --- /dev/null +++ b/scripts/magick/splitcrop @@ -0,0 +1,268 @@ +#!/bin/bash +# +# Developed by Fred Weinhaus revised 6/9/2012 .......... revised 4/25/2015 +# +# ------------------------------------------------------------------------------ +# +# Licensing: +# +# Copyright © Fred Weinhaus +# +# My scripts are available free of charge for non-commercial use, ONLY. +# +# For use of my scripts in commercial (for-profit) environments or +# non-free applications, please contact me (Fred Weinhaus) for +# licensing arrangements. My email address is fmw at alink dot net. +# +# If you: 1) redistribute, 2) incorporate any of these scripts into other +# free applications or 3) reprogram them in another scripting language, +# then you must contact me for permission, especially if the result might +# be used in a commercial or for-profit environment. +# +# My scripts are also subject, in a subordinate manner, to the ImageMagick +# license, which can be found at: http://www.imagemagick.org/script/license.php +# +# ------------------------------------------------------------------------------ +# +#### +# +# USAGE: splitcrop [-x xcoord] [-y ycoord] [-L] infile [outfile] +# USAGE: splitcrop [-h or -help] +# +# OPTIONS: +# +# -x xcoord x coordinate for split; 0&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^###/g; /^#/!q; s/^#//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } +usage2() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^######/g; /^#/!q; s/^#*//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } + + +# function to report error messages +errMsg() + { + echo "" + echo $1 + echo "" + usage1 + exit 1 + } + + +# function to test for minus at start of value of second part of option 1 or 2 +checkMinus() + { + test=`echo "$1" | grep -c '^-.*$'` # returns 1 if match; 0 otherwise + [ $test -eq 1 ] && errMsg "$errorMsg" + } + + +# test for correct number of arguments and get values +if [ $# -eq 0 ] + then + # help information + echo "" + usage2 + exit 0 +elif [ $# -gt 6 ] + then + errMsg "--- TOO MANY ARGUMENTS WERE PROVIDED ---" +else + while [ $# -gt 0 ] + do + # get parameter values + case "$1" in + -h|-help) # help information + echo "" + usage2 + exit 0 + ;; + -x) # get xcoord + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID XCOORD SPECIFICATION ---" + checkMinus "$1" + xcoord=`expr "$1" : '\([0-9]*\)'` + [ "$xcoord" = "" ] && errMsg "--- XCOORD=$xcoord MUST BE A NON-NEGATIVE INTEGER ---" + ;; + -y) # get ycoord + shift # to get the neyt parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID YCOORD SPECIFICATION ---" + checkMinus "$1" + ycoord=`expr "$1" : '\([0-9]*\)'` + [ "$ycoord" = "" ] && errMsg "--- YCOORD=$ycoord MUST BE A NON-NEGATIVE INTEGER ---" + ;; + -L) # get list + list="on" + ;; + -) # STDIN and end of arguments + break + ;; + -*) # any other - argument + errMsg "--- UNKNOWN OPTION ---" + ;; + *) # end of arguments + break + ;; + esac + shift # next option + done + # + # get infile and outfile + infile="$1" + outfile="$2" +fi + +# test that infile provided +[ "$infile" = "" ] && errMsg "NO INPUT FILE SPECIFIED" + +# test that outfile provided + +if [ "$outfile" = "" ]; then + # separate infile to outname and suffix + outname=`echo "$infile" | sed -n 's/^\(.*\)[\.].*$/\1/p'` + suffix=`echo "$infile" | sed -n 's/^.*[\.]\(.*\)$/\1/p'` +else + # separate outfile to outname and suffix + outname=`echo "$outfile" | sed -n 's/^\(.*\)[\.].*$/\1/p'` + suffix=`echo "$outfile" | sed -n 's/^.*[\.]\(.*\)$/\1/p'` +fi + +# setup temporary images +tmpA1="$dir/split_A_$$.mpc" +tmpA2="$dir/split_A_$$.cache" +trap "rm -f $tmpA1 $tmpA2;" 0 +trap "rm -f $tmpA1 $tmpA2; exit 1" 1 2 3 15 +trap "rm -f $tmpA1 $tmpA2; exit 1" ERR + + +# read the input image into the temporary cached image and test if valid +convert -quiet "$infile" +repage "$tmpA1" || + errMsg "--- FILE $infile DOES NOT EXIST OR IS NOT AN ORDINARY FILE, NOT READABLE OR HAS ZERO size ---" + + +# get input dimensions +ww=`convert $tmpA1 -format "%w" info:` +hh=`convert $tmpA1 -format "%h" info:` +#echo "ww=$ww; hh=$hh" + +x=$xcoord +y=$ycoord + +# trap for no coordinates specified +if [ "$x" = "" -a "$y" = "" ]; then + x=`convert xc: -format "%[fx:round($ww/2)-1]" info:` + y=`convert xc: -format "%[fx:round($hh/2)-1]" info:` +fi + +# trap for coordinates at boundary or outside image +if [ "$x" != "" ]; then + ( [ $x -eq 0 ] || [ $x -ge $ww ] ) && errMsg "--- INVALID X COORDINATE SPECIFIED ---" +fi +if [ "$y" != "" ]; then + ( [ $y -eq 0 ] || [ $y -ge $ww ] ) && errMsg "--- INVALID Y COORDINATE SPECIFIED ---" +fi + +# options depending upon whether x or y or (x and y) specified +if [ "$x" != "" -a "$y" != "" ]; then + #split into four parts + tlsize=`convert xc: -format "%[fx:$x+1]x%[fx:$y+1]+0+0" info:` + trsize=`convert xc: -format "%[fx:$ww-$x-1]x%[fx:$y+1]+%[fx:$x+1]+0" info:` + blsize=`convert xc: -format "%[fx:$x+1]x%[fx:$hh-$y-1]+0+%[fx:$y+1]" info:` + brsize=`convert xc: -format "%[fx:$ww-$x-1]x%[fx:$hh-$y-1]+%[fx:$x+1]+%[fx:$y+1]" info:` + if [ "$list" = "on" ]; then + echo "tlsize=$tlsize" + echo "trsize=$trsize" + echo "blsize=$blsize" + echo "brsize=$brsize" + fi + convert $tmpA1 -crop $tlsize +repage "${outname}_topleft.$suffix" + convert $tmpA1 -crop $trsize +repage "${outname}_topright.$suffix" + convert $tmpA1 -crop $blsize +repage "${outname}_bottomleft.$suffix" + convert $tmpA1 -crop $brsize +repage "${outname}_bottomright.$suffix" +elif [ "$x" != "" ]; then + #split into two parts horizontally + lsize=`convert xc: -format "%[fx:$x+1]x${hh}+0+0" info:` + rsize=`convert xc: -format "%[fx:$ww-$x-1]x${hh}+%[fx:$x+1]+0" info:` + if [ "$list" = "on" ]; then + echo "lsize=$lsize" + echo "rsize=$rsize" + fi + convert $tmpA1 -crop $lsize +repage "${outname}_left.$suffix" + convert $tmpA1 -crop $rsize +repage "${outname}_right.$suffix" +elif [ "$y" != "" ]; then + #split into two parts vertically + tsize=`convert xc: -format "${ww}x%[fx:$y+1]+0+0" info:` + bsize=`convert xc: -format "${ww}x%[fx:$hh-$y-1]+0+%[fx:$y+1]" info:` + if [ "$list" = "on" ]; then + echo "tsize=$tsize" + echo "bsize=$bsize" + fi + convert $tmpA1 -crop $tsize +repage "${outname}_top.$suffix" + convert $tmpA1 -crop $bsize +repage "${outname}_bottom.$suffix" +fi + +exit 0 diff --git a/scripts/magick/stainedglass b/scripts/magick/stainedglass new file mode 100644 index 0000000..a6fd74f --- /dev/null +++ b/scripts/magick/stainedglass @@ -0,0 +1,514 @@ +#!/bin/bash +# +# Developed by Fred Weinhaus 6/12/2010 .......... 11/12/2017 +# +# ------------------------------------------------------------------------------ +# +# Licensing: +# +# Copyright © Fred Weinhaus +# +# My scripts are available free of charge for non-commercial use, ONLY. +# +# For use of my scripts in commercial (for-profit) environments or +# non-free applications, please contact me (Fred Weinhaus) for +# licensing arrangements. My email address is fmw at alink dot net. +# +# If you: 1) redistribute, 2) incorporate any of these scripts into other +# free applications or 3) reprogram them in another scripting language, +# then you must contact me for permission, especially if the result might +# be used in a commercial or for-profit environment. +# +# My scripts are also subject, in a subordinate manner, to the ImageMagick +# license, which can be found at: http://www.imagemagick.org/script/license.php +# +# ------------------------------------------------------------------------------ +# +#### +# +# USAGE: stainedglass [-k kind] [-s size] [-o offset] [-n ncolors] [-b bright] +# [-e ecolor] [-t thick] [-r rseed] [-a] infile outfile +# USAGE: stainedglass [-h or -help] +# +# OPTIONS: +# +# -k kind kind of stainedglass cell shape; choices are: square +# (or s), hexagon (or h), random (or r); default=random +# -s size size of cell; integer>0; default=16 +# -o offset random offset amount; integer>=0; default=6; +# only applies to kind=random +# -n ncolors number of desired reduced colors for the output; +# integer>1; default is no color reduction +# -b bright brightness value in percent for output image; +# integer>=0; default=100 +# -e ecolor color for edge or border around each cell; any valid +# IM color; default=black +# -t thick thickness for edge or border around each cell; +# integer>=0; default=1; zero means no edge or border +# -r rseed random number seed value; integer>=0; if seed provided, +# then image will reproduce; default is no seed, so that +# each image will be randomly different; only applies +# to kind=random +# -a use average color of cell rather than color at center +# of cell; default is center color +# +### +# +# NAME: STAINEDGLASS +# +# PURPOSE: Applies a stained glass cell effect to an image. +# +# DESCRIPTION: STAINEDGLASS applies a stained glass cell effect to an image. The +# choices of cell shapes are hexagon, square and randomized square. The cell +# size and border around the cell can be specified. +# +# +# OPTIONS: +# +# -k kind ... KIND of stainedglass cell shape; choices are: square (or s), +# hexagon (or h), random (or r). The latter is a square with each corner +# randomly offset. The default=random. +# +# -s size ... SIZE of stained glass cells. Values are integers>=0. The +# default=16. +# +# -o offset ... OFFSET is the random offset amount for the case of kind=random. +# Values are integers>=0. The default=6. +# +# -n ncolors ... NCOLORS is the number of desired reduced colors in the output. +# Values are integers>1. The default is no color reduction. Larger number of +# colors takes more time to color reduce. +# +# -b bright ... BRIGHTNESS value in percent for the output image. Values are +# integers>=0. The default=100 means no change in brightness. +# +# -e ecolor ... ECOLOR is the color for the edge or border around each cell. +# Any valid IM color is allowed. The default=black. +# +# -t thick ... THICK is the thickness for the edge or border around each cell. +# Values are integers>=0. The default=1. A value of zero means no edge or +# border will be included. +# +# -r rseed ... RSEED is the random number seed value for kind=random. Values +# are integers>=0. If a seed is provided, then the resulting image will be +# reproducable. The default is no seed. In that case, each resulting image +# will be randomly different. +# +# -a ... use AVERAGE color of cell rather than color at center of shell; +# default is center color. The average value will be accurate only for odd +# square shapes with IM 6.5.9.0 or higher. All others cases will use only an +# approximate average. +# +# Thanks to Anthony Thyssen for critiqing the original version and for +# several useful suggestions for improvement. +# +# NOTE: This script will be slow prior to IM 6.8.3.10 due to the need to +# extract color values for each cell center point across the input image. +# A progress meter is therefore provided to the terminal. A speed-up is +# available via a -process function, getColors. To obtain getColors, +# contact me. It requires IM 6.6.2-10 or higher. +# +# IMPORTANT: This script will fail due to an unintended restriction in the +# txt: format starting with IM 6.9.9.1 and IM 7.0.6.2. It has been fixed at +# IM 6.9.9.23 and IM 7.0.7.11. +# +# REQUIREMENTS: Does not produce a proper set of edges/borders around each +# cell under Q8, due to insufficient graylevel resolution (0 and 255) +# to handle more that 255 cells. +# +# CAVEAT: No guarantee that this script will work on all platforms, +# nor that trapping of inconsistent parameters is complete and +# foolproof. Use At Your Own Risk. +# +###### +# + +# set default values +kind="random" # random, square, hexagon +size=16 # cell size +offset=6 # pixel amount to randomly add or subtract to square corners +ncolors="" # number of output colors +bright=100 # brightness adjust +ecolor="black" # edge color +thick=1 # edge thickness +rseed="" # seed for random +average="no" # preprocess for cell average + +# set directory for temporary files +dir="." # suggestions are dir="." or dir="/tmp" + +# set up functions to report Usage and Usage with Description +PROGNAME=`type $0 | awk '{print $3}'` # search for executable on path +PROGDIR=`dirname $PROGNAME` # extract directory of program +PROGNAME=`basename $PROGNAME` # base name of program +usage1() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^###/g; /^#/!q; s/^#//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } +usage2() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^######/g; /^#/!q; s/^#*//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } + + +# function to report error messages +errMsg() + { + echo "" + echo $1 + echo "" + usage1 + exit 1 + } + + +# function to test for minus at start of value of second part of option 1 or 2 +checkMinus() + { + test=`echo "$1" | grep -c '^-.*$'` # returns 1 if match; 0 otherwise + [ $test -eq 1 ] && errMsg "$errorMsg" + } + +# test for correct number of arguments and get values +if [ $# -eq 0 ] + then + # help information + echo "" + usage2 + exit 0 +elif [ $# -gt 19 ] + then + errMsg "--- TOO MANY ARGUMENTS WERE PROVIDED ---" +else + while [ $# -gt 0 ] + do + # get parameter values + case "$1" in + -help) # help information + echo "" + usage2 + exit 0 + ;; + -k) # get kind + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID KIND SPECIFICATION ---" + checkMinus "$1" + kind=`echo "$1" | tr '[A-Z]' '[a-z]'` + case "$kind" in + hexagon|h) kind="hexagon" ;; + square|s) kind="square" ;; + random|r) kind="random" ;; + *) errMsg "--- KIND=$kind IS AN INVALID VALUE ---" ;; + esac + ;; + -s) # get size + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID SIZE SPECIFICATION ---" + checkMinus "$1" + size=`expr "$1" : '\([0-9]*\)'` + [ "$size" = "" ] && errMsg "--- SIZE=$size MUST BE A NON-NEGATIVE INTEGER (with no sign) ---" + test1=`echo "$size < 1" | bc` + [ $test1 -eq 1 ] && errMsg "--- SIZE=$size MUST BE A POSITIVE INTEGER ---" + ;; + -o) # get offset + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID OFFSET SPECIFICATION ---" + checkMinus "$1" + offset=`expr "$1" : '\([0-9]*\)'` + [ "$offset" = "" ] && errMsg "--- OFFSET=$offset MUST BE A NON-NEGATIVE INTEGER ---" + ;; + -n) # get ncolors + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID NCOLORS SPECIFICATION ---" + checkMinus "$1" + ncolors=`expr "$1" : '\([0-9]*\)'` + [ "$ncolors" = "" ] && errMsg "--- NCOLORS=$ncolors MUST BE A NON-NEGATIVE INTEGER (with no sign) ---" + test1=`echo "$ncolors < 2" | bc` + [ $test1 -eq 1 ] && errMsg "--- NCOLORS=$ncolors MUST BE AN GREATER THAN 1 ---" + ;; + -b) # get bright + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID BRIGHT SPECIFICATION ---" + checkMinus "$1" + bright=`expr "$1" : '\([0-9]*\)'` + [ "$bright" = "" ] && errMsg "--- BRIGHT=$bright MUST BE A NON-NEGATIVE INTEGER ---" + ;; + -e) # get ecolor + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID ECOLOR SPECIFICATION ---" + checkMinus "$1" + ecolor="$1" + ;; + -t) # get thick + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID THICK SPECIFICATION ---" + checkMinus "$1" + thick=`expr "$1" : '\([0-9]*\)'` + [ "$thick" = "" ] && errMsg "--- THICK=$thick MUST BE A NON-NEGATIVE INTEGER ---" + ;; + -r) # get rseed + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID RSEED SPECIFICATION ---" + checkMinus "$1" + rseed=`expr "$1" : '\([0-9]*\)'` + [ "$rseed" = "" ] && errMsg "--- RSEED=$rseed MUST BE A NON-NEGATIVE INTEGER ---" + ;; + -a) # get average + average="yes" + ;; + -) # STDIN and end of arguments + break + ;; + -*) # any other - argument + errMsg "--- UNKNOWN OPTION ---" + ;; + *) # end of arguments + break + ;; + esac + shift # next option + done + # + # get infile and outfile + infile="$1" + outfile="$2" +fi + +# test that infile provided +[ "$infile" = "" ] && errMsg "NO INPUT FILE SPECIFIED" + +# test that outfile provided +[ "$outfile" = "" ] && errMsg "NO OUTPUT FILE SPECIFIED" + + +# setup temporary images and auto delete upon exit +tmpA1="$dir/stainedglass_1_$$.mpc" +tmpB1="$dir/stainedglass_1_$$.cache" +tmpA2="$dir/stainedglass_2_$$.mpc" +tmpB2="$dir/stainedglass_2_$$.cache" +tmpA3="$dir/stainedglass_3_$$.mpc" +tmpB3="$dir/stainedglass_3_$$.cache" +tmpC="$dir/stainedglass_C_$$.txt" +tmpG="$dir/stainedglass_G_$$.txt" +trap "rm -f $tmpA1 $tmpB1 $tmpA2 $tmpB2 $tmpA3 $tmpB3 $tmpC $tmpG;" 0 +trap "rm -f $tmpA1 $tmpB1 $tmpA2 $tmpB2 $tmpA3 $tmpB3 $tmpC $tmpG; exit 1" 1 2 3 15 +# does not seem to produce output with the following???? +#trap "rm -f $tmpA1 $tmpB1 $tmpA2 $tmpB2 $tmpA3 $tmpB3 $tmpC $tmpG; exit 1" ERR + +# set up color reduction +if [ "$ncolors" != "" ]; then + reduce="-monitor +dither -colors $ncolors +monitor" + echo "" + echo "Reducing Colors:" +else + reduce="" +fi + +im_version=`convert -list configure | \ + sed '/^LIB_VERSION_NUMBER */!d; s//,/; s/,/,0/g; s/,0*\([0-9][0-9]\)/\1/g' | head -n 1` +# colorspace swapped at IM 6.7.5.5, but not properly fixed until 6.7.6.6 +# before swap verbose info reported colorspace=RGB after colorspace=sRGB +if [ "$im_version" -ge "06070606" ]; then + cspace1="sRGB" + cspace2="sRGBA" +else + cspace1="RGB" + cspace2="RGBA" +fi +# no need for setcspace for grayscale or channels after 6.8.5.4 +if [ "$im_version" -gt "06080504" ]; then + cspace1="" + cspace2="" +fi + +# get colorspace +colorspace=`convert $infile -ping -format "%[colorspace]" info:` +# set up for modulate +# note there seems to be a change in -modulate (for HSL) between IM 6.8.4.6 and 6.8.4.7 that is noticeable in the output +if [ "$bright" != "100" -a "$colorspace" = "CMYK" ]; then + modulation="-colorspace $cspace1 -modulate ${bright},100,100 -colorspace cmyk" +elif [ "$bright" != "100" -a "$colorspace" = "CMYKA" ]; then + modulation="-colorspace $cspace2 -modulate ${bright},100,100 -colorspace cmyka" +elif [ "$bright" != "100" ]; then + modulation="-modulate ${bright},100,100" +else + modulation="" +fi + +# read the input image and filter image into the temp files and test validity. +convert -quiet "$infile" $reduce $modulation +repage "$tmpA1" || + errMsg "--- FILE $infile DOES NOT EXIST OR IS NOT AN ORDINARY FILE, NOT READABLE OR HAS ZERO SIZE ---" + +# preprocess for average color +if [ "$average" = "yes" -a "$im_version" -ge "06050900" -a "$kind" = "square" ]; then + dim=`convert xc: -format "%[fx:round(($size-1)/2)]" info:` + convert $tmpA1 -define convolve:scale=! -morphology convolve square:$dim $tmpA1 +elif [ "$average" = "yes" -a "$im_version" -ge "06050900" -a "$kind" != "square" ]; then + dim=`convert xc: -format "%[fx:round(($size-1)/2)+0.5]" info:` + convert $tmpA1 -define convolve:scale=! -morphology convolve disk:$dim $tmpA1 +elif [ "$average" = "yes" ]; then + dim=`convert xc: -format "%[fx:round(($size-1)/2)]" info:` + convert $tmpA1 -blur ${dim}x65000 $tmpA1 +fi + +# test if -process module getcolors exists +if [ "$im_version" -ge "06050210" ]; then + process_test=`convert -list module | grep "getColors"` +fi +#echo "process_test=$process_test;" + +ww=`convert $tmpA1 -ping -format "%w" info:` +hh=`convert $tmpA1 -ping -format "%h" info:` +ww1=$(($ww-1)) +hh1=$(($hh-1)) +ww2=`convert xc: -format "%[fx:$ww1+round($size/2)]" info:` +hh2=`convert xc: -format "%[fx:$hh1+round($size/2)]" info:` +#echo "ww=$ww; hh=$hh; ww1=$ww1; hh1=$hh1; ww2=$ww2; hh2=$hh2;" + + +# get qrange +qrange=`convert xc: -format "%[fx:quantumrange]" info:` + +# init colors file +echo "# ImageMagick pixel enumeration: $ww,$hh,255,rgb" > $tmpC + +# init increment grays file +touch $tmpG + +if [ "$kind" = "random" ]; then + # need to add 1 to offset as awk rand is exclusive between 0 and 1 + offset=$(($offset+1)) + + awk -v size="$size" -v ww1="$ww1" -v hh1="$hh1" -v ww2="$ww2" -v hh2="$hh2" -v offset="$offset" -v rseed="$rseed" ' + BEGIN { if (rseed=="") {srand();} else {srand(rseed);} y=0; while ( y < hh2 ) + { x=0; while (x < ww2 ) + { if (x>ww1) {x=ww1;} if (y>hh1) {y=hh1;} + rx=rand(); if (rx<0.5) {signx=-1;} else {signx=1;} + offx=int(signx*rand()*offset); + xx=x+offx; if (xx<0) {xx=0}; if (xx>ww1) {xx=ww1}; + ry=rand(); if (ry<0.5) {signy=-1;} else {signy=1;} + offy=int(signy*rand()*offset); + yy=y+offy; if (yy<0) {yy=0}; if (yy>hh1) {yy=hh1}; + print xx","yy": (255,255,255)"; x=x+size; } + y=y+size; } }' >> $tmpC + + awk -v size="$size" -v ww1="$ww1" -v hh1="$hh1" -v ww2="$ww2" -v hh2="$hh2" -v qrange="$qrange" -v offset="$offset" -v rseed="$rseed" ' + BEGIN { if (rseed=="") {srand();} else {srand(rseed);} k=0; y=0; while ( y < hh2 ) + { x=0; while (x < ww2 ) + { if (x>ww1) {x=ww1;} if (y>hh1) {y=hh1;} + rx=rand(); if (rx<0.5) {signx=-1;} else {signx=1;} + offx=int(signx*rand()*offset); + xx=x+offx; if (xx<0) {xx=0}; if (xx>ww1) {xx=ww1}; + ry=rand(); if (ry<0.5) {signy=-1;} else {signy=1;} + offy=int(signy*rand()*offset); + yy=y+offy; if (yy<0) {yy=0}; if (yy>hh1) {yy=hh1}; + g=(k % 256); + print xx,yy" gray("g")"; k++; x=x+size; } + y=y+size; } }' >> $tmpG + +elif [ "$kind" = "hexagon" ]; then + awk -v size="$size" -v ww1="$ww1" -v hh1="$hh1" -v ww2="$ww2" -v hh2="$hh2" ' + BEGIN { j=0; y=0; while ( y < hh2 ) + { if (j%2==0) {x=int((size+0.5)/2);} else {x=0;} while (x < ww2 ) + { if (x>ww1) {x=ww1;} if (y>hh1) {y=hh1;} print x","y": (255,255,255)"; x=x+size; } + j++; y=y+size; } }' >> $tmpC + + awk -v size="$size" -v ww1="$ww1" -v hh1="$hh1" -v ww2="$ww2" -v hh2="$hh2" -v qrange="$qrange" ' + BEGIN { j=0; k=0; y=0; while ( y < hh2 ) + { if (j%2==0) {x=int((size+0.5)/2);} else {x=0;} while (x < ww2 ) + { if (x>ww1) {x=ww1;} if (y>hh1) {y=hh1;} g=(k % 256); print x,y" gray("g")"; k++; x=x+size; } + j++; y=y+size; } }' >> $tmpG + +elif [ "$kind" = "square" ]; then + awk -v size="$size" -v ww1="$ww1" -v hh1="$hh1" -v ww2="$ww2" -v hh2="$hh2" ' + BEGIN { y=0; while ( y < hh2 ) + { x=0; while (x < ww2 ) + { if (x>ww1) {x=ww1;} if (y>hh1) {y=hh1;} print x","y": (255,255,255)"; x=x+size; } + y=y+size; } }' >> $tmpC + + awk -v size="$size" -v ww1="$ww1" -v hh1="$hh1" -v ww2="$ww2" -v hh2="$hh2" -v qrange="$qrange" ' + BEGIN { k=0; y=0; while ( y < hh2 ) + { x=0; while (x < ww2 ) + { if (x>ww1) {x=ww1;} if (y>hh1) {y=hh1;} g=(k % 256); print x,y" gray("g")"; k++; x=x+size; } + y=y+size; } }' >> $tmpG + +fi + +if [ "$thick" = "0" ]; then + if [ "$im_version" -ge "06080310" ]; then + convert $tmpA1 \( -background black $tmpC \) \ + -alpha off -compose copy_opacity -composite \ + sparse-color:- |\ + convert -size ${ww}x${hh} xc: -sparse-color voronoi '@-' \ + "$outfile" + elif [ "$im_version" -lt "06060210" -o "$process_test" != "getColors" ]; then + echo "" + echo "Progress:" + echo "" + convert $tmpA1 \( -background black $tmpC \) \ + -alpha off -compose copy_opacity -composite -monitor \ + txt:- |\ + sed '1d; / 0) /d; s/:.* /,/;' |\ + convert -size ${ww}x${hh} xc: -sparse-color voronoi '@-' \ + +monitor "$outfile" + echo "" + else + convert $tmpA1 \( -background black $tmpC \) \ + -alpha off -compose copy_opacity -composite $tmpA1 + convert $tmpA1 -alpha on -process "getColors" null: > $tmpC + convert -size ${ww}x${hh} xc: -sparse-color voronoi "@$tmpC" \ + "$outfile" + fi + +else + if [ "$im_version" -ge "06080310" ]; then + convert $tmpA1 \( -background black $tmpC \) \ + -alpha off -compose copy_opacity -composite \ + sparse-color:- |\ + convert -size ${ww}x${hh} xc: -sparse-color voronoi '@-' $tmpA2 + convert -size ${ww}x${hh} xc: -sparse-color voronoi "@$tmpG" \ + -auto-level -morphology edge diamond:$thick -threshold 0 -negate $tmpA3 + convert $tmpA2 $tmpA3 -alpha off -compose copy_opacity -composite \ + -compose over -background $ecolor -flatten \ + "$outfile" + elif [ "$im_version" -lt "06060210" -o "$process_test" != "getColors" ]; then + echo "" + echo "Progress:" + echo "" + convert $tmpA1 \( -background black $tmpC \) \ + -alpha off -compose copy_opacity -composite -monitor \ + txt:- |\ + sed '1d; / 0) /d; s/:.* /,/;' |\ + convert -size ${ww}x${hh} xc: -sparse-color voronoi '@-' +monitor $tmpA2 + convert -size ${ww}x${hh} xc: -sparse-color voronoi "@$tmpG" \ + -auto-level -morphology edge diamond:$thick -threshold 0 -negate $tmpA3 + convert $tmpA2 $tmpA3 -alpha off -compose copy_opacity -composite \ + -compose over -background $ecolor -flatten \ + "$outfile" + echo "" + else + convert $tmpA1 \( -background black $tmpC \) \ + -alpha off -compose copy_opacity -composite $tmpA1 + convert $tmpA1 -alpha on -process "getColors" null: > $tmpC + convert -size ${ww}x${hh} xc: -sparse-color voronoi "@$tmpC" $tmpA2 + convert -size ${ww}x${hh} xc: -sparse-color voronoi "@$tmpG" \ + -auto-level -morphology edge diamond:$thick -threshold 0 -negate $tmpA3 + convert $tmpA2 $tmpA3 -alpha off -compose copy_opacity -composite \ + -compose over -background $ecolor -flatten \ + "$outfile" + fi +fi + +exit 0 \ No newline at end of file diff --git a/scripts/magick/texture_setup.sh b/scripts/magick/texture_setup.sh new file mode 100644 index 0000000..88c3c4d --- /dev/null +++ b/scripts/magick/texture_setup.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -ex + +./splitcrop -x $1 -y $1 $2 corners.png + +./position -d horizontal corners_bottomright.png corners_bottomleft.png bottom.png + +./position -d horizontal corners_topright.png corners_topleft.png top.png + +./position -d vertical bottom.png top.png result-grid.png diff --git a/scripts/magick/tileimage b/scripts/magick/tileimage new file mode 100644 index 0000000..703a1a9 --- /dev/null +++ b/scripts/magick/tileimage @@ -0,0 +1,347 @@ +#!/bin/bash +# +# Developed by Fred Weinhaus 8/10/2015 .......... revised 8/10/2015 +# +# ------------------------------------------------------------------------------ +# +# Licensing: +# +# Copyright © Fred Weinhaus +# +# My scripts are available free of charge for non-commercial use, ONLY. +# +# For use of my scripts in commercial (for-profit) environments or +# non-free applications, please contact me (Fred Weinhaus) for +# licensing arrangements. My email address is fmw at alink dot net. +# +# If you: 1) redistribute, 2) incorporate any of these scripts into other +# free applications or 3) reprogram them in another scripting language, +# then you must contact me for permission, especially if the result might +# be used in a commercial or for-profit environment. +# +# My scripts are also subject, in a subordinate manner, to the ImageMagick +# license, which can be found at: http://www.imagemagick.org/script/license.php +# +# ------------------------------------------------------------------------------ +# +#### +# +# USAGE: tileimage [-a arrangement] [-o orientation ] [-r repeats] [-w width ] +# [-h height] [-b bgcolor] infile outfile +# +# USAGE: tileimage [-help] +# +# OPTIONS: +# +# -a arrangement tiling arrangement; choices are: repeat, halfdrop, +# brick, verticalmirror, horizontalmirror or rotate; +# default=repeat +# -o orientation orientation mode for image repeats; choices are: 0, +# 90, 180, 270, flip, flop, transpose, transverse; +# default=0 +# -r repeats number of horizontal repeats; integer>0; default=4 +# -w width width of canvas in pixels; integer>0; default=512 +# -h height height of canvas in pixels; integer>0; default=512 +# -b bgcolor background color for canvas, if image is transparent; +# any valid IM color; default="white" +# +### +# +# NAME: TILEIMAGE +# +# PURPOSE: To tile an image to a given size with various tile arrangements. +# +# DESCRIPTION: TILEIMAGE tiles an image to a given size. Several arrangements +# are possible as well as several orientations. The user can also specify the +# number of repeats across the width of the image. This script is typically +# used to tile objects on a constant color background. +# +# OPTIONS: +# +# -a arrangement ... tiling ARRANGEMENT. The choices are: repeat (r), +# halfdrop (h), brick (b), verticalmirror (vm), horizontalmirror (hm), or . +# rotate (ro). The default=repeat. +# +# -o orientation ... ORIENTATION mode for the image repeats. The choices are: +# 0 (degree rotation), 90 (degree rotation), 180 (degree rotation), +# 270 (degree rotation), flip, flop, transpose, transverse. The default=0. +# +# -r repeats ... REPEATS is the number of horizontal repeats across the image. +# Values are integers>0. The default=4. +# +# -w width ... WIDTH of output image in pixels. Values are integers>0. The +# default=512. +# +# -h height ... HEIGHT of output image in pixels. Values are integers>0. The +# default=512. +# +# -b bgcolor ... BGCOLOR is the desired background color for the output image, +# if the input image is transparent. Any valid IM color. The default="white". +# +# CAVEAT: No guarantee that this script will work on all platforms, +# nor that trapping of inconsistent parameters is complete and +# foolproof. Use At Your Own Risk. +# +###### +# + +# set default values +arrangement="repeat" +orientation="0" +repeats=4 +width=512 +height=512 +bgcolor="white" + +# set directory for temporary files +dir="." # suggestions are dir="." or dir="/tmp" + +# set up functions to report Usage and Usage with Description +PROGNAME=`type $0 | awk '{print $3}'` # search for executable on path +PROGDIR=`dirname $PROGNAME` # extract directory of program +PROGNAME=`basename $PROGNAME` # base name of program +usage1() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -n '/^###/q; /^#/!q; s/^#//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } + + +# function to report error messages +errMsg() + { + echo "" + echo $1 + echo "" + usage1 + exit 1 + } + + +# function to test for minus at start of value of second part of option 1 or 2 +checkMinus() + { + test=`echo "$1" | grep -c '^-.*$'` # returns 1 if match; 0 otherwise + [ $test -eq 1 ] && errMsg "$errorMsg" + } + +# test for correct number of arguments and get values +if [ $# -eq 0 ] + then + # help information + echo "" + usage1 + exit 0 +elif [ $# -gt 14 ] + then + errMsg "--- TOO MANY ARGUMENTS WERE PROVIDED ---" +else + while [ $# -gt 0 ] + do + # get parameter values + case "$1" in + -help) # help information + echo "" + usage1 + exit 0 + ;; + -a) # arrangement + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID ARRANGEMENT SPECIFICATION ---" + checkMinus "$1" + # test gravity values + arrangement="$1" + arrangement=`echo "$1" | tr '[A-Z]' '[a-z]'` + case "$arrangement" in + repeat|r) arrangement="repeat" ;; + halfdrop|h) arrangement="halfdrop" ;; + brick|b) arrangement="brick" ;; + verticalmirror|vm) arrangement="verticalmirror" ;; + horizontalmirror|hm) arrangement="horizontalmirror" ;; + rotate|ro) arrangement="rotate" ;; + *) errMsg "--- ARRANGEMENT=$arrangement IS AN INVALID OPTION ---" ;; + esac + ;; + -o) # orientation + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID ORIENTATION SPECIFICATION ---" + checkMinus "$1" + # test gravity values + orientation="$1" + orientation=`echo "$1" | tr '[A-Z]' '[a-z]'` + case "$orientation" in + 0) orientation="0" ;; + 90) orientation="90" ;; + 180) orientation="180" ;; + 270) orientation="270" ;; + flip) orientation="flip" ;; + flop) orientation="flop" ;; + transpose) orientation="transpose" ;; + transverse) orientation="transverse" ;; + *) errMsg "--- ORIENTATION=$orientation IS AN INVALID OPTION ---" ;; + esac + ;; + -w) # get width + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID WIDTH SPECIFICATION ---" + checkMinus "$1" + width=`expr "$1" : '\([.0-9]*\)'` + [ "$width" = "" ] && errMsg "--- WIDTH=$width MUST BE A NON-NEGATIVE FLOAT ---" + test=`echo "$width <= 0" | bc` + [ $test -eq 1 ] && errMsg "--- WIDTH=$width MUST BE A POSITIVE FLOAT ---" + ;; + -h) # get height + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID HEIGHT SPECIFICATION ---" + checkMinus "$1" + height=`expr "$1" : '\([.0-9]*\)'` + [ "$height" = "" ] && errMsg "--- HEIGHT=$height MUST BE A NON-NEGATIVE FLOAT ---" + test=`echo "$height <= 0" | bc` + [ $test -eq 1 ] && errMsg "--- HEIGHT=$height MUST BE A POSITIVE FLOAT ---" + ;; + -r) # get repeats + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID REPEATS SPECIFICATION ---" + checkMinus "$1" + repeats=`expr "$1" : '\([0-9]*\)'` + [ "$repeats" = "" ] && errMsg "--- REPEATS=$repeats MUST BE A NON-NEGATIVE INTEGER ---" + test=`echo "$repeats == 0" | bc` + [ $test -eq 1 ] && errMsg "--- REPEATS=$repeats MUST BE A POSITIVE INTEGER ---" + ;; + -b) # get bgcolor + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID BGCOLOR SPECIFICATION ---" + checkMinus "$1" + bgcolor="$1" + ;; + -) # STDIN and end of arguments + break + ;; + -*) # any other - argument + errMsg "--- UNKNOWN OPTION ---" + ;; + *) # end of arguments + break + ;; + esac + shift # next option + done + # + # get infile and outfile + infile="$1" + outfile="$2" +fi + +# test that infile provided +[ "$infile" = "" ] && errMsg "NO INPUT FILE SPECIFIED" + +# test that outfile provided +[ "$outfile" = "" ] && errMsg "NO OUTPUT FILE SPECIFIED" + + +# setup temporary images +tmpA1="$dir/tileimage_1_$$.mpc" +tmpB1="$dir/tileimage_1_$$.cache" +trap "rm -f $tmpA1 $tmpB1;" 0 +trap "rm -f $tmpA1 $tmpB1; exit 1" 1 2 3 15 +trap "rm -f $tmpA1 $tmpB1; exit 1" ERR + + +# read the input image into the temporary cached image and test if valid +# read the input image into the temporary cached image and test if valid +convert -quiet "$infile" +repage "$tmpA1" || + errMsg "--- FILE $infile DOES NOT EXIST OR IS NOT AN ORDINARY FILE, NOT READABLE OR HAS ZERO size ---" + +# get infile dimensions +ww=`identify -ping -format "%w" $tmpA1` +hh=`identify -ping -format "%h" $tmpA1` + +# get scaling factor for infile in percent +factor=`convert xc: -format "%[fx:100*$width/($repeats*$ww)]" info:` + +# scale the infile +convert $tmpA1 -resize $factor% $tmpA1 + +# compute resized dimensions +ww=`identify -ping -format "%w" $tmpA1` +hh=`identify -ping -format "%h" $tmpA1` + +# get half sizes for offsets +ww2=`convert xc: -format "%[fx:round($ww/2)]" info:` +hh2=`convert xc: -format "%[fx:round($hh/2)]" info:` + + +# set up orientation +if [ "$orientation" = "0" ]; then + orienting="" +elif [ "$orientation" = "90" ]; then + orienting="-rotate 90" +elif [ "$orientation" = "180" ]; then + orienting="-rotate 180" +elif [ "$orientation" = "270" ]; then + orienting="-rotate 270" +elif [ "$orientation" = "flip" ]; then + orienting="-flip" +elif [ "$orientation" = "flop" ]; then + orienting="-flop" +elif [ "$orientation" = "transpose" ]; then + orienting="-transpose" +elif [ "$orientation" = "transverse" ]; then + orienting="-transverse" +fi + +# process image +if [ "$arrangement" = "repeat" ]; then +#echo "repeat; orient=$orienting; width=$width; height=$height; bgcolor=$bgcolor;" + convert $tmpA1 $orienting -write mpr:cell +delete \ + -size ${width}x${height} tile:mpr:cell \ + -background $bgcolor -flatten "$outfile" + +elif [ "$arrangement" = "halfdrop" ]; then + convert $tmpA1 \ + \( -clone 0 $orienting -roll +0+${hh2} \) +append \ + -write mpr:cell +delete \ + -size ${width}x${height} tile:mpr:cell \ + -background "$bgcolor" -flatten "$outfile" + +elif [ "$arrangement" = "brick" ]; then + convert $tmpA1 \ + \( -clone 0 $orienting -roll +${ww2}+0 \) -append \ + -write mpr:cell +delete \ + -size ${width}x${height} tile:mpr:cell \ + -background "$bgcolor" -flatten "$outfile" + +elif [ "$arrangement" = "verticalmirror" ]; then + convert $tmpA1 \ + \( -clone 0 $orienting \) +append \ + \( -clone 0 -flip \) -append \ + -write mpr:cell +delete \ + -size ${width}x${height} tile:mpr:cell \ + -background "$bgcolor" -flatten "$outfile" + +elif [ "$arrangement" = "horizontalmirror" ]; then + convert $tmpA1 \ + \( -clone 0 $orienting \) -append \ + \( -clone 0 -flop \) +append \ + -write mpr:cell +delete \ + -size ${width}x${height} tile:mpr:cell \ + -background "$bgcolor" -flatten "$outfile" + +elif [ "$arrangement" = "rotate" ]; then + convert $tmpA1 $orienting \ + \( -clone 0 -rotate 90 \) +append \ + \( -clone 0 -rotate 180 \) -append \ + -write mpr:cell +delete \ + -size ${width}x${height} tile:mpr:cell \ + -background "$bgcolor" -flatten "$outfile" + +fi + +exit 0 \ No newline at end of file diff --git a/scripts/magick/tiler b/scripts/magick/tiler new file mode 100644 index 0000000..9f4d0ad --- /dev/null +++ b/scripts/magick/tiler @@ -0,0 +1,302 @@ +#!/bin/bash +# +# Developed by Fred Weinhaus 4/8/2011 .......... revised 5/3/2015 +# +# ------------------------------------------------------------------------------ +# +# Licensing: +# +# Copyright © Fred Weinhaus +# +# My scripts are available free of charge for non-commercial use, ONLY. +# +# For use of my scripts in commercial (for-profit) environments or +# non-free applications, please contact me (Fred Weinhaus) for +# licensing arrangements. My email address is fmw at alink dot net. +# +# If you: 1) redistribute, 2) incorporate any of these scripts into other +# free applications or 3) reprogram them in another scripting language, +# then you must contact me for permission, especially if the result might +# be used in a commercial or for-profit environment. +# +# My scripts are also subject, in a subordinate manner, to the ImageMagick +# license, which can be found at: http://www.imagemagick.org/script/license.php +# +# ------------------------------------------------------------------------------ +# +#### +# +# USAGE: tiler [-m method] [-o overlap] [-v vpmethod] infile outfile +# USAGE: tiler [-h or -help] +# +# OPTIONS: +# +# -m method method of overlap blending; choices are 1 or 2; +# 1 is simple average blending; 2 is ramped blending; +# default=1 +# -o overlap seam overlap amount in pixels; integer>=0; default=2 +# -v vpmethod virtual-pixel method used to extend the quadrants; +# any valid IM non-background virtual-pixel method +# is allowed. Best choices seem to be mirror or edge; +# default=mirror +# +# NOTE: the input image (infile) must be square and have even dimensions. +# +### +# +# NAME: TILER +# +# PURPOSE: To convert an image into a tilable texture. +# +# DESCRIPTION: TILER converts an image into a tilable texture. It does this +# by swapping diagonal quadrants, exending the borders to get overlap and +# then composite blending the extended quadrants. The extension and blending +# attempt to minimize or avoid manual painting/blurring/cloning along the +# seams. +# +# OPTIONS: +# +# -m method ... METHOD of overlap blending. The choices are 1 or 2. Method 1 +# is a simple average blending. Method 2 is a ramped blending. The default=1. +# +# -o overlap ... OVERLAP is amount of extension of the quadrants in order to +# cover the center horizontal and vertical seems. Values are integers>=0. +# The default=2 +# +# -v vpmethod ... VPMETHOD is the virtual-pixel method used to extend the +# quadrants. Any valid IM non-background virtual-pixel method is allowed. +# Recommended values are either mirror or edge. The default is mirror. +# +# Requirement: IM 6.5.3-4 or higher due to the use in method 1 of +# convert ... -compose blend -define compose:args=50,50 -composite ... +# +# CAVEAT: No guarantee that this script will work on all platforms, +# nor that trapping of inconsistent parameters is complete and +# foolproof. Use At Your Own Risk. +# +###### +# + +# set default values +method=1 # blending method +overlap=2 # overlap of quadrants +vp="mirror" # virtual pixel method for extending the quadrants + +# set directory for temporary files +dir="." # suggestions are dir="." or dir="/tmp" + +# set up functions to report Usage and Usage with Description +PROGNAME=`type $0 | awk '{print $3}'` # search for executable on path +PROGDIR=`dirname $PROGNAME` # extract directory of program +PROGNAME=`basename $PROGNAME` # base name of program +usage1() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^###/g; /^#/!q; s/^#//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } +usage2() + { + echo >&2 "" + echo >&2 "$PROGNAME:" "$@" + sed >&2 -e '1,/^####/d; /^######/g; /^#/!q; s/^#*//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" + } + + +# function to report error messages +errMsg() + { + echo "" + echo $1 + echo "" + usage1 + exit 1 + } + + +# function to test for minus at start of value of second part of option 1 or 2 +checkMinus() + { + test=`echo "$1" | grep -c '^-.*$'` # returns 1 if match; 0 otherwise + [ $test -eq 1 ] && errMsg "$errorMsg" + } + +# test for correct number of arguments and get values +if [ $# -eq 0 ] + then + # help information + echo "" + usage2 + exit 0 +elif [ $# -gt 8 ] + then + errMsg "--- TOO MANY ARGUMENTS WERE PROVIDED ---" +else + while [ $# -gt 0 ] + do + # get parameter values + case "$1" in + -h|-help) # help information + echo "" + usage2 + exit 0 + ;; + -m) # get method + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID METHOD SPECIFICATION ---" + checkMinus "$1" + method=`expr "$1" : '\([0-2]*\)'` + [ "$method" = "" ] && errMsg "--- METHOD=$method MUST BE A NON-NEGATIVE INTEGER ---" + [ $method -ne 1 -a $method -ne 2 ] && errMsg "--- METHOD=$method MUST BE EITHER 1 OR 2 ---" + ;; + -o) # get overlap + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID OVERLAP SPECIFICATION ---" + checkMinus "$1" + overlap=`expr "$1" : '\([0-9]*\)'` + [ "$overlap" = "" ] && errMsg "--- OVERLAP=$overlap MUST BE A NON-NEGATIVE INTEGER ---" + ;; + -v) # get vpmethod + shift # to get the next parameter + # test if parameter starts with minus sign + errorMsg="--- INVALID VPMETHOD SPECIFICATION ---" + checkMinus "$1" + vp="$1" + vp=`echo "$vp" | tr "[:upper:]" "[:lower:]"` + case "$vp" in + black) ;; + dither) ;; + edge) ;; + gray) ;; + mirror) ;; + random) ;; + tile) ;; + transparent) ;; + white) ;; + *) errMsg "--- VPMETHOD=$vp IS AN INVALID VALUE ---" + esac + ;; + -) # STDIN and end of arguments + break + ;; + -*) # any other - argument + errMsg "--- UNKNOWN OPTION ---" + ;; + *) # end of arguments + break + ;; + esac + shift # next option + done + # + # get infile and outfile + infile="$1" + outfile="$2" +fi + +# test that infile provided +[ "$infile" = "" ] && errMsg "NO INPUT FILE SPECIFIED" + +# test that outfile provided +[ "$outfile" = "" ] && errMsg "NO OUTPUT FILE SPECIFIED" + + +# set up temp files +tmpA1="$dir/tiler_A_$$.mpc" +tmpA2="$dir/tiler_A_$$.cache" +tmpM1="$dir/tiler_M_$$.mpc" +tmpM2="$dir/tiler_M_$$.cache" +tmpN1="$dir/tiler_N_$$.mpc" +tmpN2="$dir/tiler_N_$$.cache" +trap "rm -f $tmpA1 $tmpA2 $tmpM1 $tmpM2 $tmpN1 $tmpN2;" 0 +trap "rm -f $tmpA1 $tmpA2 $tmpM1 $tmpM2 $tmpN1 $tmpN2; exit 1" 1 2 3 15 +trap "rm -f $tmpA1 $tmpA2 $tmpM1 $tmpM2 $tmpN1 $tmpN2; exit 1" ERR + + +# read the input image into the tmp cached image. +convert -quiet "$infile" +repage "$tmpA1" || + errMsg "--- FILE $infile NOT READABLE OR HAS ZERO SIZE ---" + + +# validate input image is square +wd=`convert $tmpA1 -ping -format "%w" info:` +ht=`convert $tmpA1 -ping -format "%h" info:` +test0=`convert xc: -format "%[fx:$wd==$ht?1:0]" info:` +[ $test0 -eq 0 ] && errMsg "--- FILE $infile IS NOT SQUARE ---" +test1=`convert xc: -format "%[fx:mod($wd,2)]" info:` +test2=`convert xc: -format "%[fx:mod($ht,2)]" info:` +[ $test1 -eq 1 -o $test2 -eq 1 ] && errMsg "--- FILE $infile DOES NOT HAVE EVEN DIMENSIONS ---" + + +# compute half width and other parameters +ww=`convert xc: -format "%[fx:$wd/2]" info:` +hh=`convert xc: -format "%[fx:$ht/2]" info:` +wwo=`convert xc: -format "%[fx:$ww+$overlap]" info:` +hho=`convert xc: -format "%[fx:$hh+$overlap]" info:` +rollamount="+${ww}+${hh}" +cropsize="${ww}x${hh}+0+0" +www=$(($ww+$overlap)) +hhh=$(($hh+$overlap)) +sesize="${www}x${hhh}+0+0" +swsize="${www}x${hhh}-$overlap+0" +nesize="${www}x${hhh}+0-$overlap" +nwsize="${www}x${hhh}-$overlap-$overlap" +gsize=$(($overlap + 2)) + + +# process image +if [ $method -eq 1 ]; then + # test for valid IM version + im_version=`convert -list configure | \ + sed '/^LIB_VERSION_NUMBER */!d; s//,/; s/,/,0/g; s/,0*\([0-9][0-9]\)/\1/g' | head -n 1` + [ "$im_version" -lt "06050304" ] && errMsg "--- REQUIRES IM VERSION 6.5.3-4 OR HIGHER ---" + # Do simple average blending in overlap + convert $tmpA1 \ + \( -clone 0 -gravity southeast -crop $cropsize +repage -virtual-pixel $vp \ + -define distort:viewport=$sesize -distort SRT 0 +repage \) \ + \( -clone 0 -gravity southwest -crop $cropsize +repage -virtual-pixel $vp \ + -define distort:viewport=$swsize -distort SRT 0 +repage \) \ + \( -clone 0 -gravity northeast -crop $cropsize +repage -virtual-pixel $vp \ + -define distort:viewport=$nesize -distort SRT 0 +repage \) \ + \( -clone 0 -gravity northwest -crop $cropsize +repage -virtual-pixel $vp \ + -define distort:viewport=$nwsize -distort SRT 0 +repage \) \ + \( -clone 0 -roll $rollamount \ + -clone 1 -gravity northwest -compose blend -define compose:args=50,50 -composite \ + -clone 2 -gravity northeast -compose blend -define compose:args=50,50 -composite \ + -clone 3 -gravity southwest -compose blend -define compose:args=50,50 -composite \ + -clone 4 -gravity southeast -compose blend -define compose:args=50,50 -composite \) \ + -delete 0-4 \ + "$outfile" + +elif [ $method -eq 2 ]; then + # Create horizontal mask; black on left, gradient, white on right + convert \( -size ${ww}x${hho} xc:black xc:white +append \) \ + \( -size ${hho}x$gsize gradient: -rotate 90 \) -gravity center \ + -compose over -composite +repage $tmpM1 + # create vertical mask; black on top, gradient, white on bottom + convert \( -size ${wd}x${hh} xc:black xc:white -append \) \ + \( -size ${wd}x$gsize gradient: -rotate 180 \) -gravity center \ + -compose over -composite +repage $tmpN1 + # Do ramped blending in overlap + convert $infile \ + \( -clone 0 -gravity southeast -crop $cropsize +repage -virtual-pixel $vp \ + -define distort:viewport=$sesize -distort SRT 0 +repage \) \ + \( -clone 0 -gravity southwest -crop $cropsize +repage -virtual-pixel $vp \ + -define distort:viewport=$swsize -distort SRT 0 +repage \) \ + \( -clone 0 -gravity northeast -crop $cropsize +repage -virtual-pixel $vp \ + -define distort:viewport=$nesize -distort SRT 0 +repage \) \ + \( -clone 0 -gravity northwest -crop $cropsize +repage -virtual-pixel $vp \ + -define distort:viewport=$nwsize -distort SRT 0 +repage \) \ + \( -clone 1 -gravity northwest -extent ${wd}x${hho} -clone 2 $tmpM1 \ + -gravity northeast -composite \) \ + \( -clone 3 -gravity southwest -extent ${wd}x${hho} -clone 4 $tmpM1 \ + -gravity southeast -composite \) \ + \( -clone 5 -gravity north -extent ${wd}x${ht} -clone 6 $tmpN1 \ + -gravity south -composite \) \ + -delete 0-6 \ + "$outfile" +fi + +exit 0 \ No newline at end of file diff --git a/scripts/munge_color.ps1 b/scripts/munge_color.ps1 new file mode 100644 index 0000000..418d234 --- /dev/null +++ b/scripts/munge_color.ps1 @@ -0,0 +1,27 @@ +Param ( + [string]$Colors=16, + [string]$Size="256x256", + [string]$Pixel="7" + ) + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\armored_knight_1.png -OutFile .\assets\armored_knight_1-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\gold_savior_oil.png -OutFile .\assets\gold_savior_oil-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\ceiling_test.png -OutFile .\assets\ceiling_test-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\torch_pillar.png -OutFile .\assets\torch_pillar-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\torch_pillar.png -OutFile .\assets\torch_pillar-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\glowing_moss_wall.png -OutFile .\assets\glowing_moss_wall-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\large_stone_floor.png -OutFile .\assets\large_stone_floor-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\floor_tile_test.png -OutFile .\assets\floor_tile_test-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\wall_texture_test.png -OutFile .\assets\wall_texture_test-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\hairy_spider.png -OutFile .\assets\hairy_spider-256.png -Colors $Colors -Pixel $Pixel -Size $Size + +.\scripts\pixelize.ps1 -InFile C:\Users\lcthw\Pictures\Games\axe_ranger.png -OutFile .\assets\axe_ranger-256.png -Colors $Colors -Pixel $Pixel -Size $Size diff --git a/scripts/pixelize.ps1 b/scripts/pixelize.ps1 new file mode 100644 index 0000000..d6974ff --- /dev/null +++ b/scripts/pixelize.ps1 @@ -0,0 +1,16 @@ +Param ( + [string]$InFile, + [string]$OutFile, + [string]$Colors=16, + [string]$Size="256x256", + [string]$Pixel="7" + ) + +cp $InFile temp.png +# magick temp.png -colors $Colors -quantize sRGB -dither FloydSteinberg color.png + +magick temp.png -posterize $Colors -quantize sRGB -dither FloydSteinberg poster.png + +bash ./scripts/magick/pixelize -s $Pixel -m 2 poster.png pixels.png + +magick.exe pixels.png -interpolate nearest -interpolative-resize $Size $OutFile diff --git a/scripts/reset_build.ps1 b/scripts/reset_build.ps1 new file mode 100644 index 0000000..975852d --- /dev/null +++ b/scripts/reset_build.ps1 @@ -0,0 +1,7 @@ +mv .\subprojects\packagecache . +rm -recurse -force .\subprojects\,.\builddir\ +mkdir subprojects +mv .\packagecache .\subprojects\ +mkdir builddir +cp wraps\*.wrap subprojects\ +meson setup --default-library=static --prefer-static builddir diff --git a/scripts/reset_build.sh b/scripts/reset_build.sh new file mode 100644 index 0000000..89931e7 --- /dev/null +++ b/scripts/reset_build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +mv -f ./subprojects/packagecache . +rm -rf subprojects builddir +mkdir subprojects +mv -f packagecache ./subprojects/ && true +mkdir builddir +cp wraps/*.wrap subprojects/ +# on OSX you can't do this with static +meson setup --default-library=static --prefer-static builddir diff --git a/scripts/win_installer.ifp b/scripts/win_installer.ifp new file mode 100644 index 0000000..f1a9f29 Binary files /dev/null and b/scripts/win_installer.ifp differ diff --git a/src/ai/ai.cpp b/src/ai/ai.cpp new file mode 100644 index 0000000..1df9c07 --- /dev/null +++ b/src/ai/ai.cpp @@ -0,0 +1,214 @@ +#include "dbc.hpp" +#include "ai.hpp" + +namespace ai { + using namespace nlohmann; + using namespace dbc; + + static AIManager AIMGR; + static bool initialized = false; + + inline void validate_profile(nlohmann::json& profile) { + for(auto& [name_key, value] : profile.items()) { + check(value < STATE_MAX, + $F("profile field {} has value {} greater than STATE_MAX {}", + (std::string)name_key, (int)value, STATE_MAX)); + } + } + + Action config_action(AIProfile& profile, nlohmann::json& config) { + check(config.contains("name"), "config_action: action config missing name"); + check(config.contains("cost"), "config_action: action config missing cost"); + + Action result(config["name"], config["cost"]); + + check(config.contains("needs"), + $F("config_action: no 'needs' field", result.name)); + check(config.contains("effects"), + $F("config_action: no 'effects' field", result.name)); + + for(auto& [name_key, value] : config["needs"].items()) { + check(profile.contains(name_key), $F("config_action({}): profile does not have need named {}", result.name, name_key)); + result.needs(profile.at(name_key), bool(value)); + } + + for(auto& [name_key, value] : config["effects"].items()) { + check(profile.contains(name_key), $F("config_action({}): profile does not have effect named {}", result.name, name_key)); + + result.effect(profile.at(name_key), bool(value)); + } + + return result; + } + + State config_state(AIProfile& profile, nlohmann::json& config) { + State result; + + for(auto& [name_key, value] : config.items()) { + check(profile.contains(name_key), $F("config_state: profile does not have name {}", name_key)); + + int name_id = profile.at(name_key); + result[name_id] = bool(value); + } + + return result; + } + + /* + * This is only used in tests so I can load different fixtures. + */ + void reset() { + initialized = false; + AIMGR.actions.clear(); + AIMGR.states.clear(); + AIMGR.scripts.clear(); + AIMGR.profile = json({}); + } + + void init(const std::string& config_path) { + if(!initialized) { + auto config = settings::get(config_path); + + // profile specifies what keys (bitset indexes) are allowed + // and how they map to the bitset of State + validate_profile(config["profile"]); + + // relies on json conversion? + AIMGR.profile = config["profile"]; + + // load all actions + auto& actions = config["actions"]; + for(auto& action_vars : actions) { + auto the_action = config_action(AIMGR.profile, action_vars); + AIMGR.actions.insert_or_assign(the_action.name, the_action); + } + + // load all states + auto& states = config["states"]; + for(auto& [name, state_vars] : states.items()) { + auto the_state = config_state(AIMGR.profile, state_vars); + AIMGR.states.insert_or_assign(name, the_state); + } + + auto& scripts = config["scripts"]; + for(auto& [script_name, action_names] : scripts.items()) { + std::vector the_script; + + for(auto name : action_names) { + check(AIMGR.actions.contains(name), + $F("ai::init(): script {} uses action {} that doesn't exist", + (std::string)script_name, (std::string)name)); + + the_script.push_back(AIMGR.actions.at(name)); + } + + AIMGR.scripts.insert_or_assign(script_name, the_script); + } + initialized = true; + } else { + // BUG: should track the inits per file, or create a separate init that's an overload of the default + // then this version is just used in tests + dbc::sentinel($F("DOUBLE INIT {}: AI manager should only be intialized once if not in tests.", config_path)); + } + } + + void check_valid_action(std::string name, std::string msg) { + dbc::check(AIMGR.actions.contains(name), + $F("{} tried to access action that doesn't exist {}", + msg, name)); + } + + State load_state(std::string state_name) { + check(initialized, "you forgot to initialize the AI first."); + check(AIMGR.states.contains(state_name), + $F("ai::load_state({}): state does not exist in config", + state_name)); + + return AIMGR.states.at(state_name); + } + + Action load_action(std::string action_name) { + check(initialized, "you forgot to initialize the AI first."); + check(AIMGR.actions.contains(action_name), + $F("ai::load_action({}): action does not exist in config", + action_name)); + return AIMGR.actions.at(action_name); + } + + std::vector load_script(std::string script_name) { + check(AIMGR.scripts.contains(script_name), + $F("ai::load_script(): no script named {} configured", script_name)); + return AIMGR.scripts.at(script_name); + } + + ActionPlan plan(std::string script_name, State start, State goal) { + // BUG: could probably memoize here, since: + // same script+same start+same goal will/should produce the same results + + check(initialized, "you forgot to initialize the AI first."); + auto script = load_script(script_name); + return plan_actions(script, start, goal); + } + + int state_id(std::string name) { + check(AIMGR.profile.contains(name), + $F("ai::state_id({}): id is not configured in profile", name)); + return AIMGR.profile.at(name); + } + + void set(State& state, std::string name, bool value) { + // resort by best fit + state.set(state_id(name), value); + } + + bool test(State state, std::string name) { + return state.test(state_id(name)); + } + + void EntityAI::fit_sort() { + if(active()) { + std::sort(plan.script.begin(), plan.script.end(), + [&](auto& l, auto& r) { + int l_cost = l.cost + ai::distance_to_goal(start, goal); + int r_cost = r.cost + ai::distance_to_goal(start, goal); + return l_cost < r_cost; + }); + } + } + + std::string& EntityAI::wants_to() { + return plan.script[0].name; + } + + bool EntityAI::wants_to(std::string name) { + ai::check_valid_action(name, "EntityAI::wants_to"); + return plan.script.size() > 0 && plan.script[0].name == name; + } + + bool EntityAI::active() { + if(plan.script.size() == 1) { + return plan.script[0] != FINAL_ACTION; + } else { + return plan.script.size() != 0; + } + } + + void EntityAI::set_state(std::string name, bool setting) { + fit_sort(); + ai::set(start, name, setting); + } + + bool EntityAI::get_state(std::string name) { + return ai::test(start, name); + } + + void EntityAI::update() { + plan = ai::plan(script, start, goal); + fit_sort(); + } + + AIProfile* profile() { + return &AIMGR.profile; + } + +} diff --git a/src/ai/ai.hpp b/src/ai/ai.hpp new file mode 100644 index 0000000..fcf007c --- /dev/null +++ b/src/ai/ai.hpp @@ -0,0 +1,65 @@ +#pragma once +#include +#include "algos/matrix.hpp" +#include +#include +#include +#include +#include "game/config.hpp" +#include "goap.hpp" + +namespace ai { + struct EntityAI { + std::string script; + ai::State start; + ai::State goal; + ai::ActionPlan plan; + + EntityAI(std::string script, ai::State start, ai::State goal) : + script(script), start(start), goal(goal) + { + } + + EntityAI() {}; + + bool wants_to(std::string name); + std::string& wants_to(); + void fit_sort(); + + bool active(); + + void set_state(std::string name, bool setting); + bool get_state(std::string name); + + void update(); + + void dump(); + std::string to_string(); + }; + + struct AIManager { + AIProfile profile; + std::unordered_map actions; + std::unordered_map states; + std::unordered_map> scripts; + }; + + /* This is really only used in test to load different fixtures. */ + void reset(); + void init(const std::string& config_path); + + Action config_action(AIProfile& profile, nlohmann::json& config); + State config_state(AIProfile& profile, nlohmann::json& config); + + int state_id(std::string name); + State load_state(std::string state_name); + Action load_action(std::string action_name); + std::vector load_script(std::string script_name); + + void set(State& state, std::string name, bool value=true); + bool test(State state, std::string name); + ActionPlan plan(std::string script_name, State start, State goal); + + /* Mostly used for debugging and validation. */ + void check_valid_action(std::string name, std::string msg); +} diff --git a/src/ai/ai_debug.cpp b/src/ai/ai_debug.cpp new file mode 100644 index 0000000..351df2b --- /dev/null +++ b/src/ai/ai_debug.cpp @@ -0,0 +1,74 @@ +#include "ai.hpp" +#include "ai_debug.hpp" + +namespace ai { + + /* + * Yeah this is weird but it's only to debug things like + * the preconditions which are weirdly done. + */ + void dump_only(State state, bool matching, bool show_as) { + AIProfile* profile = ai::profile(); + for(auto& [name, name_id] : *profile) { + if(state.test(name_id) == matching) { + fmt::println("\t{}={}", name, show_as); + } + } + } + + void dump_state(State state) { + AIProfile* profile = ai::profile(); + for(auto& [name, name_id] : *profile) { + fmt::println("\t{}={}", name, + state.test(name_id)); + } + } + + void dump_action(Action& action) { + fmt::println(" --ACTION: {}, cost={}", action.name, action.cost); + + fmt::println(" PRECONDS:"); + dump_only(action.$positive_preconds, true, true); + dump_only(action.$negative_preconds, true, false); + + fmt::println(" EFFECTS:"); + dump_only(action.$positive_effects, true, true); + dump_only(action.$negative_effects, true, false); + } + + State dump_script(std::string msg, State start, Script& script) { + fmt::println("--SCRIPT DUMP: {}", msg); + fmt::println("# STATE BEFORE:"); + dump_state(start); + fmt::print("% ACTIONS PLANNED:"); + for(auto& action : script) { + fmt::print("{} ", action.name); + } + fmt::print("\n"); + + for(auto& action : script) { + dump_action(action); + + start = action.apply_effect(start); + fmt::println(" ## STATE AFTER:"); + dump_state(start); + } + + return start; + } + + void EntityAI::dump() { + dump_script(script, start, plan.script); + } + + std::string EntityAI::to_string() { + AIProfile* profile = ai::profile(); + std::string result = wants_to(); + + for(auto& [name, name_id] : *profile) { + result += fmt::format("\n{}={}", name, start.test(name_id)); + } + + return result; + } +} diff --git a/src/ai/ai_debug.hpp b/src/ai/ai_debug.hpp new file mode 100644 index 0000000..27f3b54 --- /dev/null +++ b/src/ai/ai_debug.hpp @@ -0,0 +1,10 @@ +#pragma once +#include "goap.hpp" + +namespace ai { + AIProfile* profile(); + void dump_only(State state, bool matching, bool show_as); + void dump_state(State state); + void dump_action(Action& action); + State dump_script(std::string msg, State start, Script& script); +} diff --git a/src/ai/goap.cpp b/src/ai/goap.cpp new file mode 100644 index 0000000..ee2e024 --- /dev/null +++ b/src/ai/goap.cpp @@ -0,0 +1,187 @@ +#include "dbc.hpp" +#include "goap.hpp" +#include "ai_debug.hpp" +#include "algos/stats.hpp" +#include + +// #define DEBUG_CYCLES 1 + +namespace ai { + using namespace nlohmann; + using namespace dbc; + + bool is_subset(State& source, State& target) { + State result = source & target; + return result == target; + } + + void Action::needs(int name, bool val) { + if(val) { + $positive_preconds[name] = true; + $negative_preconds[name] = false; + } else { + $negative_preconds[name] = true; + $positive_preconds[name] = false; + } + } + + void Action::effect(int name, bool val) { + if(val) { + $positive_effects[name] = true; + $negative_effects[name] = false; + } else { + $negative_effects[name] = true; + $positive_effects[name] = false; + } + } + + void Action::ignore(int name) { + $positive_preconds[name] = false; + $negative_preconds[name] = false; + } + + bool Action::can_effect(State& state) { + bool posbit_match = (state & $positive_preconds) == $positive_preconds; + bool negbit_match = (state & $negative_preconds) == ALL_ZERO; + return posbit_match && negbit_match; + } + + State Action::apply_effect(State& state) { + return (state | $positive_effects) & ~$negative_effects; + } + + int distance_to_goal(State from, State to) { + auto result = from ^ to; + int count = result.count(); + return count; + } + + inline void dump_came_from(std::string msg, std::unordered_map& came_from, Action& current) { + fmt::println("{}: {}", msg, current.name); + + for(auto& [from, to] : came_from) { + fmt::println("from={}; to={}", from.name, to.name); + } + } + + inline void path_invariant(std::unordered_map& came_from, Action current) { +#if defined(DEBUG_CYCLES) + bool final_found = current == FINAL_ACTION; + + for(size_t i = 0; i <= came_from.size() && came_from.contains(current); i++) { + current = came_from.at(current); + final_found = current == FINAL_ACTION; + } + + if(!final_found) { + dump_came_from("CYCLE DETECTED!", came_from, current); + dbc::sentinel("AI CYCLE FOUND!"); + } +#else + (void)came_from; // disable errors about unused + (void)current; +#endif + } + + Script reconstruct_path(std::unordered_map& came_from, Action& current) { + Script total_path{current}; + + path_invariant(came_from, current); + + for(size_t i = 0; i <= came_from.size() && came_from.contains(current); i++) { + auto next = came_from.at(current); + + if(next != FINAL_ACTION) { + // remove the previous node to avoid cycles and repeated actions + total_path.push_front(next); + came_from.erase(current); + current = next; + } else { + // found the terminator, done + break; + } + } + + return total_path; + } + + inline int h(State start, State goal) { + return distance_to_goal(start, goal); + } + + inline int d(State start, State goal) { + return distance_to_goal(start, goal); + } + + ActionState find_lowest(std::unordered_map& open_set) { + check(!open_set.empty(), "open set can't be empty in find_lowest"); + int found_score = std::numeric_limits::max(); + ActionState found_as; + + for(auto& kv : open_set) { + if(kv.second <= found_score) { + found_score = kv.second; + found_as = kv.first; + } + } + + return found_as; + } + + ActionPlan plan_actions(std::vector& actions, State start, State goal) { + std::unordered_map open_set; + std::unordered_map came_from; + std::unordered_map g_score; + std::unordered_map closed_set; + ActionState current{FINAL_ACTION, start}; + + g_score.insert_or_assign(start, 0); + open_set.insert_or_assign(current, h(start, goal)); + + while(!open_set.empty()) { + // current := the node in openSet having the lowest fScore[] value + current = find_lowest(open_set); + + if(is_subset(current.state, goal)) { + return {true, + reconstruct_path(came_from, current.action)}; + } + + open_set.erase(current); + closed_set.insert_or_assign(current.state, true); + + for(auto& neighbor_action : actions) { + // calculate the State being current/neighbor + if(!neighbor_action.can_effect(current.state)) continue; + + auto neighbor = neighbor_action.apply_effect(current.state); + if(closed_set.contains(neighbor)) continue; + + // BUG: no matter what I do cost really doesn't impact the graph + // Additionally, every other GOAP implementation has the same problem, and + // it's probably because the selection of actions is based more on sets matching + // than actual weights of paths. This reduces the probability that an action will + // be chosen over another due to only cost. + int d_score = d(current.state, neighbor) + neighbor_action.cost; + + int tentative_g_score = g_score[current.state] + d_score; + int neighbor_g_score = g_score.contains(neighbor) ? g_score[neighbor] : SCORE_MAX; + + if(tentative_g_score + neighbor_action.cost < neighbor_g_score) { + came_from.insert_or_assign(neighbor_action, current.action); + + g_score.insert_or_assign(neighbor, tentative_g_score); + + ActionState neighbor_as{neighbor_action, neighbor}; + + int score = tentative_g_score + h(neighbor, goal); + + // this maybe doesn't need score + open_set.insert_or_assign(neighbor_as, score); + } + } + } + + return {is_subset(current.state, goal), reconstruct_path(came_from, current.action)}; + } +} diff --git a/src/ai/goap.hpp b/src/ai/goap.hpp new file mode 100644 index 0000000..4ab861c --- /dev/null +++ b/src/ai/goap.hpp @@ -0,0 +1,84 @@ +#pragma once +#include +#include "algos/matrix.hpp" +#include +#include +#include +#include +#include "game/config.hpp" + +namespace ai { + // ZED: I don't know if this is the best place for this + using AIProfile = std::unordered_map; + + constexpr const int SCORE_MAX = std::numeric_limits::max() / 2; + constexpr const size_t STATE_MAX = 32; + + using State = std::bitset; + + const State ALL_ZERO; + const State ALL_ONES = ~ALL_ZERO; + + struct Action { + std::string name=""; + int cost = 0; + + State $positive_preconds=ALL_ZERO; + State $negative_preconds=ALL_ZERO; + + State $positive_effects=ALL_ZERO; + State $negative_effects=ALL_ZERO; + + void needs(int name, bool val); + void effect(int name, bool val); + void ignore(int name); + + bool can_effect(State& state); + State apply_effect(State& state); + + bool operator==(const Action& other) const { + return other.name == name; + } + }; + + + using Script = std::deque; + const Action FINAL_ACTION{"END", SCORE_MAX}; + + struct ActionState { + Action action; + State state; + + ActionState(Action action, State state) : + action(action), state(state) {} + + ActionState() : action(FINAL_ACTION), state(0) {} + + bool operator==(const ActionState& other) const { + return other.action == action && other.state == state; + } + }; + + struct ActionPlan { + bool complete = false; + Script script; + }; + + bool is_subset(State& source, State& target); + + int distance_to_goal(State from, State to); + + ActionPlan plan_actions(std::vector& actions, State start, State goal); +} + +template<> struct std::hash { + size_t operator()(const ai::Action& p) const { + return std::hash{}(p.name); + } +}; + +template<> struct std::hash { + size_t operator()(const ai::ActionState& p) const { + return std::hash{}(p.action) ^ std::hash{}(p.state); + } +}; diff --git a/src/algos/dinkyecs.hpp b/src/algos/dinkyecs.hpp new file mode 100644 index 0000000..0fd0283 --- /dev/null +++ b/src/algos/dinkyecs.hpp @@ -0,0 +1,247 @@ +#pragma once + +#include "dbc.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DinkyECS +{ + using Entity = unsigned long; + + const Entity NONE = 0; + + template + struct ComponentStorage { + std::vector data; + }; + + struct Event { + int event = 0; + Entity entity = 0; + std::any data; + }; + + using EntityMap = std::unordered_map; + using EventQueue = std::queue; + using TypeMap = std::unordered_map; + + struct World { + unsigned long entity_count = NONE+1; + std::unordered_map $components; + std::shared_ptr $facts = nullptr; + std::unordered_map $events; + std::unordered_map $component_storages; + std::unordered_map> $free_indices; + std::unordered_map $constants; + + World() : $facts(std::make_shared()) + {} + + Entity entity() { return ++entity_count; } + + void destroy(DinkyECS::Entity entity) { + dbc::check(!$constants.contains(entity), "trying to destroy an entity in constants"); + + for(auto& [tid, map] : $components) { + + if(map.contains(entity)) { + size_t index = map.at(entity); + auto& free_queue = $free_indices.at(tid); + free_queue.push(index); + map.erase(entity); + } + } + } + + void clone_into(DinkyECS::World &to_world) { + to_world.$constants = $constants; + to_world.$facts = $facts; + // BUG*10: entity IDs should be a global counter, not per world + to_world.entity_count = entity_count; + to_world.$component_storages = $component_storages; + + for(auto [eid, is_set] : $constants) { + dbc::check(is_set == true, "is_set was not true? WHAT?!"); + dbc::check(eid <= entity_count, + $F("eid {} is not less than entity_count {}", eid, entity_count)); + + for(const auto &[tid, eid_map] : $components) { + auto &their_map = to_world.$components[tid]; + if(eid_map.contains(eid)) { + their_map.insert_or_assign(eid, eid_map.at(eid)); + } + } + } + } + + void make_constant(DinkyECS::Entity entity) { + $constants.try_emplace(entity, true); + } + + void not_constant(DinkyECS::Entity entity) { + $constants.erase(entity); + } + + template + size_t make_component() { + auto &storage = component_storage_for(); + auto &free_queue = $free_indices.at(std::type_index(typeid(Comp))); + size_t index; + + if(!free_queue.empty()) { + index = free_queue.front(); + free_queue.pop(); + } else { + storage.data.emplace_back(); + index = storage.data.size() - 1; + } + + return index; + } + + template + ComponentStorage &component_storage_for() { + auto type_index = std::type_index(typeid(Comp)); + $component_storages.try_emplace(type_index, ComponentStorage{}); + $free_indices.try_emplace(type_index, std::queue{}); + return std::any_cast &>( + $component_storages.at(type_index)); + } + + template + EntityMap &entity_map_for() { + return $components[std::type_index(typeid(Comp))]; + } + + template + EventQueue &queue_map_for() { + return $events[std::type_index(typeid(Comp))]; + } + + template + void remove(Entity ent) { + EntityMap &map = entity_map_for(); + + if(map.contains(ent)) { + size_t index = map.at(ent); + auto& free_queue = $free_indices.at(std::type_index(typeid(Comp))); + free_queue.push(index); + map.erase(ent); + } + } + + template + void set_the(Comp val) { + $facts->insert_or_assign(std::type_index(typeid(Comp)), val); + } + + template + Comp &get_the() { + auto comp_id = std::type_index(typeid(Comp)); + dbc::check($facts->contains(comp_id), + $F("!!!! ATTEMPT to access world fact that hasn't been set yet: {}", + typeid(Comp).name())); + + // use .at to get std::out_of_range if fact not set + std::any &res = $facts->at(comp_id); + return std::any_cast(res); + } + + template + bool has_the() { + auto comp_id = std::type_index(typeid(Comp)); + return $facts->contains(comp_id); + } + + template + void set(Entity ent, Comp val) { + EntityMap &map = entity_map_for(); + + if(has(ent)) { + get(ent) = val; + return; + } + + map.insert_or_assign(ent, make_component()); + get(ent) = val; + } + + template + Comp &get(Entity ent) { + EntityMap &map = entity_map_for(); + auto &storage = component_storage_for(); + auto index = map.at(ent); + return storage.data[index]; + } + + template + bool has(Entity ent) { + EntityMap &map = entity_map_for(); + return map.contains(ent); + } + + template + void query(std::function cb) { + EntityMap &map = entity_map_for(); + + for(auto &[entity, index] : map) { + cb(entity, get(entity)); + } + } + + template + void query(std::function cb) { + EntityMap &map_a = entity_map_for(); + EntityMap &map_b = entity_map_for(); + + for(auto &[entity, index_a] : map_a) { + if(map_b.contains(entity)) { + cb(entity, get(entity), get(entity)); + } + } + } + + template + void send(Comp event, Entity entity, std::any data) { + EventQueue &queue = queue_map_for(); + queue.push({event, entity, data}); + } + + template + Event recv() { + EventQueue &queue = queue_map_for(); + Event evt = queue.front(); + queue.pop(); + return evt; + } + + template + bool has_event() { + EventQueue &queue = queue_map_for(); + return !queue.empty(); + } + + /* std::optional can't do references. Don't try it! + * Actually, this sucks, either delete it or have it + * return pointers (assuming optional can handle pointers) + */ + template + Comp* get_if(DinkyECS::Entity entity) { + EntityMap &map = entity_map_for(); + auto &storage = component_storage_for(); + if(map.contains(entity)) { + auto index = map.at(entity); + return &storage.data[index]; + } else { + return nullptr; + } + } + }; +} // namespace DinkyECS diff --git a/src/algos/matrix.cpp b/src/algos/matrix.cpp new file mode 100644 index 0000000..ad72d64 --- /dev/null +++ b/src/algos/matrix.cpp @@ -0,0 +1,44 @@ +#include "algos/matrix.hpp" +#include +#include "constants.hpp" + +using namespace fmt; +using std::min, std::max; + +namespace matrix { + void dump(const std::string &msg, Matrix &map, int show_x, int show_y) { + println("----------------- {}", msg); + + for(each_row it{map}; it.next();) { + int cell = map[it.y][it.x]; + + if(int(it.x) == show_x && int(it.y) == show_y) { + if(cell == WALL_PATH_LIMIT) { + print("!<", cell); + } else { + print("{:x}<", cell); + } + } else if(cell == DEAD_END_VALUE) { + print("* "); + } else if(cell == DOOR_VALUE) { + print("¦ "); + } else if(cell == 1) { + print("â–’â–’"); + } else if(cell == ROOM_SPACE_VALUE) { + print("\%\%"); + } else if(cell == WALL_PATH_LIMIT) { + print("â– â– "); + } else if(cell == 0) { + print(" "); + } else if(cell > 15 && cell < 32) { + print("{:X} ", cell - 16); + } else if(cell > 31) { + print(".."); + } else { + print("{:x} ", cell); + } + + if(it.row) print("\n"); + } + } +} diff --git a/src/algos/matrix.hpp b/src/algos/matrix.hpp new file mode 100644 index 0000000..dedcd57 --- /dev/null +++ b/src/algos/matrix.hpp @@ -0,0 +1,44 @@ +#pragma once +#include "algos/shiterator.hpp" + +namespace matrix { + using Row = shiterator::BaseRow; + using Matrix = shiterator::Base; + + using viewport = shiterator::viewport_t; + using perimeter = shiterator::perimeter_t; + + using each_cell = shiterator::each_cell_t; + + using each_row = shiterator::each_row_t; + using box = shiterator::box_t; + using compass = shiterator::compass_t; + using circle = shiterator::circle_t; + using rectangle = shiterator::rectangle_t; + using rando_rect = shiterator::rando_rect_t; + using rando_rect = shiterator::rando_rect_t; + using rando_box = shiterator::rando_box_t; + using line = shiterator::line_t; + + void dump(const std::string &msg, Matrix &map, int show_x=-1, int show_y=-1); + + inline Matrix make(size_t width, size_t height) { + return shiterator::make(width, height); + } + + inline bool inbounds(Matrix &mat, size_t x, size_t y) { + return shiterator::inbounds(mat, x, y); + } + + inline size_t width(Matrix &mat) { + return shiterator::width(mat); + } + + inline size_t height(Matrix &mat) { + return shiterator::height(mat); + } + + inline void assign(Matrix &out, int new_value) { + shiterator::assign(out, new_value); + } +} diff --git a/src/algos/maze.cpp b/src/algos/maze.cpp new file mode 100644 index 0000000..8646748 --- /dev/null +++ b/src/algos/maze.cpp @@ -0,0 +1,509 @@ +#include +#include +#include "algos/rand.hpp" +#include "constants.hpp" +#include "algos/maze.hpp" +#include + +using std::string; +using matrix::Matrix; + +namespace maze { + inline bool complete(Matrix& maze, size_t width, size_t height) { + for(size_t row = 1; row < height; row += 2) { + for(size_t col = 1; col < width; col += 2) { + if(maze[row][col] != 0) return false; + } + } + + return true; + } + + std::vector neighborsAB(Matrix& maze, Point on) { + std::vector result; + + std::array points{{ + {on.x, on.y - 2}, + {on.x, on.y + 2}, + {on.x - 2, on.y}, + {on.x + 2, on.y} + }}; + + for(auto point : points) { + if(matrix::inbounds(maze, point.x, point.y)) { + result.push_back(point); + } + } + + return result; + } + + std::vector neighbors(Matrix& maze, Point on) { + std::vector result; + + std::array points{{ + {on.x, on.y - 2}, + {on.x, on.y + 2}, + {on.x - 2, on.y}, + {on.x + 2, on.y} + }}; + + for(auto point : points) { + if(matrix::inbounds(maze, point.x, point.y)) { + if(maze[point.y][point.x] == WALL_VALUE) { + result.push_back(point); + } + } + } + + return result; + } + + inline std::pair find_coord(Matrix& maze, size_t width, size_t height) { + for(size_t y = 1; y < height; y += 2) { + for(size_t x = 1; x < width; x += 2) { + if(maze[y][x] == WALL_VALUE) { + auto found = neighborsAB(maze, {x, y}); + + for(auto point : found) { + if(maze[point.y][point.x] == 0) { + return {{x, y}, point}; + } + } + } + } + } + + matrix::dump("BAD MAZE", maze); + dbc::sentinel("failed to find coord?"); + } + + bool Builder::room_should_exist(Room& room, bool allow_dupes) { + // padding by 1 for the perimeter wall + if(!matrix::inbounds($walls, room.x - 1, room.y - 1) || + !matrix::inbounds($walls, room.x + room.width + 2, room.y + room.height + 2)) + { + return false; + } + + if(room.overlaps($no_rooms_region)) { + return false; + } + + for(auto& other : $rooms) { + bool is_duped = allow_dupes ? room == other : room != other; + + if(is_duped && room.overlaps(other)) { + return false; + } + } + + // it's in the map and doesn't collide with another room + return true; + } + + inline size_t room_sweetspot(size_t width, size_t room_size) { + if(width < 20) { + return 4; + } else if(width < 30) { + return (width / room_size / 2); + } if(width < 50) { + return width / room_size; + } else { + return width / 2; + } + } + + void Builder::randomize_rooms(size_t room_size) { + std::shuffle($dead_ends.begin(), $dead_ends.end(), Random::GENERATOR); + size_t max_rooms = room_sweetspot($width, room_size); + + // use those dead ends to randomly place rooms + for(auto at : $dead_ends) { + // don't bother with dead ends near the edge + if(at.x < 2 || at.y < 2 || at.x > $width - 3 || at.y > $height - 3) continue; + + // quit after we've hit the room max threshold + if($rooms.size() > max_rooms) break; + + // get the room corners randomized + std::array starts{{ + {at.x, at.y}, // top left + {at.x - room_size + 1, at.y}, // top right + {at.x - room_size + 1, at.y - room_size + 1}, // bottom right + {at.x, at.y - room_size + 1} // bottom left + }}; + + size_t offset = Random::uniform(0, 3); + + // BUG: this still accidentally merges rooms + for(size_t i = 0; i < starts.size(); i++) { + size_t index = (i + offset) % starts.size(); + Room cur{starts[index].x, starts[index].y, room_size, room_size}; + + // if it's out of bounds skip it + if(room_should_exist(cur)) { + $rooms.push_back(cur); + break; + } + } + } + } + + void Builder::clear() { + matrix::assign($walls, WALL_VALUE); + } + + void Builder::divide(Point start, Point end) { + for(matrix::line it{start, end}; it.next();) { + $walls[it.y][it.x] = 0; + $walls[it.y+1][it.x] = 0; + } + } + + void Builder::hunt_and_kill(Point on) { + if($rooms.size() > 0) place_rooms(); + + while(!complete($walls, $width, $height)) { + auto n = neighbors($walls, on); + + if(n.size() == 0) { + // no neighbors, must be dead end + add_dead_end(on); + auto t = find_coord($walls, $width, $height); + on = t.first; + $walls[on.y][on.x] = 0; + size_t row = (on.y + t.second.y) / 2; + size_t col = (on.x + t.second.x) / 2; + $walls[row][col] = 0; + } else { + // found neighbors, pick random one + auto nb = n[Random::abs(size_t(0), n.size() - 1)]; + $walls[nb.y][nb.x] = 0; + + size_t row = (nb.y + on.y) / 2; + size_t col = (nb.x + on.x) / 2; + $walls[row][col] = 0; + on = nb; + } + } + + if($rooms.size() > 0) place_rooms(); + } + + void Builder::place_rooms() { + for(auto& room : $rooms) { + for(matrix::rectangle it{$walls, room.x, room.y, room.width, room.height}; it.next();) { + $walls[it.y][it.x] = SPACE_VALUE; + } + } + } + + void Builder::inner_donut(float outer_rad, float inner_rad) { + size_t x = $width / 2; + size_t y = $height / 2; + + for(matrix::circle it{$walls, {x, y}, outer_rad}; it.next();) + { + for(int x = it.left; x < it.right; x++) { + $walls[it.y][x] = 0; + } + } + + for(matrix::circle it{$walls, {x, y}, inner_rad}; it.next();) + { + for(int x = it.left; x < it.right; x++) { + $walls[it.y][x] = 1; + } + } + } + + void Builder::remove_dead_ends() { + dbc::check($dead_ends.size() > 0, "you have to run an algo first, no dead_ends to remove"); + + for(auto at : $dead_ends) { + for(matrix::compass it{$walls, at.x, at.y}; it.next();) { + if($walls[it.y][it.x] == SPACE_VALUE) { + punch_dead_end(at.x, at.y, it.x, it.y); + break; + } + } + } + } + + void Builder::dump(const std::string& msg, bool path_too) { + Matrix wall_copy = $walls; + + // mark the rooms too, but not if pathing + if(!path_too) { + for(auto& room : $rooms) { + for(matrix::rectangle it{wall_copy, room.x, room.y, room.width, room.height}; + it.next();) + { + if(wall_copy[it.y][it.x] == 0) { + wall_copy[it.y][it.x] = ROOM_SPACE_VALUE; + } + } + } + } + + // mark dead ends + for(auto at : $dead_ends) { + // don't mark dead ends if there's something else there + wall_copy[at.y][at.x] = DEAD_END_VALUE; + } + + for(auto [at, _] : $doors) { + wall_copy[at.y][at.x] = DOOR_VALUE; + } + + if(path_too) { + for(matrix::each_cell it{wall_copy}; it.next();) { + if(wall_copy[it.y][it.x] == SPACE_VALUE) { + wall_copy[it.y][it.x] = $pathing.$paths[it.y][it.x]; + } + } + } + + matrix::dump(msg, wall_copy); + } + + void Builder::enclose() { + for(matrix::perimeter it{0, 0, $width, $height}; it.next();) { + $walls[it.y][it.x] = WALL_VALUE; + Point at{it.x,it.y}; + if($doors.contains(at)) $doors.erase(at); + } + } + + void Builder::open_box(size_t outer_size) { + size_t center_x = $width / 2; + size_t center_y = $height / 2; + + // compensate for the box's border now + outer_size++; + + // this can't be right but for now it's working + size_t x = center_x - outer_size; + size_t y = center_y - outer_size; + // BUG: is the + 1 here because the bug in perimeter + size_t width = (outer_size * 2) + 1; + size_t height = (outer_size * 2) + 1; + + for(matrix::perimeter p{x, y, width, height}; p.next();) { + for(matrix::compass it{$walls, p.x, p.y}; it.next();) { + if($ends_map.contains({it.x, it.y})) { + $walls[y][x] = 0; + break; + } + } + } + } + + bool Builder::valid_door(size_t x, size_t y) { + return (space_available(x, y - 1) && space_available(x, y + 1)) // north south + || (space_available(x - 1, y) && space_available(x + 1, y)); + } + + void Builder::place_doors() { + for(auto room : $rooms) { + int best_longest = 0; + Point best_door{0,0}; + bool door_found = false; + + // do an initial pathing check and if it's good then done + int longest = compute_paths(room.x, room.y); + if(longest < WALL_PATH_LIMIT && longest > int($width * 2)) continue; + + // can't path out of the room, so now punch a hole until it can + matrix::perimeter it{room.x - 1, room.y - 1, room.width + 2, room.height + 2}; + + while(it.next()) { + if($walls[it.y][it.x] == WALL_VALUE) { + // valid doors are free north/south or east/west + if(!valid_door(it.x, it.y)) continue; + + $walls[it.y][it.x] = SPACE_VALUE; + + longest = compute_paths(room.x, room.y); + + // keep track of the best door so far, which is the one with the longest path + if(longest != WALL_PATH_LIMIT && longest > best_longest) { + best_longest = longest; + best_door = {it.x, it.y}; + } + $walls[it.y][it.x] = WALL_VALUE; + } + + dbc::check(best_longest < WALL_PATH_LIMIT, "bad best_longest!"); + if(best_longest > int($width * 2)) break; + } + + // should now have a door with the longest path + $walls[best_door.y][best_door.x] = SPACE_VALUE; + $doors.insert_or_assign(best_door, true); + } + } + + void Builder::inner_box(size_t outer_size, size_t inner_size) { + size_t x = matrix::width($walls) / 2; + size_t y = matrix::height($walls) / 2; + + for(matrix::box it{$walls, x, y, outer_size}; it.next();) + { + $walls[it.y][it.x] = 0; + } + + for(matrix::box it{$walls, x, y, inner_size}; it.next();) + { + $walls[it.y][it.x] = 1; + } + + // make a fake room that blocks others + $no_rooms_region = {x - outer_size, y - outer_size, outer_size * 2 + 1, outer_size * 2 + 1}; + } + + void Builder::add_dead_end(Point at) { + // doing this ensures no dupes, if it's !inserted then it already existed + auto [_, inserted] = $ends_map.insert_or_assign(at, true); + + // so skip it, it isn't new + if(inserted) { + $dead_ends.push_back(at); + } + } + + int Builder::compute_paths(size_t x, size_t y) { + Point test{x, y}; + $pathing.set_target(test); + int longest = $pathing.compute_paths($walls); + // $pathing.dump("AFTER COMPUTE"); + $pathing.clear_target(test); + return longest; + } + + bool Builder::validate() { + size_t width = matrix::width($walls); + size_t height = matrix::height($walls); + + // no rooms can overlap + for(auto& room : $rooms) { + if(!room_should_exist(room)) return false; + } + + for(matrix::perimeter it{0, 0, width, height}; it.next();) { + if($ends_map.contains({it.x, it.y})) return false; + if($doors.contains({it.x, it.y})) return false; + if($walls[it.y][it.x] != WALL_VALUE) return false; + } + + if($rooms.size() <= 1) return true; + + // initial path test can just use one room then look for + // any cells that are empty in the walls map but unpathed in the paths + Room test_room = $rooms.at(Random::abs(size_t(0), $rooms.size() - 1)); + compute_paths(test_room.x, test_room.y); + + for(matrix::each_cell it{$walls}; it.next();) { + if($walls[it.y][it.x] == SPACE_VALUE && + $pathing.$paths[it.y][it.x] == WALL_PATH_LIMIT) { + return false; + } + } + + return true; + } + + bool Builder::space_available(size_t x, size_t y) { + return (matrix::inbounds($walls, x, y) && // in bounds + $walls[y][x] == SPACE_VALUE && // is a space + !$doors.contains({x, y}) && // no door there + (x > 0 && y > 0 && x < $width - 2 && y < $height - 2)); // not perimeter; + } + + void Builder::punch_dead_end(size_t at_x, size_t at_y, size_t x, size_t y) { + int diff_x = at_x - x; + int diff_y = at_y - y; + $walls[at_y + diff_y][at_x + diff_x] = SPACE_VALUE; + } + + bool Builder::repair() { + enclose(); + // if it's already valid then done + if(validate()) return true; + + std::vector removed_ends; + bool now_valid = false; + + for(auto& at : $dead_ends) { + // punch a hole for this dead end + for(matrix::compass it{$walls, at.x, at.y}; it.next();) { + if($walls[it.y][it.x] == SPACE_VALUE) { + punch_dead_end(at.x, at.y, it.x, it.y); + removed_ends.push_back({it.x, it.y}); + break; + } + } + + // if that validates it then done + if(validate()) { + now_valid = true; + break; + } + } + + // now go back and see if we can add any back + for(auto& at : removed_ends) { + auto temp = $walls[at.y][at.x]; + $walls[at.y][at.x] = WALL_VALUE; + + if(!validate()) { + $walls[at.y][at.x] = temp; + } + } + + enclose(); + return validate(); + } + + std::pair script(Map& map, nlohmann::json& config) { + maze::Builder maze(map); + + for(auto& action : config) { + std::string aname = action["action"]; + + if(aname == "hunt_and_kill") { + maze.hunt_and_kill(); + } else if(aname == "clear") { + maze.clear(); + } else if(aname == "inner_box") { + std::vector data = action["data"]; + maze.inner_box(data[0], data[1]); + } else if(aname == "randomize_rooms") { + std::vector data = action["data"]; + maze.randomize_rooms(data[0]); + } else if(aname == "open_box") { + std::vector data = action["data"]; + maze.open_box(data[0]); + } else if(aname == "place_doors") { + maze.place_doors(); + } else if(aname == "divide") { + std::vector data = action["data"]; + maze.divide({data[0], data[1]}, {data[2], data[3]}); + } else if(aname == "inner_donut") { + std::vector data = action["data"]; + maze.inner_donut(data[0], data[1]); + } else if(aname == "remove_dead_ends") { + maze.remove_dead_ends(); + } else if(aname == "enclose") { + maze.enclose(); + } else { + dbc::sentinel(fmt::format("Invalid maze action {}", aname)); + } + } + + bool valid = maze.repair(); + + return {maze, valid}; + } +} diff --git a/src/algos/maze.hpp b/src/algos/maze.hpp new file mode 100644 index 0000000..39bc41f --- /dev/null +++ b/src/algos/maze.hpp @@ -0,0 +1,54 @@ +#pragma once +#include "algos/matrix.hpp" +#include "game/map.hpp" +#include "algos/pathing.hpp" +#include + +namespace maze { + + struct Builder { + size_t $width = 0; + size_t $height = 0; + Matrix& $walls; + std::vector& $rooms; + std::unordered_map& $doors; + std::vector& $dead_ends; + std::unordered_map $ends_map; + Room $no_rooms_region{0,0,0,0}; + // BUG: instead of bool map it to the room? + Pathing $pathing; + + Builder(Map& map) : + $width(map.$width), $height(map.$height), $walls(map.$walls), + $rooms(map.$rooms), $doors(map.$doors), $dead_ends(map.$dead_ends), $pathing{$width, $height} + { + dbc::check($width % 2 == 1, "map width not an ODD number (perimter dead ends bug)"); + dbc::check($height % 2 == 1, "map height not an ODD number (perimter dead ends bug)"); + + clear(); + } + + void clear(); + void hunt_and_kill(Point on={1,1}); + void place_rooms(); + void enclose(); + void randomize_rooms(size_t room_size); + void inner_donut(float outer_rad, float inner_rad); + void inner_box(size_t outer_size, size_t inner_size); + void divide(Point start, Point end); + void remove_dead_ends(); + void dump(const std::string& msg, bool path_too=false); + void open_box(size_t outer_size); + void add_dead_end(Point at); + bool room_should_exist(Room& room, bool allow_dupes=false); + void place_doors(); + bool validate(); + bool repair(); + void punch_dead_end(size_t at_x, size_t at_y, size_t x, size_t y); + bool space_available(size_t x, size_t y); + int compute_paths(size_t x, size_t y); + bool valid_door(size_t x, size_t y); + }; + + std::pair script(Map& map, nlohmann::json& config); +} diff --git a/src/algos/pathing.cpp b/src/algos/pathing.cpp new file mode 100644 index 0000000..c67c9b4 --- /dev/null +++ b/src/algos/pathing.cpp @@ -0,0 +1,146 @@ +#include "constants.hpp" +#include "algos/pathing.hpp" +#include "dbc.hpp" +#include + +using std::vector; + +inline void add_neighbors(PointList &neighbors, Matrix &closed, size_t y, size_t x) { + for(matrix::box it{closed, x, y, 1}; it.next();) { + if(closed[it.y][it.x] == 0) { + closed[it.y][it.x] = 1; + neighbors.emplace_back(it.x, it.y); + } + } +} + +int Pathing::compute_paths(Matrix &walls) { + INVARIANT(); + dbc::check(walls[0].size() == $width, + $F("Pathing::compute_paths called with walls.width={} but paths $width={}", walls[0].size(), $width)); + + dbc::check(walls.size() == $height, + $F("Pathing::compute_paths called with walls.height={} but paths $height={}", walls[0].size(), $height)); + + // Initialize the new array with every cell at limit distance + matrix::assign($paths, WALL_PATH_LIMIT); + + Matrix closed = walls; + PointList starting_cells; + PointList open_cells; + + // First pass: Add starting cells and put them in closed + for(size_t counter = 0; counter < $height * $width; counter++) { + size_t x = counter % $width; + size_t y = counter / $width; + if($input[y][x] == 0) { + $paths[y][x] = 0; + closed[y][x] = 1; + starting_cells.emplace_back(x,y); + } + } + + // Second pass: Add border to open + for(auto sp : starting_cells) { + add_neighbors(open_cells, closed, sp.y, sp.x); + } + + // Third pass: Iterate filling in the open list + int counter = 1; // leave this here so it's available below + for(; counter < WALL_PATH_LIMIT && !open_cells.empty(); ++counter) { + PointList next_open; + for(auto sp : open_cells) { + $paths[sp.y][sp.x] = counter; + add_neighbors(next_open, closed, sp.y, sp.x); + } + open_cells = next_open; + } + + // Last pass: flood last cells + for(auto sp : open_cells) { + $paths[sp.y][sp.x] = counter; + } + + return counter; +} + +void Pathing::set_target(const Point &at, int value) { + $input[at.y][at.x] = value; +} + +void Pathing::clear_target(const Point &at) { + $input[at.y][at.x] = 1; +} + +PathingResult Pathing::find_path(Point &out, int direction, bool diag) +{ + // get the current dijkstra number + int cur = $paths[out.y][out.x]; + int target = cur; + // BUG: can I shortcut if target == 0 now? + + bool found = false; + + + // a lambda makes it easy to capture what we have to change + auto next_step = [&](size_t x, size_t y) -> bool { + target = $paths[y][x]; + // don't go through walls + // BUG: should actually do a collision check + // BUG: can I shortcut if target == 0 now? + if(target == WALL_PATH_LIMIT) return false; + + int weight = cur - target; + + if(weight == direction) { + out = {x, y}; + found = true; + // only break if this is a lower path + return true; + } else if(weight == 0) { + out = {x, y}; + found = true; + // only found an equal path, keep checking + } + + // this says keep going + return false; + }; + + + if(diag) { + // BUG: maybe a special alternative to box that doesn't do the central cell? + for(matrix::box it{$paths, out.x, out.y, 1}; it.next();) { + bool should_stop = next_step(it.x, it.y); + if(should_stop) break; + } + } else { + for(matrix::compass it{$paths, out.x, out.y}; it.next();) { + bool should_stop = next_step(it.x, it.y); + if(should_stop) break; + } + } + + if(target == 0) { + return PathingResult::FOUND; + } else if(!found) { + return PathingResult::FAIL; + } else { + return PathingResult::CONTINUE; + } +} + +bool Pathing::INVARIANT() { + using dbc::check; + + check($paths.size() == $height, "paths wrong height"); + check($paths[0].size() == $width, "paths wrong width"); + check($input.size() == $height, "input wrong height"); + check($input[0].size() == $width, "input wrong width"); + + return true; +} + +void Pathing::dump(const std::string& msg) { + matrix::dump(msg, $paths); +} diff --git a/src/algos/pathing.hpp b/src/algos/pathing.hpp new file mode 100644 index 0000000..e5eebf2 --- /dev/null +++ b/src/algos/pathing.hpp @@ -0,0 +1,41 @@ +#pragma once +#include "algos/point.hpp" +#include "algos/matrix.hpp" +#include + +using matrix::Matrix; + +constexpr const int PATHING_TOWARD=1; +constexpr const int PATHING_AWAY=-1; + +enum class PathingResult { + FAIL=0, + FOUND=1, + CONTINUE=2 +}; + +class Pathing { +public: + size_t $width; + size_t $height; + Matrix $paths; + Matrix $input; + + Pathing(size_t width, size_t height) : + $width(width), + $height(height), + $paths(height, matrix::Row(width, 1)), + $input(height, matrix::Row(width, 1)) + {} + + int compute_paths(Matrix &walls); + void set_target(const Point &at, int value=0); + void clear_target(const Point &at); + Matrix &paths() { return $paths; } + Matrix &input() { return $input; } + int distance(Point to) { return $paths[to.y][to.x];} + PathingResult find_path(Point &out, int direction, bool diag); + + bool INVARIANT(); + void dump(const std::string& msg); +}; diff --git a/src/algos/point.hpp b/src/algos/point.hpp new file mode 100644 index 0000000..8f8886b --- /dev/null +++ b/src/algos/point.hpp @@ -0,0 +1,20 @@ +#pragma once +#include + +struct Point { + size_t x = 0; + size_t y = 0; + + bool operator==(const Point& other) const { + return other.x == x && other.y == y; + } +}; + +typedef std::vector PointList; + +template<> struct std::hash { + size_t operator()(const Point& p) const { + auto hasher = std::hash(); + return hasher(p.x) ^ hasher(p.y); + } +}; diff --git a/src/algos/rand.cpp b/src/algos/rand.cpp new file mode 100644 index 0000000..983e04a --- /dev/null +++ b/src/algos/rand.cpp @@ -0,0 +1,12 @@ +#include "algos/rand.hpp" + +namespace Random { + std::random_device RNG; + std::mt19937 GENERATOR(RNG()); + + + std::chrono::milliseconds milliseconds(int from, int to) { + int tick = Random::uniform_real(float(from), float(to)); + return std::chrono::milliseconds{tick}; + } +} diff --git a/src/algos/rand.hpp b/src/algos/rand.hpp new file mode 100644 index 0000000..b82e3e4 --- /dev/null +++ b/src/algos/rand.hpp @@ -0,0 +1,41 @@ +#pragma once +#include +#include + + +namespace Random { + extern std::mt19937 GENERATOR; + + template + T uniform(T from, T to) { + std::uniform_int_distribution rand(from, to); + + return rand(GENERATOR); + } + + template + T uniform_real(T from, T to) { + std::uniform_real_distribution rand(from, to); + + return rand(GENERATOR); + } + + template + T normal(T mean, T stddev) { + std::normal_distribution rand(mean, stddev); + + return rand(GENERATOR); + } + + auto abs(auto i, auto j) { + if(i < j) { + return Random::uniform(i, j); + } else if(j < i) { + return Random::uniform(j, i); + } else { + return i; + } + } + + std::chrono::milliseconds milliseconds(int from, int to); +} diff --git a/src/algos/shiterator.hpp b/src/algos/shiterator.hpp new file mode 100644 index 0000000..077fce7 --- /dev/null +++ b/src/algos/shiterator.hpp @@ -0,0 +1,674 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "algos/point.hpp" +#include "algos/rand.hpp" +#include "dbc.hpp" + +/* + * # What is This Shit? + * + * Announcing the Shape Iterators, or "shiterators" for short. The best shite + * for C++ for-loops since that [one youtube + * video](https://www.youtube.com/watch?v=rX0ItVEVjHc) told everyone to + * recreate SQL databases with structs. You could also say these are Shaw's + * Iterators, but either way they are the _shite_. Or are they shit? You decide. + * Maybe they're "shite"? + * + * A shiterator is a simple generator that converts 2D shapes into a 1D stream + * of x/y coordinates. You give it a matrix, some parameters like start, end, + * etc. and each time you call `next()` you get the next viable x/y coordinate to + * complete the shape. This makes them far superior to _any_ existing for-loop + * technology because shiterators operate _intelligently_ in shapes. Other + * [programming pundits](https://www.youtube.com/watch?v=tD5NrevFtbU) will say + * their 7000 line "easy to maintain" switch statements are better at drawing + * shapes, but they're wrong. My way of making a for-loop do stuff is vastly + * superior because it doesn't use a switch _or_ a virtual function _or_ + * inheritance at all. That means they have to be the _fastest_. Feel free to run + * them 1000 times and bask in the glory of 1 nanosecond difference performance. + * + * It's science and shite. + * + * More importantly, shiterators are simple and easy to use. They're so easy to + * use you _don't even use the 3rd part of the for-loop_. What? You read that right, + * not only have I managed to eliminate _both_ massive horrible to maintain switches, + * and also avoided virtual functions, but I've also _eliminated one entire part + * of the for-loop_. This obviously makes them way faster than other inferior + * three-clause-loop-trash. Just look at this comparison: + * + * ```cpp + * for(it = trash.begin(); it != trash.end(); it++) { + * std::cout << it << std::endl; + * } + * ``` + * + * ```cpp + * for(each_cell it{mat}; it.next();) { + * std::cout << mat[it.y][it.x] << std::endl; + * } + * ``` + * + * Obviously this will outperform _any_ iterator invented in the last 30 years, but the best + * thing about shiterators is their composability and ability to work simultaneously across + * multiple matrices in one loop: + * + * ```cpp + * for(line it{start, end}; it.next();) { + * for(compass neighbor{walls, it.x, it.y}; neighbor.next();) { + * if(walls[neighbor.y][neighbor.x] == 1) { + * wall_update[it.y][it.x] = walls[it.y][it.x] + 10; + * } + * } + * } + * ``` + * + * This code sample (maybe, because I didn't run it) draws a line from + * `start` to `end` then looks at each neighbor on a compass (north, south, east, west) + * at each point to see if it's set to 1. If it is then it copies that cell over to + * another matrix with +10. Why would you need this? Your Wizard just shot a fireball + * down a corridor and you need to see if anything in the path is within 1 square of it. + * + * You _also_ don't even need to use a for-loop. Yes, you can harken back to the old + * days when we did everything RAW inside a Duff's Device between a while-loop for + * that PERFORMANCE because who cares about maintenance? You're a game developer! Tests? + * Don't need a test if it runs fine on Sony Playstation only. Maintenance? You're moving + * on to the next project in two weeks anyway right?! Use that while-loop and a shiterator + * to really help that next guy: + * + * ```cpp + * box it{walls, center_x, center_y, 20}; + * while(it.next()) { + * walls[it.y][it.x] = 1; + * } + * ``` + * + * ## Shiterator "Guarantees" + * + * Just like Rust [guarantees no memory leaks](https://github.com/pop-os/cosmic-comp/issues/1133), + * a shiterator tries to ensure a few things, if it can: + * + * 1. All x/y values will be within the Matrix you give it. The `line` shiterator doesn't though. + * 2. They try to not store anything and only calculate the math necessary to linearlize the shape. + * 3. You can store them and incrementally call next to get the next value. + * 4. You should be able to compose them together on the same Matrix or different matrices of the same dimensions. + * 5. Most of them will only require 1 for-loop, the few that require 2 only do this so you can draw the inside of a shape. `circle` is like this. + * 6. They don't assume any particular classes or require subclassing. As long as the type given enables `mat[y][x]` (row major) access then it'll work. + * 7. The matrix given to a shiterator isn't actually attached to it, so you can use one matrix to setup an iterator, then apply the x/y values to any other matrix of the same dimensions. Great for smart copying and transforming. + * 8. More importantly, shiterators _do not return any values from the matrix_. They only do the math for coordinates and leave it to you to work your matrix. + * + * These shiterators are used all over the game to do map rendering, randomization, drawing, nearly everything that involves a shape. + * + * ## Algorithms I Need + * + * I'm currently looking for a few algorithms, so if you know how to do these let me know: + * + * 1. _Flood fill_ This turns out to be really hard because most algorithms require keeping track of visited cells with a queue, recursion, etc. + * 2. _Random rectangle fill_ I have something that mostly works but it's really only random across each y-axis, then separate y-axes are randomized. + * 3. _Dijkstra Map_ I have a Dijkstra algorithm but it's not in this style yet. Look in `worldbuilder.cpp` for my current implementation. + * 4. _Viewport_ Currently working on this but I need to have a rectangle I can move around as a viewport. + * + * + * ## Usage + * + * Check the `algos/matrix.hpp` for an example if you want to make it more conventient for your own type. + * + * ## Thanks + * + * Special thanks to Amit and hirdrac for their help with the math and for + * giving me the initial idea. hirdrac doesn't want to be held responsible for + * this travesty but he showed me that you can do iteration and _not_ use the + * weird C++ iterators. Amit did a lot to show me how to do these calculations + * without branching. Thanks to you both--and to everyone else--for helping me while I + * stream my development. + * + * ### SERIOUS DISCLAIMER + * + * I am horribly bad at trigonometry and graphics algorithms, so if you've got an idea to improve them + * or find a bug shoot me an email at help@learncodethehardway.com. + */ +namespace shiterator { + using std::vector, std::queue, std::array; + using std::min, std::max, std::floor; + + template + using BaseRow = vector; + + template + using Base = vector>; + + template + inline Base make(size_t width, size_t height) { + Base result(height, BaseRow(width)); + return result; + } + + /* + * Just a quick thing to reset a matrix to a value. + */ + template + inline void assign(MAT &out, VAL new_value) { + for(auto &row : out) { + row.assign(row.size(), new_value); + } + } + + + /* + * Tells you if a coordinate is in bounds of the matrix + * and therefore safe to use. + */ + template + inline bool inbounds(MAT &mat, size_t x, size_t y) { + // since Point.x and Point.y are size_t any negatives are massive + return (y < mat.size()) && (x < mat[0].size()); + } + + /* + * Gives the width of a matrix. Assumes row major (y/x) + * and vector API .size(). + */ + template + inline size_t width(MAT &mat) { + return mat[0].size(); + } + + /* + * Same as shiterator::width but just the height. + */ + template + inline size_t height(MAT &mat) { + return mat.size(); + } + + /* + * These are internal calculations that help + * with keeping track of the next x coordinate. + */ + inline size_t next_x(size_t x, size_t width) { + return (x + 1) * ((x + 1) < width); + } + + /* + * Same as next_x but updates the next y coordinate. + * It uses the fact that when x==0 you have a new + * line so increment y. + */ + inline size_t next_y(size_t x, size_t y) { + return y + (x == 0); + } + + /* + * Figures out if you're at the end of the shape, + * which is usually when y > height. + */ + inline bool at_end(size_t y, size_t height) { + return y < height; + } + + /* + * Determines if you're at the end of a row. + */ + inline bool end_row(size_t x, size_t width) { + return x == width - 1; + } + + /* + * Most basic shiterator. It just goes through + * every cell in the matrix in linear order + * with not tracking of anything else. + */ + template + struct each_cell_t { + size_t x = ~0; + size_t y = ~0; + size_t width = 0; + size_t height = 0; + + each_cell_t(MAT &mat) + { + height = shiterator::height(mat); + width = shiterator::width(mat); + } + + bool next() { + x = next_x(x, width); + y = next_y(x, y); + return at_end(y, height); + } + }; + + /* + * This is just each_cell_t but it sets + * a boolean value `bool row` so you can + * tell when you've reached the end of a + * row. This is mostly used for printing + * out a matrix and similar just drawing the + * whole thing with its boundaries. + */ + template + struct each_row_t { + size_t x = ~0; + size_t y = ~0; + size_t width = 0; + size_t height = 0; + bool row = false; + + each_row_t(MAT &mat) { + height = shiterator::height(mat); + width = shiterator::width(mat); + } + + bool next() { + x = next_x(x, width); + y = next_y(x, y); + row = end_row(x, width); + return at_end(y, height); + } + }; + + /* + * This is a CENTERED box, that will create + * a centered rectangle around a point of a + * certain dimension. This kind of needs a + * rewrite but if you want a rectangle from + * a upper corner then use rectangle_t type. + * + * Passing 1 parameter for the size will make + * a square. + */ + template + struct box_t { + size_t from_x; + size_t from_y; + size_t x = 0; // these are set in constructor + size_t y = 0; // again, no fancy ~ trick needed + size_t left = 0; + size_t top = 0; + size_t right = 0; + size_t bottom = 0; + + box_t(MAT &mat, size_t at_x, size_t at_y, size_t size) : + box_t(mat, at_x, at_y, size, size) { + } + + box_t(MAT &mat, size_t at_x, size_t at_y, size_t width, size_t height) : + from_x(at_x), from_y(at_y) + { + size_t h = shiterator::height(mat); + size_t w = shiterator::width(mat); + + // keeps it from going below zero + // need extra -1 to compensate for the first next() + left = max(from_x, width) - width; + x = left - 1; // must be -1 for next() + // keeps it from going above width + right = min(from_x + width + 1, w); + + // same for these two + top = max(from_y, height) - height; + y = top - (left == 0); + bottom = min(from_y + height + 1, h); + } + + bool next() { + // calc next but allow to go to 0 for next + x = next_x(x, right); + // x will go to 0, which signals new line + y = next_y(x, y); // this must go here + // if x==0 then this moves it to min_x + x = max(x, left); + // and done + + return at_end(y, bottom); + } + + /* + * This was useful for doing quick lighting + * calculations, and I might need to implement + * it in other shiterators. It gives the distance + * to the center from the current x/y. + */ + float distance() { + int dx = from_x - x; + int dy = from_y - y; + + return sqrt((dx * dx) + (dy * dy)); + } + }; + + /* + * Stupid simple compass shape North/South/East/West. + * This comes up a _ton_ when doing searching, flood + * algorithms, collision, etc. Probably not the + * fastest way to do it but good enough. + */ + template + struct compass_t { + size_t x = 0; // these are set in constructor + size_t y = 0; // again, no fancy ~ trick needed + array x_dirs{0, 1, 0, -1}; + array y_dirs{-1, 0, 1, 0}; + size_t max_dirs=0; + size_t dir = ~0; + + compass_t(MAT &mat, size_t x, size_t y) : + x(x), y(y) + { + array x_in{0, 1, 0, -1}; + array y_in{-1, 0, 1, 0}; + + for(size_t i = 0; i < 4; i++) { + int nx = x + x_in[i]; + int ny = y + y_in[i]; + if(shiterator::inbounds(mat, nx, ny)) { + x_dirs[max_dirs] = nx; + y_dirs[max_dirs] = ny; + max_dirs++; + } + } + } + + bool next() { + dir++; + if(dir < max_dirs) { + x = x_dirs[dir]; + y = y_dirs[dir]; + return true; + } else { + return false; + } + } + }; + + /* + * Draws a line from start to end using a algorithm from + * https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm + * No idea if the one I picked is best but it's the one + * that works in the shiterator requirements and produced + * good results. + * + * _WARNING_: This one doesn't check if the start/end are + * within your Matrix, as it's assumed _you_ did that + * already. + */ + struct line_t { + int x; + int y; + int x1; + int y1; + int sx; + int sy; + int dx; + int dy; + int error; + + line_t(Point start, Point end) : + x(start.x), y(start.y), + x1(end.x), y1(end.y) + { + dx = std::abs(x1 - x); + sx = x < x1 ? 1 : -1; + dy = std::abs(y1 - y) * -1; + sy = y < y1 ? 1 : -1; + error = dx + dy; + } + + bool next() { + if(x != x1 || y != y1) { + int e2 = 2 * error; + + if(e2 >= dy) { + error = error + dy; + x = x + sx; + } + + if(e2 <= dx) { + error = error + dx; + y = y + sy; + } + return true; + } else { + return false; + } + } + }; + + /* + * Draws a simple circle using a fairly naive algorithm + * but one that actually worked. So, so, so, so many + * circle drawing algorithms described online don't work + * or are flat wrong. Even the very best I could find + * did overdrawing of multiple lines or simply got the + * math wrong. Keep in mind, _I_ am bad at this trig math + * so if I'm finding errors in your circle drawing then + * you got problems. + * + * This one is real simple, and works. If you got better + * then take the challenge but be ready to get it wrong. + */ + template + struct circle_t { + float center_x; + float center_y; + float radius = 0.0f; + int y = 0; + int dx = 0; + int dy = 0; + int left = 0; + int right = 0; + int top = 0; + int bottom = 0; + int width = 0; + int height = 0; + + circle_t(MAT &mat, Point center, float radius) : + center_x(center.x), center_y(center.y), radius(radius) + { + width = shiterator::width(mat); + height = shiterator::height(mat); + top = max(int(floor(center_y - radius)), 0); + bottom = min(int(floor(center_y + radius)), height - 1); + + y = top; + } + + bool next() { + y++; + if(y <= bottom) { + dy = y - center_y; + dx = floor(sqrt(radius * radius - dy * dy)); + left = max(0, int(center_x) - dx); + right = min(width, int(center_x) + dx + 1); + return true; + } else { + return false; + } + } + }; + + /* + * Basic rectangle shiterator, and like box and rando_rect_t you can + * pass only 1 parameter for size to do a square. + */ + template + struct rectangle_t { + int x; + int y; + int top; + int left; + int width; + int height; + int right; + int bottom; + + rectangle_t(MAT &mat, size_t start_x, size_t start_y, size_t size) : + rectangle_t(mat, start_x, start_y, size, size) { + } + + rectangle_t(MAT &mat, size_t start_x, size_t start_y, size_t width, size_t height) : + top(start_y), + left(start_x), + width(width), + height(height) + { + size_t h = shiterator::height(mat); + size_t w = shiterator::width(mat); + y = start_y - 1; + x = left - 1; // must be -1 for next() + right = min(start_x + width, w); + + y = start_y; + bottom = min(start_y + height, h); + } + + bool next() { + x = next_x(x, right); + y = next_y(x, y); + x = max(x, left); + return at_end(y, bottom); + } + }; + + /* + * Same as rando_rect_t but it uses a centered box. + */ + template + struct rando_box_t { + size_t x; + size_t y; + size_t x_offset; + size_t y_offset; + box_t it; + + rando_box_t(MAT &mat, size_t start_x, size_t start_y, size_t size) : + it{mat, start_x, start_y, size} + { + x_offset = Random::uniform(size_t(0), it.right); + y_offset = Random::uniform(size_t(0), it.bottom); + } + + bool next() { + bool done = it.next(); + x = it.left + ((it.x + x_offset) % it.right); + y = it.top + ((it.y + y_offset) % it.bottom); + return done; + } + }; + + /* + * WIP: This one is used to place entities randomly but + * could be used for effects like random destruction of floors. + * It simply "wraps" the rectangle_t but randomizes the x/y values + * using a random starting point. This makes it random across the + * x-axis but only partially random across the y. + */ + template + struct rando_rect_t { + int x; + int y; + int x_offset; + int y_offset; + rectangle_t it; + + rando_rect_t(MAT &mat, size_t start_x, size_t start_y, size_t size) : + rando_rect_t(mat, start_x, start_y, size, size) { + } + + rando_rect_t(MAT &mat, size_t start_x, size_t start_y, size_t width, size_t height) : + it{mat, start_x, start_y, width, height} + { + x_offset = Random::uniform(0, int(width)); + y_offset = Random::uniform(0, int(height)); + } + + bool next() { + bool done = it.next(); + x = it.left + ((it.x + x_offset) % it.width); + y = it.top + ((it.y + y_offset) % it.height); + return done; + } + }; + + template + struct perimeter_t { + size_t x; + size_t y; + size_t width; + size_t height; + + size_t i = 0; + + std::array starts{{ + {x,y}, {x + width-1, y}, {x + width-1, y + height-1}, {x, y + height-1} + }}; + + std::array ends{{ + {x + width-1, y}, {x + width-1, y + height-1}, {x, y + height-1}, {x,y}, + }}; + + line_t it{starts[i], ends[i]}; + + bool next() { + if(i >= starts.size()) return false; + + if(it.next()) { + x = it.x; + y = it.y; + return true; + } else { + x = 0; + y = 0; + i++; + if(i < starts.size()) { + it = {starts[i], ends[i]}; + return true; + } else { + return false; + } + } + } + }; + + /* + * BROKEN: I'm actually not sure what I'm trying to + * do here yet. + */ + template + struct viewport_t { + Point start; + // this is the point in the map + size_t x; + size_t y; + // this is the point inside the box, start at 0 + size_t view_x = ~0; + size_t view_y = ~0; + // viewport width/height + size_t width; + size_t height; + + viewport_t(MAT &mat, Point start, int max_x, int max_y) : + start(start), + x(start.x-1), + y(start.y-1) + { + width = std::min(size_t(max_x), shiterator::width(mat) - start.x); + height = std::min(size_t(max_y), shiterator::height(mat) - start.y); + fmt::println("viewport_t max_x, max_y {},{} vs matrix {},{}, x={}, y={}", + max_x, max_y, shiterator::width(mat), shiterator::height(mat), x, y); + } + + bool next() { + y = next_y(x, y); + x = next_x(x, width); + view_x = next_x(view_x, width); + view_y = next_y(view_x, view_y); + return at_end(y, height); + } + }; + +} diff --git a/src/algos/simplefsm.hpp b/src/algos/simplefsm.hpp new file mode 100644 index 0000000..4eb5718 --- /dev/null +++ b/src/algos/simplefsm.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#ifndef FSM_DEBUG +#define FSM_STATE(C, S, E, ...) case C::S: S(E, ##__VA_ARGS__); break +#else +static int last_event=-1; +#define FSM_STATE(C, S, E, ...) case C::S: if(last_event != int(E)) { last_event = int(E); fmt::println(">> " #C " " #S " event={}, state={}", int(E), int($state));}; S(E, ##__VA_ARGS__); break +#endif + +template +class DeadSimpleFSM { +protected: + // BUG: don't put this in your class because state() won't work + S $state = S::START; + +public: + template + void event(E event, Types... args); + + void state(S next_state) { +#ifdef FSM_DEBUG + fmt::println("<< STATE: {} -> {}", int($state), int(next_state)); +#endif + $state = next_state; + } + + bool in_state(S state) { + return $state == state; + } +}; diff --git a/src/algos/spatialmap.cpp b/src/algos/spatialmap.cpp new file mode 100644 index 0000000..3ee2a90 --- /dev/null +++ b/src/algos/spatialmap.cpp @@ -0,0 +1,138 @@ +#include "algos/spatialmap.hpp" +#include + +using namespace fmt; + +using DinkyECS::Entity; + +void SpatialMap::insert(Point pos, Entity ent, bool has_collision) { + if(has_collision) { + dbc::check(!occupied(pos), "attempt to insert an entity with collision in space with collision"); + } + + $collision.emplace(pos, CollisionData{ent, has_collision}); +} + +CollisionData SpatialMap::remove(Point pos, Entity ent) { + auto [begin, end] = $collision.equal_range(pos); + for(auto it = begin; it != end; ++it) { + if(it->second.entity == ent) { + // does the it->second go invalid after erase? + auto copy = it->second; + $collision.erase(it); + return copy; + } + } + + dbc::sentinel("failed to find entity to remove"); +} + +void SpatialMap::move(Point from, Point to, Entity ent) { + auto data = remove(from, ent); + insert(to, ent, data.collision); +} + +Entity SpatialMap::occupied_by(Point at) const { + auto [begin, end] = $collision.equal_range(at); + for(auto it = begin; it != end; ++it) { + if(it->second.collision) { + return it->second.entity; + } + } + + return DinkyECS::NONE; +} + +bool SpatialMap::occupied(Point at) const { + return occupied_by(at) != DinkyECS::NONE; +} + +bool SpatialMap::something_there(Point at) const { + return $collision.count(at) > 0; +} + +Entity SpatialMap::get(Point at) const { + dbc::check($collision.contains(at), "attempt to get entity when none there"); + auto [begin, end] = $collision.equal_range(at); + return begin->second.entity; +} + +void SpatialMap::find_neighbor(EntityList &result, Point at, int dy, int dx) const { + // don't bother checking for cells out of bounds + if((dx < 0 && at.x <= 0) || (dy < 0 && at.y <= 0)) { + return; + } + + Point cell = {at.x + dx, at.y + dy}; + + auto entity = find(cell, [&](auto data) { + return data.collision; + }); + + if(entity != DinkyECS::NONE) result.push_back(entity); +} + +FoundEntities SpatialMap::neighbors(Point cell, bool diag) const { + EntityList result; + + // just unroll the loop since we only check four directions + // this also solves the problem that it was detecting that the cell was automatically included as a "neighbor" but it's not + find_neighbor(result, cell, 0, 1); // north + find_neighbor(result, cell, 0, -1); // south + find_neighbor(result, cell, 1, 0); // east + find_neighbor(result, cell, -1, 0); // west + + if(diag) { + find_neighbor(result, cell, 1, -1); // south east + find_neighbor(result, cell, -1, -1); // south west + find_neighbor(result, cell, 1, 1); // north east + find_neighbor(result, cell, -1, 1); // north west + } + + return {!result.empty(), result}; +} + +inline void update_sorted(SortedEntities& sprite_distance, PointEntityMap& table, Point from, int max_dist) { + Point seen{0,0}; + float wiggle = 0.0f; + + for(const auto &rec : table) { + Point sprite = rec.first; + + int inside = (from.x - sprite.x) * (from.x - sprite.x) + + (from.y - sprite.y) * (from.y - sprite.y); + + if(from == sprite || rec.second.collision) { + wiggle = 0.0f; + } else if(sprite == seen) { + wiggle += 0.02f; + } else { + wiggle = 0.0f; + seen = sprite; + } + + if(inside < max_dist) { + sprite_distance.push_back({inside, rec.second.entity, wiggle}); + } + } +} + +Entity SpatialMap::find(Point at, std::function cb) const { + auto [begin, end] = $collision.equal_range(at); + + for(auto it = begin; it != end; ++it) { + if(cb(it->second)) return it->second.entity; + } + + return DinkyECS::NONE; +} + +void SpatialMap::distance_sorted(SortedEntities& sprite_distance, Point from, int max_dist) { + sprite_distance.clear(); + + update_sorted(sprite_distance, $collision, from, max_dist); + + std::sort(sprite_distance.begin(), sprite_distance.end(), [](auto &a, auto &b) { + return a.dist_square > b.dist_square; + }); +} diff --git a/src/algos/spatialmap.hpp b/src/algos/spatialmap.hpp new file mode 100644 index 0000000..1077c37 --- /dev/null +++ b/src/algos/spatialmap.hpp @@ -0,0 +1,49 @@ +#pragma once +#include +#include +#include "game/map.hpp" +#include "algos/dinkyecs.hpp" +#include "algos/point.hpp" + +struct CollisionData { + DinkyECS::Entity entity = DinkyECS::NONE; + bool collision = false; +}; + +struct EntityDistance { + int dist_square=0; + DinkyECS::Entity entity=DinkyECS::NONE; + float wiggle=0.0f; +}; + +// Point's has is in point.hpp +using EntityList = std::vector; +using PointEntityMap = std::unordered_multimap; +using SortedEntities = std::vector; + +struct FoundEntities { + bool found; + EntityList nearby; +}; + +class SpatialMap { + public: + SpatialMap() {} + PointEntityMap $collision; + + void insert(Point pos, DinkyECS::Entity obj, bool has_collision); + void move(Point from, Point to, DinkyECS::Entity ent); + // return value is whether the removed thing has collision + CollisionData remove(Point pos, DinkyECS::Entity entity); + DinkyECS::Entity occupied_by(Point pos) const; + bool occupied(Point pos) const; + bool something_there(Point at) const; + DinkyECS::Entity get(Point at) const; + DinkyECS::Entity find(Point at, std::function cb) const; + void find_neighbor(EntityList &result, Point at, int dy, int dx) const; + + FoundEntities neighbors(Point position, bool diag=false) const; + + void distance_sorted(SortedEntities& sorted_sprites, Point from, int max_distance); + size_t size() { return $collision.size(); } +}; diff --git a/src/algos/stats.cpp b/src/algos/stats.cpp new file mode 100644 index 0000000..52ccc7d --- /dev/null +++ b/src/algos/stats.cpp @@ -0,0 +1,11 @@ +#include "algos/stats.hpp" +#include +#include "dbc.hpp" + +void Stats::dump(std::string msg) +{ + dbc::log($F("{}: sum: {}, sumsq: {}, n: {}, " + "min: {}, max: {}, mean: {}, stddev: {}", + msg, sum, sumsq, n, min, max, mean(), + stddev())); +} diff --git a/src/algos/stats.hpp b/src/algos/stats.hpp new file mode 100644 index 0000000..8c8749e --- /dev/null +++ b/src/algos/stats.hpp @@ -0,0 +1,59 @@ +#pragma once +#include +#include + +struct Stats { + using TimeBullshit = std::chrono::time_point; + + double sum = 0.0; + double sumsq = 0.0; + double n = 0.0; + double min = 0.0; + double max = 0.0; + + inline void reset() { + sum = 0.0; + sumsq = 0.0; + n = 0.0; + min = 0.0; + max = 0.0; + } + + inline double mean() { + return sum / n; + } + + inline double stddev() { + return std::sqrt((sumsq - (sum * sum / n)) / (n - 1)); + } + + inline void sample(double s) { + sum += s; + sumsq += s * s; + + if (n == 0) { + min = s; + max = s; + } else { + if (min > s) min = s; + if (max < s) max = s; + } + + n += 1; + } + + inline TimeBullshit time_start() { + return std::chrono::high_resolution_clock::now(); + } + + inline void sample_time(TimeBullshit start) { + auto end = std::chrono::high_resolution_clock::now(); + auto elapsed = std::chrono::duration(end - start); + + if(elapsed.count() > 0.0) { + sample(1.0/elapsed.count()); + } + } + + void dump(std::string msg=""); +}; diff --git a/src/combat/battle.cpp b/src/combat/battle.cpp new file mode 100644 index 0000000..11f5fda --- /dev/null +++ b/src/combat/battle.cpp @@ -0,0 +1,127 @@ +#include "combat/battle.hpp" + +namespace combat { + void BattleEngine::add_enemy(Combatant enemy) { + $combatants.try_emplace(enemy.entity, enemy); + + if(enemy.is_host) { + dbc::check($host_combat == nullptr, "added the host twice!"); + $host_combat = enemy.combat; + } + } + + bool BattleEngine::player_request(const std::string& request) { + auto action = ai::load_action(request); + bool can_go = player_pending_ap() >= action.cost; + + if(can_go) { + $player_requests.try_emplace(request, action); + } + + return can_go; + } + + void BattleEngine::clear_requests() { + $player_requests.clear(); + } + + void BattleEngine::ap_refresh() { + for(auto& [entity, enemy] : $combatants) { + if(enemy.combat->ap < enemy.combat->max_ap) { + int new_ap = std::min(enemy.combat->max_ap, enemy.combat->ap_delta + enemy.combat->ap); + enemy.combat->ap = new_ap; + } + } + } + + int BattleEngine::player_pending_ap() { + dbc::check($host_combat != nullptr, "didn't set host before checking AP"); + int pending_ap = $host_combat->ap; + + for(auto& [name, action] : $player_requests) { + pending_ap -= action.cost; + } + + return pending_ap; + } + + bool BattleEngine::plan() { + using enum BattleHostState; + + int active = 0; + bool had_host = false; + + for(auto& [entity, enemy] : $combatants) { + //NOTE: this is just for asserting I'm using things right + if(enemy.is_host) had_host = true; + + enemy.ai->update(); + active += enemy.ai->active(); + + if(enemy.ai->active()) { + for(auto& action : enemy.ai->plan.script) { + BattleHostState host_state = not_host; + + if(action.cost > enemy.combat->ap) { + host_state = out_of_ap; + } else if(enemy.is_host) { + host_state = $player_requests.contains(action.name) ? agree : disagree; + } + + if(host_state != out_of_ap) { + enemy.combat->ap -= action.cost; + } + + $pending_actions.emplace_back(enemy, action.name, action.cost, host_state); + } + + dbc::check(enemy.combat->ap >= 0, "enemy's AP went below 0"); + dbc::check(enemy.combat->ap <= enemy.combat->max_ap, "enemy's AP went above max"); + } + } + + dbc::check(had_host, "FAIL, you forgot to set enemy.is_host=true for one entity"); + if($pending_actions.size() > 0) { + std::sort($pending_actions.begin(), $pending_actions.end(), + [](const auto& a, const auto& b) -> bool + { + return a.cost > b.cost; + }); + } + + return active > 0; + } + + std::optional BattleEngine::next() { + if($pending_actions.size() == 0) return std::nullopt; + + auto ba = $pending_actions.back(); + $pending_actions.pop_back(); + return std::make_optional(ba); + } + + void BattleEngine::dump() { + for(auto& [entity, enemy] : $combatants) { + fmt::println("\n\n###### ENTITY #{}", entity); + enemy.ai->dump(); + } + } + + void BattleEngine::set(DinkyECS::Entity entity, const std::string& state, bool setting) { + dbc::check($combatants.contains(entity), "invalid combatant given to BattleEngine"); + auto& action = $combatants.at(entity); + action.ai->set_state(state, setting); + } + + void BattleEngine::set_all(const std::string& state, bool setting) { + for(auto& [ent, action] : $combatants) { + action.ai->set_state(state, setting); + } + } + + Combatant& BattleEngine::get_enemy(DinkyECS::Entity entity) { + dbc::check($combatants.contains(entity), "invalid combatant given to BattleEngine"); + + return $combatants.at(entity); + } +} diff --git a/src/combat/battle.hpp b/src/combat/battle.hpp new file mode 100644 index 0000000..12d83f3 --- /dev/null +++ b/src/combat/battle.hpp @@ -0,0 +1,50 @@ +#pragma once +#include "game/config.hpp" +#include "algos/dinkyecs.hpp" +#include +#include "game/components.hpp" +#include +#include "ai/ai.hpp" + +namespace combat { + + enum class BattleHostState { + not_host = 0, + agree = 1, + disagree = 2, + out_of_ap = 3 + }; + + struct Combatant { + DinkyECS::Entity entity = DinkyECS::NONE; + ai::EntityAI* ai = nullptr; + components::Combat* combat = nullptr; + bool is_host=false; + }; + + struct BattleResult { + Combatant enemy; + std::string wants_to; + int cost; + BattleHostState host_state; + }; + + struct BattleEngine { + std::unordered_map $combatants; + std::vector $pending_actions; + std::unordered_map $player_requests; + components::Combat* $host_combat = nullptr; + + void add_enemy(Combatant ba); + Combatant& get_enemy(DinkyECS::Entity entity); + bool plan(); + std::optional next(); + void dump(); + void set(DinkyECS::Entity entity, const std::string& state, bool setting); + void set_all(const std::string& state, bool setting); + bool player_request(const std::string& request); + int player_pending_ap(); + void clear_requests(); + void ap_refresh(); + }; +} diff --git a/src/combat/combat.cpp b/src/combat/combat.cpp new file mode 100644 index 0000000..042895b --- /dev/null +++ b/src/combat/combat.cpp @@ -0,0 +1,16 @@ +#include "game/components.hpp" +#include "algos/rand.hpp" + +namespace components { + int Combat::attack(Combat &target) { + int attack = Random::uniform(0,1); + int my_dmg = 0; + + if(attack) { + my_dmg = Random::uniform(1, damage); + target.hp -= my_dmg; + } + + return my_dmg; + } +} diff --git a/src/constants.hpp b/src/constants.hpp new file mode 100644 index 0000000..43d4adf --- /dev/null +++ b/src/constants.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +constexpr int INV_SLOTS=16; +constexpr int TEXTURE_WIDTH=256; +constexpr int TEXTURE_HEIGHT=256; +constexpr int RAY_VIEW_WIDTH=1080; +constexpr int RAY_VIEW_HEIGHT=720; +constexpr int SCREEN_WIDTH=1280; +constexpr int SCREEN_HEIGHT=720; +constexpr int RAY_VIEW_X=SCREEN_WIDTH - RAY_VIEW_WIDTH; +constexpr int RAY_VIEW_Y=0; +constexpr int GLOW_LIMIT=220; +constexpr int LIGHT_MULTIPLIER=2.5; +constexpr float AIMED_AT_BRIGHTNESS=0.2f; +constexpr int MAP_TILE_DIM=64; +constexpr int ICONGEN_MAP_TILE_DIM=64; +constexpr int PLAYER_SPRITE_DIR_CORRECTION=270; +constexpr int RENDER_DISTANCE=500; +constexpr int ROOM_SIZE=3; + +constexpr int BOSS_VIEW_WIDTH=1080; +constexpr int BOSS_VIEW_HEIGHT=SCREEN_HEIGHT; +constexpr int BOSS_VIEW_X=SCREEN_WIDTH - BOSS_VIEW_WIDTH; +constexpr int BOSS_VIEW_Y=0; + +constexpr bool VSYNC=true; +constexpr int FRAME_LIMIT=60; +constexpr int NUM_SPRITES=1; +constexpr int MAX_LOG_MESSAGES=17; + + +#ifdef NDEBUG +constexpr bool DEBUG_BUILD=false; +#else +constexpr bool DEBUG_BUILD=true; +#endif + + +////////// copied from roguish + +constexpr int INV_WALL = 0; +constexpr int INV_SPACE = 1; +constexpr int WALL_VALUE = 1; +constexpr int SPACE_VALUE = 0; +constexpr int WALL_PATH_LIMIT = 1000; +constexpr int ROOM_SPACE_VALUE = 1001; +constexpr int DOOR_VALUE = 1002; +constexpr int DEAD_END_VALUE = 1003; +constexpr int WALL_LIGHT_LEVEL = 3; +constexpr int WORLDBUILD_DIVISION = 4; +constexpr int WORLDBUILD_SHRINK = 2; +constexpr int WORLDBUILD_MAX_PATH = 200; +constexpr int UI_FONT_SIZE=20; +constexpr int BASE_MAP_FONT_SIZE=80; +constexpr int GAME_MAP_PIXEL_POS = 600; +constexpr int MAX_FONT_SIZE = 140; +constexpr int MIN_FONT_SIZE = 20; + +constexpr float PERCENT = 0.01f; + +constexpr int STATUS_UI_X = 0; +constexpr int STATUS_UI_Y = 0; +constexpr int STATUS_UI_WIDTH = SCREEN_WIDTH - RAY_VIEW_WIDTH; +constexpr int STATUS_UI_HEIGHT = SCREEN_HEIGHT; + +constexpr int INITIAL_MAP_W = 21; +constexpr int INITIAL_MAP_H = 21; + +constexpr float DEFAULT_ROTATE=0.5f; + +// for the panels/renderer +constexpr wchar_t BG_TILE = L'â–ˆ'; +constexpr wchar_t UI_BASE_CHAR = L'â–ˆ'; +constexpr int BG_BOX_OFFSET=5; +constexpr const char *FONT_FILE_NAME="assets/text.otf"; + +constexpr std::array COMPASS{ + // L"E", L"SE", L"S", L"SW", L"W", L"NW", L"N", L"NE" + L"\u2192", L"\u2198", L"\uffec", L"\u21d9", L"\u2190", L"\u2196", L"\uffea", L"\u21d7" }; diff --git a/src/dbc.cpp b/src/dbc.cpp new file mode 100644 index 0000000..be045c9 --- /dev/null +++ b/src/dbc.cpp @@ -0,0 +1,47 @@ +#include "dbc.hpp" +#include + +void dbc::log(const string &message, const std::source_location location) { + std::cout << '[' << location.file_name() << ':' + << location.line() << "|" + << location.function_name() << "] " + << message << std::endl; +} + +void dbc::sentinel(const string &message, const std::source_location location) { + string err = $F("[SENTINEL!] {}", message); + dbc::log(err, location); + throw dbc::SentinelError{err}; +} + +void dbc::pre(const string &message, bool test, const std::source_location location) { + if(!test) { + string err = $F("[PRE!] {}", message); + dbc::log(err, location); + throw dbc::PreCondError{err}; + } +} + +void dbc::pre(const string &message, std::function tester, const std::source_location location) { + dbc::pre(message, tester(), location); +} + +void dbc::post(const string &message, bool test, const std::source_location location) { + if(!test) { + string err = $F("[POST!] {}", message); + dbc::log(err, location); + throw dbc::PostCondError{err}; + } +} + +void dbc::post(const string &message, std::function tester, const std::source_location location) { + dbc::post(message, tester(), location); +} + +void dbc::check(bool test, const string &message, const std::source_location location) { + if(!test) { + string err = $F("[CHECK!] {}\n", message); + dbc::log(err, location); + throw dbc::CheckError{err}; + } +} diff --git a/src/dbc.hpp b/src/dbc.hpp new file mode 100644 index 0000000..b2d054a --- /dev/null +++ b/src/dbc.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +// AKA the Fuckit macro +#define $F(FMT, ...) fmt::format(FMT, ##__VA_ARGS__) + +namespace dbc { + using std::string; + + class Error { + public: + const string message; + Error(string m) : message{m} {} + Error(const char *m) : message{m} {} + }; + + class CheckError : public Error {}; + class SentinelError : public Error {}; + class PreCondError : public Error {}; + class PostCondError : public Error {}; + + void log(const string &message, + const std::source_location location = + std::source_location::current()); + + [[noreturn]] void sentinel(const string &message, + const std::source_location location = + std::source_location::current()); + + void pre(const string &message, bool test, + const std::source_location location = + std::source_location::current()); + + void pre(const string &message, std::function tester, + const std::source_location location = + std::source_location::current()); + + void post(const string &message, bool test, + const std::source_location location = + std::source_location::current()); + + void post(const string &message, std::function tester, + const std::source_location location = + std::source_location::current()); + + void check(bool test, const string &message, + const std::source_location location = + std::source_location::current()); +} diff --git a/src/events.hpp b/src/events.hpp new file mode 100644 index 0000000..55efbb3 --- /dev/null +++ b/src/events.hpp @@ -0,0 +1,49 @@ +#pragma once + +namespace game { + enum Event { + AIM_CLICK=__LINE__, + ANIMATION_END=__LINE__, + ANIMATION_START=__LINE__, + ATTACK=__LINE__, + BOSS_END=__LINE__, + BOSS_START=__LINE__, + CLOSE=__LINE__, + COMBAT=__LINE__, + COMBAT_START=__LINE__, + COMBAT_STOP=__LINE__, + DEATH=__LINE__, + ENTITY_SPAWN=__LINE__, + HP_STATUS=__LINE__, + INV_SELECT=__LINE__, + KEY_PRESS=__LINE__, + LOOT_CLOSE=__LINE__, + LOOT_CONTAINER=__LINE__, + LOOT_ITEM=__LINE__, + LOOT_OPEN=__LINE__, + LOOT_SELECT=__LINE__, + MAP_OPEN=__LINE__, + MOUSE_CLICK=__LINE__, + MOUSE_DRAG=__LINE__, + MOUSE_DRAG_START=__LINE__, + MOUSE_DROP=__LINE__, + MOUSE_MOVE=__LINE__, + MOVE_BACK=__LINE__, + MOVE_FORWARD=__LINE__, + MOVE_LEFT=__LINE__, + MOVE_RIGHT=__LINE__, + NEW_RITUAL=__LINE__, + NOOP=__LINE__, + NO_NEIGHBORS=__LINE__, + QUIT=__LINE__, + ROTATE_LEFT=__LINE__, + ROTATE_RIGHT=__LINE__, + STAIRS_DOWN=__LINE__, + STAIRS_UP=__LINE__, + START=__LINE__, + TICK=__LINE__, + TRAP=__LINE__, + UPDATE_SPRITE=__LINE__, + USE_ITEM=__LINE__, + }; +} diff --git a/src/game/autowalker.cpp b/src/game/autowalker.cpp new file mode 100644 index 0000000..6ab5450 --- /dev/null +++ b/src/game/autowalker.cpp @@ -0,0 +1,420 @@ +#include "game/autowalker.hpp" +#include "ai/ai_debug.hpp" +#include "game/level.hpp" +#include "game/systems.hpp" + +struct InventoryStats { + int healing = 0; + int other = 0; +}; + +template +int number_left() { + int count = 0; + auto world = GameDB::current_world(); + auto player = GameDB::the_player(); + + world->query( + [&](const auto ent, auto&, auto&) { + if(ent != player) { + count++; + } + }); + + return count; +} + +template +Pathing compute_paths() { + auto& level = GameDB::current_level(); + auto walls_copy = level.map->$walls; + + Pathing paths{matrix::width(walls_copy), matrix::height(walls_copy)}; + + System::multi_path(level, paths, walls_copy); + + return paths; +} + +DinkyECS::Entity Autowalker::camera_aim() { + auto& level = GameDB::current_level(); + auto player_pos = GameDB::player_position(); + + // what happens if there's two things at that spot + if(level.collision->something_there(player_pos.aiming_at)) { + return level.collision->get(player_pos.aiming_at); + } else { + return DinkyECS::NONE; + } +} + +void Autowalker::log(std::wstring msg) { + fsm.$map_ui.log(msg); +} + +void Autowalker::status(std::wstring msg) { + fsm.$main_ui.$overlay_ui.show_text("bottom", msg); +} + +void Autowalker::close_status() { + fsm.$main_ui.$overlay_ui.close_text("bottom"); +} + +Pathing Autowalker::path_to_enemies() { + return compute_paths(); +} + +Pathing Autowalker::path_to_items() { + return compute_paths(); +} + +void Autowalker::handle_window_events() { + fsm.$window.handleEvents( + [&](const sf::Event::KeyPressed &) { + fsm.autowalking = false; + close_status(); + log(L"Aborting autowalk."); + }, + [&](const sf::Event::MouseButtonPressed &) { + fsm.autowalking = false; + close_status(); + log(L"Aborting autowalk."); + } + ); +} + +void Autowalker::process_combat() { + while(fsm.in_state(gui::State::IN_COMBAT) + || fsm.in_state(gui::State::ATTACKING)) + { + if(fsm.in_state(gui::State::ATTACKING)) { + send_event(game::Event::TICK); + } else { + send_event(game::Event::ATTACK); + } + } +} + +void Autowalker::path_fail(const std::string& msg, Matrix& bad_paths, Point pos) { + dbc::log(msg); + status(L"PATH FAIL"); + matrix::dump("MOVE FAIL PATHS", bad_paths, pos.x, pos.y); + log(L"Autowalk failed to find a path."); + send_event(game::Event::BOSS_START); +} + +bool Autowalker::path_player(Pathing& paths, Point& target_out) { + auto& level = GameDB::current_level(); + auto found = paths.find_path(target_out, PATHING_TOWARD, false); + + if(found == PathingResult::FAIL) { + // failed to find a linear path, try diagonal + if(paths.find_path(target_out, PATHING_TOWARD, true) == PathingResult::FAIL) { + path_fail("random_walk", paths.$paths, target_out); + return false; + } + } + + if(!level.map->can_move(target_out)) { + path_fail("level_map->can_move", paths.$paths, target_out); + return false; + } + + return true; +} + +void Autowalker::rotate_player(Point target) { + auto &player = GameDB::player_position(); + + if(target == player.location) { + dbc::log("player stuck at a locatoin"); + fsm.autowalking = false; + return; + } + + auto dir = System::shortest_rotate(player.location, player.aiming_at, target); + + for(int i = 0; player.aiming_at != target; i++) { + if(i > 10) { + dbc::log("HIT OVER ROTATE BUG!"); + break; + } + + send_event(dir); + + while(fsm.in_state(gui::State::ROTATING) || + fsm.in_state(gui::State::COMBAT_ROTATE)) + { + send_event(game::Event::TICK); + } + } + + fsm.autowalking = player.aiming_at == target; +} + +void Autowalker::update_state(ai::EntityAI& player_ai) { + int enemy_count = number_left(); + int item_count = number_left(); + + player_ai.set_state("no_more_enemies", enemy_count == 0); + player_ai.set_state("no_more_items", item_count == 0); + + player_ai.set_state("enemy_found", found_enemy()); + player_ai.set_state("health_good", player_health_good()); + + player_ai.set_state("in_combat", + fsm.in_state(gui::State::IN_COMBAT) || + fsm.in_state(gui::State::ATTACKING)); + + auto inv = player_item_count(); + player_ai.set_state("have_item", inv.other > 0 || inv.healing > 0); + player_ai.set_state("have_healing", inv.healing > 0); + + player_ai.update(); +} + +void Autowalker::handle_player_walk(ai::State& start, ai::State& goal) { + ai::EntityAI player_ai("Host::actions", start, goal); + update_state(player_ai); + auto level = GameDB::current_level(); + + if(player_ai.wants_to("find_enemy")) { + status(L"FINDING ENEMY"); + auto paths = path_to_enemies(); + process_move(paths, [&](auto target) -> bool { + return level.collision->occupied(target); + }); + face_enemy(); + } else if(player_ai.wants_to("kill_enemy")) { + status(L"KILLING ENEMY"); + + if(fsm.in_state(gui::State::IN_COMBAT)) { + if(face_enemy()) { + process_combat(); + } + } + } else if(player_ai.wants_to("use_healing")) { + status(L"USING HEALING"); + player_use_healing(); + } else if(player_ai.wants_to("collect_items") || player_ai.wants_to("find_healing")) { + fmt::println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"); + status(player_ai.wants_to("collect_items") ? L"COLLECTING ITEMS" : L"FIND HEALING"); + player_ai.dump(); + + auto paths = path_to_items(); + + bool found_it = process_move(paths, [&](auto target) -> bool { + if(!level.collision->something_there(target)) return false; + + auto entity = level.collision->get(target); + return level.world->has(entity); + }); + + if(found_it) pickup_item(); + fmt::println("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"); + } else if(!player_ai.active()) { + close_status(); + log(L"FINAL ACTION! Autowalk done."); + fsm.autowalking = false; + } else { + close_status(); + dbc::log($F("Unknown action: {}", player_ai.to_string())); + } +} + +void Autowalker::open_map() { + if(!map_opened_once) { + if(!fsm.$map_open) { + send_event(game::Event::MAP_OPEN); + map_opened_once = true; + } + } +} + +void Autowalker::close_map() { + if(fsm.$map_open) { + send_event(game::Event::MAP_OPEN); + } +} + +void Autowalker::autowalk() { + handle_window_events(); + if(!fsm.autowalking) { + close_status(); + return; + } + + open_map(); + face_enemy(); + + int move_attempts = 0; + + auto start = ai::load_state("Host::initial_state"); + auto goal = ai::load_state("Host::final_state"); + + do { + handle_window_events(); + handle_player_walk(start, goal); + + close_map(); + + move_attempts++; + } while(move_attempts < 100 && fsm.autowalking); +} + +bool Autowalker::process_move(Pathing& paths, std::function is_that_it) { + // target has to start at the player location then... + auto target_out = GameDB::player_position().location; + + // ... target gets modified as an out parameter to find the path + if(!path_player(paths, target_out)) { + close_status(); + log(L"No paths found, aborting autowalk."); + return false; + } + + if(rayview->aiming_at != target_out) rotate_player(target_out); + + bool found_it = is_that_it(target_out); + + if(!found_it) { + send_event(game::Event::MOVE_FORWARD); + while(fsm.in_state(gui::State::MOVING)) send_event(game::Event::TICK); + } + + return found_it; +} + +bool Autowalker::found_enemy() { + auto& level = GameDB::current_level(); + auto player = GameDB::player_position(); + + for(matrix::compass it{level.map->$walls, player.location.x, player.location.y}; it.next();) { + Point aim{it.x, it.y}; + auto aimed_ent = level.collision->occupied_by(player.aiming_at); + if(aim != player.aiming_at || aimed_ent == DinkyECS::NONE) continue; + + if(level.world->has(aimed_ent)) return true; + } + + return false; +} + +bool Autowalker::found_item() { + auto world = GameDB::current_world(); + auto aimed_at = camera_aim(); + return aimed_at != DinkyECS::NONE && world->has(aimed_at); +} + +void Autowalker::send_event(game::Event ev, std::any data) { + fsm.event(ev, data); + fsm.render(); + fsm.handle_world_events(); +} + +bool Autowalker::player_health_good() { + auto world = GameDB::current_world(); + auto player = GameDB::the_player(); + auto combat = world->get(player); + float health = float(combat.hp) / float(combat.max_hp); + return health > 0.5f; +} + +InventoryStats Autowalker::player_item_count() { + InventoryStats stats; + auto& level = GameDB::current_level(); + auto& inventory = level.world->get(level.player); + + if(inventory.has("pocket_r")) { + stats.other += 1; + stats.healing += 1; + } + + if(inventory.has("pocket_l")) { + stats.other += 1; + stats.healing += 1; + } + + return stats; +} + +void Autowalker::player_use_healing() { + auto& level = GameDB::current_level(); + auto& inventory = level.world->get(level.player); + + if(inventory.has("pocket_r")) { + auto gui_id = fsm.$status_ui.$gui.entity("pocket_r"); + send_event(game::Event::USE_ITEM, gui_id); + } + + if(inventory.has("pocket_l")) { + auto gui_id = fsm.$status_ui.$gui.entity("pocket_l"); + send_event(game::Event::USE_ITEM, gui_id); + } +} + +void Autowalker::start_autowalk() { + fsm.autowalking = true; +} + +void Autowalker::face_target(Point target) { + if(rayview->aiming_at != target) rotate_player(target); +} + +bool Autowalker::face_enemy() { + auto& level = GameDB::current_level(); + auto player_at = GameDB::player_position(); + + auto [found, neighbors] = level.collision->neighbors(player_at.location, true); + + if(found) { + auto enemy_pos = level.world->get(neighbors[0]); + face_target(enemy_pos.location); + } else { + dbc::log("No enemies nearby, moving on."); + } + + return found; +} + +void Autowalker::click_inventory(const std::string& name, guecs::Modifiers mods) { + auto& cell = fsm.$status_ui.$gui.cell_for(name); + fsm.$status_ui.mouse(cell.mid_x, cell.mid_y, mods); + fsm.handle_world_events(); +} + +void Autowalker::pocket_potion(GameDB::Level &level) { + auto& inventory = level.world->get(level.player); + + if(inventory.has("pocket_r") && inventory.has("pocket_l")) { + player_use_healing(); + } + + send_event(game::Event::AIM_CLICK); + + if(inventory.has("pocket_r")) { + click_inventory("pocket_l", {1 << guecs::ModBit::left}); + } else { + click_inventory("pocket_r", {1 << guecs::ModBit::left}); + } +} + +void Autowalker::pickup_item() { + auto& level = GameDB::current_level(); + auto& player_pos = GameDB::player_position(); + auto collision = level.collision; + + if(collision->something_there(player_pos.aiming_at)) { + auto entity = collision->get(player_pos.aiming_at); + fmt::println("AIMING AT entity {} @ {},{}", + entity, player_pos.aiming_at.x, player_pos.aiming_at.y); + + if(level.world->has(entity)) { + pocket_potion(level); + status(L"A POTION"); + } else { + send_event(game::Event::AIM_CLICK); + status(L"I DON'T KNOW"); + } + } +} diff --git a/src/game/autowalker.hpp b/src/game/autowalker.hpp new file mode 100644 index 0000000..8fb405b --- /dev/null +++ b/src/game/autowalker.hpp @@ -0,0 +1,51 @@ +#pragma once +#include "ai/ai.hpp" +#include "gui/fsm.hpp" +#include + +struct InventoryStats; + +struct Autowalker { + int enemy_count = 0; + int item_count = 0; + int device_count = 0; + bool map_opened_once = false; + gui::FSM& fsm; + std::shared_ptr rayview; + + Autowalker(gui::FSM& fsm) + : fsm(fsm), rayview(fsm.$main_ui.$rayview) {} + + void autowalk(); + void start_autowalk(); + void open_map(); + void close_map(); + bool found_enemy(); + bool found_item(); + + void handle_window_events(); + void handle_player_walk(ai::State& start, ai::State& goal); + + void send_event(game::Event ev, std::any data={}); + void process_combat(); + bool process_move(Pathing& paths, std::function cb); + bool path_player(Pathing& paths, Point &target_out); + void path_fail(const std::string& msg, Matrix& bad_paths, Point pos); + void rotate_player(Point target); + void log(std::wstring msg); + void status(std::wstring msg); + void close_status(); + bool player_health_good(); + void player_use_healing(); + InventoryStats player_item_count(); + void update_state(ai::EntityAI& player_ai); + DinkyECS::Entity camera_aim(); + + Pathing path_to_enemies(); + Pathing path_to_items(); + void face_target(Point target); + bool face_enemy(); + void pickup_item(); + void pocket_potion(GameDB::Level &level); + void click_inventory(const std::string& name, guecs::Modifiers mods); +}; diff --git a/src/game/components.cpp b/src/game/components.cpp new file mode 100644 index 0000000..42304fe --- /dev/null +++ b/src/game/components.cpp @@ -0,0 +1,36 @@ +#include "game/components.hpp" + +#include "algos/point.hpp" + +namespace components { + static ComponentMap MAP; + static bool MAP_configured = false; + + void configure_entity(DinkyECS::World& world, DinkyECS::Entity ent, json& data) { + for (auto &i : data) { + dbc::check(i.contains("_type") && i["_type"].is_string(), $F("component has no _type: {}", data.dump())); + dbc::check(MAP.contains(i["_type"]), $F("MAP doesn't have type {}", std::string(i["_type"]))); + MAP.at(i["_type"])(world, ent, i); + } + } + + void init() { + if(!MAP_configured) { + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + components::enroll(MAP); + MAP_configured = true; + } + } +} diff --git a/src/game/components.hpp b/src/game/components.hpp new file mode 100644 index 0000000..de49c35 --- /dev/null +++ b/src/game/components.hpp @@ -0,0 +1,193 @@ +#pragma once +#include "game/config.hpp" +#include "constants.hpp" +#include "algos/dinkyecs.hpp" +#include "algos/point.hpp" +#include +#include +#include +#include +#include +#include +#include +#include "game/json_mods.hpp" +#include "ai/goap.hpp" +#include + +namespace combat { + enum class BattleHostState; +} + +namespace components { + using std::string; + using namespace nlohmann; + + struct CombatResult { + DinkyECS::Entity attacker; + combat::BattleHostState host_state; + int player_did = 0; + int enemy_did = 0; + }; + + struct InventoryItem { + int count; + json data; + }; + + struct SpriteEffect { + int frames; + std::shared_ptr effect; + }; + + struct Temporary { + bool is = true; + }; + + struct Collision { + bool has = true; + }; + + struct Position { + Point location{0,0}; + Point aiming_at{0,0}; + }; + + struct Motion { + int dx; + int dy; + bool random=false; + }; + + struct Tile { + wchar_t display; + std::string foreground; + std::string background; + }; + + struct GameConfig { + settings::Config game; + settings::Config enemies; + settings::Config items; + settings::Config tiles; + settings::Config devices; + }; + + struct Personality { + int hearing_distance = 10; + bool tough = true; + }; + + struct EnemyConfig { + std::string ai_script; + std::string ai_start_name; + std::string ai_goal_name; + }; + + struct Curative { + int hp = 10; + }; + + struct Sprite { + string name; + float scale; + }; + + struct AnimatedScene { + std::string background; + std::vector layout; + json actors; + json fixtures; + }; + + struct Storyboard { + std::string image; + std::string audio; + std::vector layout; + std::vector> beats; + }; + + struct Combat { + int hp; + int max_hp; + int ap_delta; + int max_ap; + int damage; + + // everyone starts at 0 but ap_delta is added each round + int ap = 0; + + /* NOTE: This is used to _mark_ entities as dead, to detect ones that have just died. Don't make attack automatically set it.*/ + bool dead = false; + + int attack(Combat &target); + }; + + struct LightSource { + int strength = 0; + float radius = 1.0f; + }; + + struct Device { + json config; + std::vector events; + }; + + struct Sound { + std::string attack; + std::string death; + }; + + struct Player { + DinkyECS::Entity entity; + }; + + template struct NameOf; + + using ReflFuncSignature = std::function; + + using ComponentMap = std::unordered_map; + + ENROLL_COMPONENT(Tile, display, foreground, background); + ENROLL_COMPONENT(AnimatedScene, background, layout, actors, fixtures); + ENROLL_COMPONENT(Sprite, name, scale); + ENROLL_COMPONENT(Curative, hp); + ENROLL_COMPONENT(LightSource, strength, radius); + ENROLL_COMPONENT(Position, location.x, location.y); + ENROLL_COMPONENT(EnemyConfig, ai_script, ai_start_name, ai_goal_name); + ENROLL_COMPONENT(Personality, hearing_distance, tough); + ENROLL_COMPONENT(Motion, dx, dy, random); + ENROLL_COMPONENT(Combat, hp, max_hp, ap_delta, max_ap, damage, dead); + ENROLL_COMPONENT(Device, config, events); + ENROLL_COMPONENT(Storyboard, image, audio, layout, beats); + ENROLL_COMPONENT(Sound, attack, death); + ENROLL_COMPONENT(Collision, has); + + template COMPONENT convert(nlohmann::json &data) { + COMPONENT result; + from_json(data, result); + return result; + } + + template COMPONENT get(nlohmann::json &data) { + for (auto &i : data["components"]) { + if(i["_type"] == NameOf::name) { + return convert(i); + } + } + + return {}; + } + + template void enroll(ComponentMap &m) { + m[NameOf::name] = [](DinkyECS::World& world, DinkyECS::Entity ent, nlohmann::json &j) { + COMPONENT c; + from_json(j, c); + world.set(ent, c); + }; + } + + void init(); + + void configure_entity(DinkyECS::World& world, DinkyECS::Entity ent, json& data); + +} diff --git a/src/game/config.cpp b/src/game/config.cpp new file mode 100644 index 0000000..a666051 --- /dev/null +++ b/src/game/config.cpp @@ -0,0 +1,68 @@ +#include "game/config.hpp" +#include "dbc.hpp" +#include + +namespace settings { + using nlohmann::json; + + std::filesystem::path Config::BASE_DIR{"."}; + + Config::Config(const std::string src_path) : $src_path(src_path) { + auto path_to = Config::path_to($src_path); + dbc::check(std::filesystem::exists(path_to), + $F("requested config file {} doesn't exist", path_to.string())); + std::ifstream infile(path_to); + $config = json::parse(infile); + } + + nlohmann::json &Config::operator[](size_t key) { + return $config[key]; + } + + json &Config::operator[](const std::string &key) { + dbc::check($config.contains(key), $F("ERROR in config, key {} doesn't exist.", key)); + return $config[key]; + } + + std::wstring Config::wstring(const std::string main_key, const std::string sub_key) { + dbc::check($config.contains(main_key), + $F("ERROR wstring main/key in config, main_key {} doesn't exist.", main_key)); + dbc::check($config[main_key].contains(sub_key), + $F("ERROR wstring in config, main_key/key {}/{} doesn't exist.", main_key, sub_key)); + + const std::string& str_val = $config[main_key][sub_key]; + std::wstring_convert> $converter; + return $converter.from_bytes(str_val); + } + + std::vector Config::keys() { + std::vector the_fucking_keys; + + for(auto& [key, value] : $config.items()) { + the_fucking_keys.push_back(key); + } + + return the_fucking_keys; + } + + void Config::set_base_dir(const char *optarg) { + Config::BASE_DIR.assign(optarg); + } + + std::filesystem::path Config::path_to(const std::string& path) { + return Config::BASE_DIR / path; + } + + Config get(const std::string& name) { + if(name.ends_with(".json")) { + return {name}; + } else { + auto path = Config::BASE_DIR / fmt::format("assets/{}.json", name); + + dbc::check(std::filesystem::exists(path), + $F("config file {} does not exist", path.string())); + + return {path.string()}; + } + } +} diff --git a/src/game/config.hpp b/src/game/config.hpp new file mode 100644 index 0000000..2512e3d --- /dev/null +++ b/src/game/config.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include +#include + +namespace settings { + struct Config { + static std::filesystem::path BASE_DIR; + nlohmann::json $config; + std::string $src_path; + + Config(const std::string src_path); + + Config(nlohmann::json config, std::string src_path) + : $config(config), $src_path(src_path) {} + + nlohmann::json &operator[](size_t); + nlohmann::json &operator[](const std::string &key); + nlohmann::json &json() { return $config; }; + std::wstring wstring(const std::string main_key, const std::string sub_key); + std::vector keys(); + + static void set_base_dir(const char *optarg); + static std::filesystem::path path_to(const std::string& path); + }; + + Config get(const std::string &name); +} diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp new file mode 100644 index 0000000..cc3edf4 --- /dev/null +++ b/src/game/inventory.cpp @@ -0,0 +1,99 @@ +#include "game/inventory.hpp" + +namespace inventory { + bool Model::add(const std::string in_slot, DinkyECS::Entity ent) { + // NOTE: for the C++ die hards, copy the in_slot on purpose to avoid dangling reference + if(by_slot.contains(in_slot) || by_entity.contains(ent)) return false; + + by_entity.insert_or_assign(ent, in_slot); + by_slot.insert_or_assign(in_slot, ent); + + invariant(); + + return true; + } + + const std::string& Model::get(DinkyECS::Entity ent) { + return by_entity.at(ent); + } + + DinkyECS::Entity Model::get(const std::string& slot) { + return by_slot.at(slot); + } + + bool Model::has(DinkyECS::Entity ent) { + return by_entity.contains(ent); + } + + bool Model::has(const std::string& slot) { + return by_slot.contains(slot); + } + + void Model::remove(DinkyECS::Entity ent) { + dbc::check(by_entity.contains(ent), "attempt to remove entity that isn't in by_entity"); + // NOTE: this was a reference but that caused corruption, just copy + auto slot = by_entity.at(ent); + + dbc::log($F("removing entity {} and slot {}", ent, slot)); + dbc::check(by_slot.contains(slot), "entity is in by_entity but the slot is not in by_slot"); + + // NOTE: you have to erase the entity after the slot or else you get corruption + by_slot.erase(slot); + by_entity.erase(ent); + + invariant(); + } + + void Model::invariant() { + for(auto& [slot, ent] : by_slot) { + dbc::check(by_entity.contains(ent), + $F("entity {} in by_slot isn't in by_entity?", ent)); + dbc::check(by_entity.at(ent) == slot, + $F("mismatched slot {} in by_slot doesn't match entity {}", slot, ent)); + } + + for(auto& [ent, slot] : by_entity) { + dbc::check(by_slot.contains(slot), + $F("slot {} in by_entity isn't in by_slot?", ent)); + dbc::check(by_slot.at(slot) == ent, + $F("mismatched entity {} in by_entity doesn't match entity {}", ent, slot)); + } + + dbc::check(by_slot.size() == by_entity.size(), "by_slot and by_entity have differing sizes"); + } + + void Model::dump() { + invariant(); + fmt::println("INVENTORY has {} slots, sizes equal? {}, contents:", + by_entity.size(), by_entity.size() == by_slot.size()); + + for(auto [slot, ent] : by_slot) { + fmt::println("slot={}, ent={}, both={}, equal={}", + slot, ent, by_entity.contains(ent), by_entity.at(ent) == slot); + } + } + + void Model::swap(DinkyECS::Entity a_ent, DinkyECS::Entity b_ent) { + dbc::check(by_entity.contains(a_ent), "a_entity not in inventory"); + dbc::check(by_entity.contains(b_ent), "b_entity not in inventory"); + + if(a_ent == b_ent) return; + + auto a_slot = get(a_ent); + auto b_slot = get(b_ent); + + dbc::check(a_slot != b_slot, "somehow I got two different entities but they gave the same slot?"); + + by_slot.insert_or_assign(a_slot, b_ent); + by_entity.insert_or_assign(b_ent, a_slot); + + by_slot.insert_or_assign(b_slot, a_ent); + by_entity.insert_or_assign(a_ent, b_slot); + } + + size_t Model::count() { + dbc::check(by_slot.size() == by_entity.size(), "entity and slot maps have different sizes"); + + return by_entity.size(); + } +} diff --git a/src/game/inventory.hpp b/src/game/inventory.hpp new file mode 100644 index 0000000..1a832cf --- /dev/null +++ b/src/game/inventory.hpp @@ -0,0 +1,25 @@ +#pragma once +#include "algos/dinkyecs.hpp" +#include + +// BUG: this should have a bool for "permanent" or "constant" so that +// everything working with it knows to do the make_constant/not_constant +// dance when using it. Idea is the System:: ops for this would get it +// and then look at the bool and add the constant ops as needed. +namespace inventory { + struct Model { + std::unordered_map by_slot; + std::unordered_map by_entity; + + bool add(const std::string in_slot, DinkyECS::Entity ent); + const std::string& get(DinkyECS::Entity ent); + DinkyECS::Entity get(const std::string& slot); + bool has(DinkyECS::Entity ent); + bool has(const std::string& slot); + void remove(DinkyECS::Entity ent); + void invariant(); + void dump(); + void swap(DinkyECS::Entity a_ent, DinkyECS::Entity b_ent); + size_t count(); + }; +} diff --git a/src/game/json_mods.hpp b/src/game/json_mods.hpp new file mode 100644 index 0000000..e582af2 --- /dev/null +++ b/src/game/json_mods.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include + +#define ENROLL_COMPONENT(COMPONENT, ...) \ + NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(COMPONENT, __VA_ARGS__); \ + template <> struct NameOf { \ + static constexpr const char *name = #COMPONENT; \ + }; + +// partial specialization (full specialization works too) +namespace nlohmann { + template + struct adl_serializer> { + static void to_json(json& j, const std::optional& opt) { + if (opt == std::nullopt) { + j = nullptr; + } else { + j = *opt; // this will call adl_serializer::to_json which will + // find the free function to_json in T's namespace! + } + } + + static void from_json(const json& j, std::optional& opt) { + if (j.is_null() || j == false) { + opt = std::nullopt; + } else { + opt = std::make_optional(j.template get()); + // same as above, but with adl_serializer::from_json + } + } + }; + + template<> + struct adl_serializer { + static void to_json(json& j, const std::chrono::milliseconds& opt) { + j = opt.count(); + } + + static void from_json(const json& j, std::chrono::milliseconds& opt) { + opt = std::chrono::milliseconds{int(j)}; + } + }; +} diff --git a/src/game/level.cpp b/src/game/level.cpp new file mode 100644 index 0000000..5f2992f --- /dev/null +++ b/src/game/level.cpp @@ -0,0 +1,134 @@ +#include "game/level.hpp" +#include "game/components.hpp" +#include "game/worldbuilder.hpp" +#include "constants.hpp" +#include "game/systems.hpp" +#include "graphics/textures.hpp" +#include + +using lighting::LightRender; +using std::shared_ptr, std::make_shared; +using namespace components; + +struct LevelScaling { + int map_width=INITIAL_MAP_W; + int map_height=INITIAL_MAP_H; +}; + +namespace GameDB { + using std::shared_ptr, std::string, std::make_shared; + + struct LevelDB { + std::list levels; + Level* current_level = nullptr; + int level_count = 0; + }; + + shared_ptr LDB = nullptr; + bool initialized = false; + + LevelScaling scale_level() { + return { + INITIAL_MAP_W + int(LDB->level_count * 2), + INITIAL_MAP_H + int(LDB->level_count * 2) + }; + } + + shared_ptr clone_load_world(shared_ptr prev_world) { + auto world = make_shared(); + + if(prev_world == nullptr) { + GameDB::load_configs(*world); + } else { + prev_world->clone_into(*world); + } + + return world; + } + + void register_level(Level level) { + // size BEFORE push to get the correct index + level.index = LDB->levels.size(); + + LDB->levels.push_back(level); + + dbc::check(level.index == LDB->levels.size() - 1, "Level index is not the same as LDB->levels.size() - 1, off by one error"); + + LDB->level_count = level.index; + LDB->current_level = &LDB->levels.back(); + } + + void new_level(std::shared_ptr prev_world) { + dbc::check(initialized, "Forgot to call GameDB::init()"); + auto world = clone_load_world(prev_world); + + auto scaling = scale_level(); + + auto map = make_shared(scaling.map_width, scaling.map_height); + auto collision = std::make_shared(); + + WorldBuilder builder(*map, *collision); + builder.generate(*world); + + auto lights = make_shared(map->tiles()); + + auto player = world->get_the(); + + register_level({ + .player=player.entity, + .map=map, + .world=world, + .lights=lights, + .collision=collision}); + } + + void init() { + components::init(); + textures::init(); + + if(!initialized) { + LDB = make_shared(); + initialized = true; + new_level(NULL); + } + } + + shared_ptr current_world() { + dbc::check(initialized, "Forgot to call GameDB::init()"); + return current_level().world; + } + + Level& create_level() { + dbc::check(initialized, "Forgot to call GameDB::init()"); + + new_level(current_world()); + + return LDB->levels.back(); + } + + Level ¤t_level() { + dbc::check(initialized, "Forgot to call GameDB::init()"); + return *LDB->current_level; + } + + components::Position& player_position() { + dbc::check(initialized, "Forgot to call GameDB::init()"); + auto& level = current_level(); + return level.world->get(level.player); + } + + DinkyECS::Entity the_player() { + dbc::check(initialized, "Forgot to call GameDB::init()"); + return current_level().player; + } + + void load_configs(DinkyECS::World &world) { + world.set_the({ + settings::get("config"), + settings::get("enemies"), + settings::get("items"), + settings::get("tiles"), + settings::get("devices"), + }); + } +} diff --git a/src/game/level.hpp b/src/game/level.hpp new file mode 100644 index 0000000..abc5bff --- /dev/null +++ b/src/game/level.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "algos/dinkyecs.hpp" +#include "graphics/lights.hpp" +#include "game/map.hpp" +#include +#include "algos/spatialmap.hpp" + +namespace components { + struct Position; +} + +namespace GameDB { + struct Level { + size_t index = 0; + DinkyECS::Entity player = DinkyECS::NONE; + std::shared_ptr map = nullptr; + std::shared_ptr world = nullptr; + std::shared_ptr lights = nullptr; + std::shared_ptr collision = nullptr; + }; + + Level& create_level(); + + void init(); + Level ¤t_level(); + std::shared_ptr current_world(); + components::Position& player_position(); + DinkyECS::Entity the_player(); + + std::shared_ptr clone_load_world(std::shared_ptr prev_world); + void load_configs(DinkyECS::World &world); + void register_level(Level level); +} diff --git a/src/game/map.cpp b/src/game/map.cpp new file mode 100644 index 0000000..9970a3e --- /dev/null +++ b/src/game/map.cpp @@ -0,0 +1,132 @@ +#include "game/map.hpp" +#include "dbc.hpp" +#include "algos/rand.hpp" +#include +#include +#include +#include +#include "algos/matrix.hpp" + +using std::vector, std::pair; +using namespace fmt; + +Map::Map(size_t width, size_t height) : + $width(width), + $height(height), + $walls(height, matrix::Row(width, SPACE_VALUE)), + $paths(width, height) +{} + +Map::Map(Matrix &walls, Pathing &paths) : + $walls(walls), + $paths(paths) +{ + $width = matrix::width(walls); + $height = matrix::height(walls); +} + +void Map::make_paths() { + INVARIANT(); + $paths.compute_paths($walls); +} + +bool Map::inmap(size_t x, size_t y) { + return x < $width && y < $height; +} + +void Map::set_target(const Point &at, int value) { + $paths.set_target(at, value); +} + +void Map::clear_target(const Point &at) { + $paths.clear_target(at); +} + +bool Map::place_entity(size_t room_index, Point &out) { + dbc::check($dead_ends.size() != 0, "no dead ends?!"); + + if(room_index < $rooms.size()) { + Room &start = $rooms.at(room_index); + + for(matrix::rando_rect it{$walls, start.x, start.y, start.width, start.height}; it.next();) { + if(!iswall(it.x, it.y)) { + out.x = it.x; + out.y = it.y; + return true; + } + } + } + + out = $dead_ends.at(room_index % $dead_ends.size()); + return true; +} + +bool Map::iswall(size_t x, size_t y) { + return !$doors.contains({x, y}) && $walls[y][x] == WALL_VALUE; +} + +void Map::dump(int show_x, int show_y) { + matrix::dump("WALLS", walls(), show_x, show_y); + matrix::dump("PATHS", paths(), show_x, show_y); +} + +bool Map::can_move(Point move_to) { + return inmap(move_to.x, move_to.y) && !iswall(move_to.x, move_to.y); +} + +Point Map::map_to_camera(const Point &loc, const Point &cam_orig) { + return {loc.x - cam_orig.x, loc.y - cam_orig.y}; +} + +Point Map::center_camera(const Point &around, size_t view_x, size_t view_y) { + int high_x = int(width() - view_x); + int high_y = int(height() - view_y); + int center_x = int(around.x - view_x / 2); + int center_y = int(around.y - view_y / 2); + + size_t start_x = high_x > 0 ? std::clamp(center_x, 0, high_x) : 0; + size_t start_y = high_y > 0 ? std::clamp(center_y, 0, high_y) : 0; + + return {start_x, start_y}; +} + +bool Map::random_walk(Point &out, bool random, int direction) { + int choice = Random::uniform(0,4); + return $paths.find_path(out, direction, random && choice == 0) != PathingResult::FAIL; +} + +bool Map::INVARIANT() { + using dbc::check; + + check($walls.size() == height(), "walls wrong height"); + check($walls[0].size() == width(), "walls wrong width"); + check($paths.$width == width(), "in Map paths width don't match map width"); + check($paths.$height == height(), "in Map paths height don't match map height"); + + for(auto room : $rooms) { + check(int(room.x) >= 0 && int(room.y) >= 0, + $F("room invalid position {},{}", + room.x, room.y)); + check(int(room.width) > 0 && int(room.height) > 0, + $F("room has invalid dims {},{}", + room.width, room.height)); + } + + return true; +} + +void Map::init_tiles() { + $tiles = $walls; +} + + +void Map::add_room(Room &room) { + $rooms.push_back(room); +} + +void Map::invert_space() { + for(matrix::each_cell it{$walls}; it.next();) { + int is_wall = !$walls[it.y][it.x]; + $walls[it.y][it.x] = is_wall; + } +} diff --git a/src/game/map.hpp b/src/game/map.hpp new file mode 100644 index 0000000..d988bd7 --- /dev/null +++ b/src/game/map.hpp @@ -0,0 +1,96 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "algos/point.hpp" +#include "graphics/lights.hpp" +#include "algos/pathing.hpp" +#include "algos/matrix.hpp" +#include "constants.hpp" + +using lighting::LightSource; + +struct Room { + size_t x = 0; + size_t y = 0; + size_t width = 0; + size_t height = 0; + + bool contains(Point at) { + return at.x >= x + && at.x <= x + width -1 + && at.y >= y + && at.y <= y + height - 1; + } + + bool overlaps(Room other) { + return + // other left > this right == other too far right + !( other.x > x + width + + // other right < this left == other too far left + || other.x + other.width < x + + // other top > this bottom == too far below + || other.y > y + height + + // other bottom < this top == too far above + || other.y + other.height < y); + } + + bool operator==(const Room&) const = default; +}; + +using EntityGrid = std::unordered_map; + +class Map { +public: + size_t $width; + size_t $height; + Matrix $walls; + Matrix $tiles; + Pathing $paths; + std::vector $rooms; + std::vector $dead_ends; + std::unordered_map $doors; + + Map(size_t width, size_t height); + + Map(Matrix &walls, Pathing &paths); + + Matrix& paths() { return $paths.paths(); } + Matrix& input_map() { return $paths.input(); } + Matrix& walls() { return $walls; } + Matrix& tiles() { return $tiles; } + std::vector& rooms() { return $rooms; } + size_t width() { return $width; } + size_t height() { return $height; } + int distance(Point to) { return $paths.distance(to); } + + Room &room(size_t at) { return $rooms[at]; } + size_t room_count() { return $rooms.size(); } + + bool place_entity(size_t room_index, Point &out); + bool inmap(size_t x, size_t y); + bool iswall(size_t x, size_t y); + bool can_move(Point move_to); + bool random_walk(Point &out, bool random=false, int direction=PATHING_TOWARD); + + void make_paths(); + void set_target(const Point &at, int value=0); + void clear_target(const Point &at); + + Point map_to_camera(const Point &loc, const Point &cam_orig); + Point center_camera(const Point &around, size_t view_x, size_t view_y); + + + void dump(int show_x=-1, int show_y=-1); + bool INVARIANT(); + + void init_tiles(); + void add_room(Room &room); + void invert_space(); +}; diff --git a/src/game/sound.cpp b/src/game/sound.cpp new file mode 100644 index 0000000..58ca1d7 --- /dev/null +++ b/src/game/sound.cpp @@ -0,0 +1,81 @@ +#include "game/sound.hpp" +#include "dbc.hpp" +#include +#include "game/config.hpp" + +namespace sound { + static SoundManager SMGR; + static bool initialized = false; + static bool muted = false; + + using namespace fmt; + using std::make_shared; + namespace fs = std::filesystem; + + SoundPair& get_sound_pair(const std::string& name) { + dbc::check(initialized, "You need to call sound::init() first"); + + if(SMGR.sounds.contains(name)) { + // get the sound from the sound map + return SMGR.sounds.at(name); + } else { + dbc::log($F("Attempted to stop {} sound but not available.", name)); + return SMGR.sounds.at("blank"); + } + } + + + void init() { + if(!initialized) { + auto assets = settings::get("config"); + + for(auto& el : assets["sounds"].items()) { + load(el.key(), el.value()); + } + initialized = true; + } + } + + void load(const std::string& name, const std::string& sound_path) { + dbc::check(fs::exists(sound_path), $F("sound file {} does not exist", sound_path)); + + // create the buffer and keep in the buffer map + auto buffer = make_shared(sound_path); + + // set it on the sound and keep in the sound map + auto sound = make_shared(*buffer); + sound->setRelativeToListener(false); + sound->setPosition({0.0f, 0.0f, 1.0f}); + + SMGR.sounds.try_emplace(name, buffer, sound); + } + + void play(const std::string& name, bool loop) { + if(muted) return; + auto& pair = get_sound_pair(name); + pair.sound->setLooping(loop); + // play it + pair.sound->play(); + } + + void stop(const std::string& name) { + auto& pair = get_sound_pair(name); + pair.sound->stop(); + } + + bool playing(const std::string& name) { + auto& pair = get_sound_pair(name); + auto status = pair.sound->getStatus(); + return status == sf::SoundSource::Status::Playing; + } + + void play_at(const std::string& name, float x, float y, float z) { + auto& pair = get_sound_pair(name); + pair.sound->setPosition({x, y, z}); + pair.sound->play(); + } + + void mute(bool setting) { + muted = setting; + } +} diff --git a/src/game/sound.hpp b/src/game/sound.hpp new file mode 100644 index 0000000..3dc21cc --- /dev/null +++ b/src/game/sound.hpp @@ -0,0 +1,26 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace sound { + struct SoundPair { + std::shared_ptr buffer; + std::shared_ptr sound; + }; + + struct SoundManager { + std::unordered_map sounds; + }; + + void init(); + void load(const std::string& name, const std::string& path); + void play(const std::string& name, bool loop=false); + void play_at(const std::string& name, float x, float y, float z); + void stop(const std::string& name); + void mute(bool setting); + bool playing(const std::string& name); + SoundPair& get_sound_pair(const std::string& name); +} diff --git a/src/game/systems.cpp b/src/game/systems.cpp new file mode 100644 index 0000000..1c21c54 --- /dev/null +++ b/src/game/systems.cpp @@ -0,0 +1,656 @@ +#include "game/systems.hpp" +#include +#include +#include +#include "algos/rand.hpp" +#include "algos/spatialmap.hpp" +#include "dbc.hpp" +#include "graphics/lights.hpp" +#include "events.hpp" +#include "game/sound.hpp" +#include "ai/ai.hpp" +#include "ai/ai_debug.hpp" +#include "algos/shiterator.hpp" +#include "combat/battle.hpp" +#include +#include "graphics/shaders.hpp" +#include "graphics/textures.hpp" +#include "game/inventory.hpp" +#include "game/level.hpp" +#include "events.hpp" +#include "graphics/animation.hpp" + +using std::string; +using namespace fmt; +using namespace combat; +using namespace components; +using namespace DinkyECS; +using lighting::LightSource; + +void System::set_position(World& world, SpatialMap& collision, Entity entity, Position pos) { + world.set(entity, pos); + bool has_collision = world.has(entity); + collision.insert(pos.location, entity, has_collision); +} + +void System::lighting() { + auto& level = GameDB::current_level(); + auto& light = *level.lights; + auto& world = *level.world; + auto& map = *level.map; + + light.reset_light(); + + world.query([&](auto, auto &position) { + light.set_light_target(position.location); + }); + + light.path_light(map.walls()); + + world.query([&](auto ent, auto &position, auto &lightsource) { + light.render_light(lightsource, position.location); + + if(ent == level.player) { + light.update_fow(position.location, lightsource); + } + }); +} + +void System::generate_paths() { + auto& level = GameDB::current_level(); + const auto &player_pos = GameDB::player_position(); + + level.map->set_target(player_pos.location); + level.map->make_paths(); +} + +void System::enemy_ai_initialize() { + auto& level = GameDB::current_level(); + auto& world = *level.world; + auto& map = *level.map; + + world.query([&](const auto ent, auto& pos, auto& config) { + if(world.has(ent)) { + auto& enemy = world.get(ent); + auto& personality = world.get(ent); + + enemy.set_state("detect_enemy", map.distance(pos.location) < personality.hearing_distance); + enemy.update(); + } else { + auto ai_start = ai::load_state(config.ai_start_name); + auto ai_goal = ai::load_state(config.ai_goal_name); + + ai::EntityAI enemy(config.ai_script, ai_start, ai_goal); + auto& personality = world.get(ent); + + enemy.set_state("tough_personality", personality.tough); + enemy.set_state("detect_enemy", map.distance(pos.location) < personality.hearing_distance); + enemy.update(); + + world.set(ent, enemy); + } + }); +} + +void System::enemy_pathing() { + auto& level = GameDB::current_level(); + auto& world = *level.world; + auto& map = *level.map; + const auto &player_pos = GameDB::player_position(); + + world.query([&](auto ent, auto &position, auto &motion) { + if(ent != level.player) { + auto& enemy_ai = world.get(ent); + Point out = position.location; // copy + bool found_path = false; + + if(enemy_ai.wants_to("find_enemy")) { + found_path = map.random_walk(out, motion.random, PATHING_TOWARD); + } else if(enemy_ai.wants_to("run_away")) { + found_path = map.random_walk(out, motion.random, PATHING_AWAY); + } else { + motion = {0,0}; + return; // enemy doesn't want to move + } + + enemy_ai.set_state("cant_move", !found_path); + enemy_ai.update(); + + motion = { int(out.x - position.location.x), int(out.y - position.location.y)}; + } + }); + + map.clear_target(player_pos.location); +} + + +void System::motion() { + auto& level = GameDB::current_level(); + auto world = level.world; + auto map = level.map; + auto collider = level.collision; + + world->query( + [&](auto ent, auto &position, auto &motion) { + // skip enemies that aren't moving + if(motion.dx == 0 && motion.dy == 0) return; + + Point move_to = { + position.location.x + motion.dx, + position.location.y + motion.dy + }; + + motion = {0,0}; // clear it after getting it + + dbc::check(map->can_move(move_to), "Enemy pathing failed, move_to is wall."); + + bool cant_move = collider->occupied(move_to); + + if(auto enemy_ai = world->get_if(ent)) { + enemy_ai->set_state("cant_move", cant_move); + } + + // it's a wall, skip + if(cant_move) return; + + // all good, do the move + collider->move(position.location, move_to, ent); + position.location = move_to; + }); +} + +void System::distribute_loot(Position target_pos) { + auto& level = GameDB::current_level(); + auto& world = *level.world; + auto& config = world.get_the(); + int inventory_count = Random::uniform(0, 3); + auto loot_entity = world.entity(); + + if(inventory_count > 0) { + auto& entity_data = config.devices["DEAD_BODY_LOOTABLE"]; + components::configure_entity(world, loot_entity, entity_data["components"]); + // BUG: inventory_count here isn't really used to remove it + world.set(loot_entity, {inventory_count, entity_data}); + } else { + // this creates a dead body on the ground + auto& entity_data = config.devices["DEAD_BODY"]; + components::configure_entity(world, loot_entity, entity_data["components"]); + } + + set_position(world, *level.collision, loot_entity, target_pos); + level.world->send(game::Event::ENTITY_SPAWN, loot_entity, {}); +} + +void System::death() { + auto& level = GameDB::current_level(); + auto& world = *level.world; + auto player = world.get_the(); + std::vector dead_things; + + world.query([&](auto ent, auto &combat) { + // bring out yer dead + if(combat.hp <= 0 && !combat.dead) { + combat.dead = true; + if(ent != player.entity) { + // we won't change out the player's components later + dead_things.push_back(ent); + } + // we need to send this event for everything that dies + world.send(game::Event::DEATH, ent, {}); + } else if(float(combat.hp) / float(combat.max_hp) < 0.5f) { + // if enemies are below 50% health they are marked with bad health + if(world.has(ent)) { + auto& enemy_ai = world.get(ent); + enemy_ai.set_state("health_good", false); + enemy_ai.update(); + } + } + }); + + // this goes through everything that died and changes them to a gravestone + for(auto ent : dead_things) { + if(auto snd = world.get_if(ent)) { + sound::stop(snd->attack); + sound::play(snd->death); + } + + auto pos = world.get(ent); + + // need to remove _after_ getting the position + level.collision->remove(pos.location, ent); + + // distribute_loot is then responsible for putting something there + System::distribute_loot(pos); + + world.destroy(ent); + } +} + +void System::combat(int attack_id) { + auto& level = GameDB::current_level(); + auto& collider = *level.collision; + auto& world = *level.world; + const auto& player_pos = GameDB::player_position(); + auto& player_combat = world.get(level.player); + auto& player_ai = world.get(level.player); + + // this is guaranteed to not return the given position + auto [found, nearby] = collider.neighbors(player_pos.location); + combat::BattleEngine battle; + + if(found) { + for(auto entity : nearby) { + if(world.has(entity)) { + auto& enemy_ai = world.get(entity); + auto& enemy_combat = world.get(entity); + battle.add_enemy({entity, &enemy_ai, &enemy_combat}); + } + } + + battle.add_enemy({level.player, &player_ai, &player_combat, true}); + battle.set_all("enemy_found", true); + battle.set_all("in_combat", true); + battle.player_request("kill_enemy"); + battle.ap_refresh(); + battle.plan(); + } + + // battle.dump(); + + while(auto act = battle.next()) { + auto [enemy, enemy_action, cost, host_state] = *act; + + // player shouldn't hit theirself + if(host_state != BattleHostState::not_host) continue; + + components::CombatResult result { + .attacker=enemy.entity, + .host_state=host_state, + .player_did=player_combat.attack(*enemy.combat), + .enemy_did=0 + }; + + if(result.player_did > 0) { + spawn_attack(world, attack_id, enemy.entity); + } + + if(enemy_action == "kill_enemy") { + result.enemy_did = enemy.combat->attack(player_combat); + animation::animate_entity(world, enemy.entity); + } + + world.send(game::Event::COMBAT, enemy.entity, result); + } +} + + +void System::collision() { + auto& level = GameDB::current_level(); + auto& collider = *level.collision; + auto& world = *level.world; + const auto& player_pos = GameDB::player_position(); + + // this is guaranteed to not return the given position + auto [found, nearby] = collider.neighbors(player_pos.location); + int combat_count = 0; + + // AI: I think also this would a possible place to run AI decisions + for(auto entity : nearby) { + if(world.has(entity)) { + auto combat = world.get(entity); + if(!combat.dead) { + combat_count++; + world.send(game::Event::COMBAT_START, entity, entity); + } + } else { + dbc::log($F("UNKNOWN COLLISION TYPE {}", entity)); + } + } + + if(combat_count == 0) { + // BUG: this is probably how we get stuck in combat + world.send(game::Event::NO_NEIGHBORS, level.player, level.player); + } +} + +/* + * This isn't for destroying something, but just removing it + * from the world for say, putting into a container or inventory. + */ +void System::remove_from_world(Entity entity) { + auto& level = GameDB::current_level(); + auto& item_pos = level.world->get(entity); + level.collision->remove(item_pos.location, entity); + // if you don't do this you get the bug that you can pickup + // an item and it'll also be in your inventory + level.world->remove(entity); +} + +void System::pickup() { + auto& level = GameDB::current_level(); + auto& world = *level.world; + auto& collision = *level.collision; + auto pos = GameDB::player_position(); + + if(!collision.something_there(pos.aiming_at)) return; + + auto entity = level.collision->find(pos.aiming_at, [&](auto data) -> bool { + return (world.has(data.entity) || + world.has(data.entity)); + }); + + if(entity == DinkyECS::NONE) { + dbc::log("no inventory or devices there"); + return; + } + + // use spatial find to find an item with inventory... + if(world.has(entity)) { + // NOTE: this might need to be a separate system so that people can leave stuff alone + remove_from_world(entity); + // NOTE: chests are different from say a torch, maybe 2 events or the + // GUI figures out which it is, then when you click either pick it up + // and move it or show the loot container UI + world.send(game::Event::LOOT_ITEM, entity, entity); + } else if(world.has(entity)) { + System::device(world, level.player, entity); + } else { + dbc::log("BUG: is this a bug in pickup?!"); + } +} + +void System::device(World &world, Entity actor, Entity item) { + auto& device = world.get(item); + dbc::log($F("entity {} INTERACTED WITH DEVICE {}", actor, item)); + + for(auto event : device.events) { + if(event == "STAIRS_DOWN") { + world.send(game::Event::STAIRS_DOWN, actor, device); + } else if(event == "STAIRS_UP") { + world.send(game::Event::STAIRS_UP, actor, device); + } else if(event == "TRAP") { + world.send(game::Event::TRAP, actor, device); + } else if(event == "LOOT_CONTAINER") { + world.send(game::Event::LOOT_CONTAINER, actor, device); + } else { + dbc::log($F( + "INVALID EVENT {} for device {}", + event, (std::string)device.config["name"])); + } + } +} + +void System::move_player(Position move_to) { + auto& level = GameDB::current_level(); + auto old_pos = level.world->get(level.player); + + level.world->set(level.player, move_to); + level.collision->move(old_pos.location, move_to.location, level.player); +} + + +void System::player_status() { + auto& level = GameDB::current_level(); + auto& combat = level.world->get(level.player); + float percent = float(combat.hp) / float(combat.max_hp); + + if(percent > 0.8) { + sound::play("hp_status_80"); + } else if(percent > 0.6) { + sound::play("hp_status_60"); + } else if(percent > 0.3) { + sound::play("hp_status_30"); + } else if(percent > 0.1) { + sound::play("hp_status_10"); + } else { + sound::play("hp_status_00"); + } +} + +std::shared_ptr System::sprite_effect(Entity entity) { + auto world = GameDB::current_world(); + if(auto se = world->get_if(entity)) { + if(se->frames > 0) { + se->frames--; + return se->effect; + } else { + world->remove(entity); + return nullptr; + } + } else { + return nullptr; + } +} + +Entity System::spawn_item(World& world, const std::string& name) { + auto& config = world.get_the().items; + auto& item_config = config[name]; + auto item_id = world.entity(); + world.set(item_id, {1, item_config}); + components::configure_entity(world, item_id, item_config["components"]); + + return item_id; +} + +void System::drop_item(Entity item) { + auto& level = GameDB::current_level(); + auto& world = *level.world; + auto& map = *level.map; + auto player_pos = GameDB::player_position(); + + dbc::check(map.can_move(player_pos.location), "impossible, the player can't be in a wall"); + + Position drop_spot = {player_pos.aiming_at.x, player_pos.aiming_at.y}; + + // if they're aiming at a wall then drop at their feet + if(!map.can_move(drop_spot.location)) drop_spot = player_pos; + + set_position(world, *level.collision, item, drop_spot); + + level.world->not_constant(item); + level.world->send(game::Event::ENTITY_SPAWN, item, {}); +} + +// NOTE: I think pickup and this need to be different +bool System::place_in_container(Entity cont_id, const std::string& name, Entity world_entity) { + auto world = GameDB::current_world(); + auto& container = world->get(cont_id); + + if(container.has(world_entity)) { + fmt::println("container {} already has entity {}, skip", cont_id, world_entity); + // NOTE: I think this would be a move?! + return false; + } else if(container.has(name)) { + // this is an already occupied slot + fmt::println("container {} already has SLOT {}, skip", cont_id, name); + return false; + } else { + // this should only apply to the player's inventory + fmt::println("adding {} entity to loot with name {}", world_entity, name); + container.add(name, world_entity); + return true; + } +} + +void System::remove_from_container(Entity cont_id, const std::string& slot_id) { + auto world = GameDB::current_world(); + auto& container = world->get(cont_id); + auto entity = container.get(slot_id); + container.remove(entity); +} + + +void System::inventory_swap(Entity container_id, const std::string& a_name, const std::string &b_name) { + auto& level = GameDB::current_level(); + dbc::check(a_name != b_name, "Attempt to inventory swap the same slot, you should check this and avoid calling me."); + + auto& inventory = level.world->get(container_id); + + auto a_ent = inventory.get(a_name); + auto b_ent = inventory.get(b_name); + inventory.swap(a_ent, b_ent); +} + +bool System::inventory_occupied(Entity container_id, const std::string& name) { + auto world = GameDB::current_world(); + auto& inventory = world->get(container_id); + return inventory.has(name); +} + + +void System::draw_map(Matrix& grid, EntityGrid& entity_map) { + auto& level = GameDB::current_level(); + auto& world = *level.world; + Map &map = *level.map; + Matrix &fow = level.lights->$fow; + size_t view_x = matrix::width(grid) - 1; + size_t view_y = matrix::height(grid) - 1; + + entity_map.clear(); + + auto player_pos = world.get(level.player).location; + Point cam_orig = map.center_camera(player_pos, view_x, view_y); + auto &tiles = map.tiles(); + auto &tile_set = textures::get_map_tile_set(); + + /* I'm doing double tid->wchar_t conversion here, maybe just + * render the tids into the grid then let someone else do this. */ + + // first fill it with the map cells + for(shiterator::each_cell_t it{grid}; it.next();) { + size_t tile_y = size_t(it.y) + cam_orig.y; + size_t tile_x = size_t(it.x) + cam_orig.x; + + if(matrix::inbounds(tiles, tile_x, tile_y) && fow[tile_y][tile_x]) { + size_t tid = tiles[tile_y][tile_x]; + grid[it.y][it.x] = tile_set[tid]; + } else { + grid[it.y][it.x] = L' '; + } + } + + // then get the enemy/item/device tiles and fill those in + world.query([&](auto, auto &pos, auto &entity_glyph) { + // BUG: don't I have a within bounds macro somewhere? + if(pos.location.x >= cam_orig.x + && pos.location.x <= cam_orig.x + view_x + && pos.location.y >= cam_orig.y + && pos.location.y <= cam_orig.y + view_y) + { + if(fow[pos.location.y][pos.location.x]) { + Point view_pos = map.map_to_camera(pos.location, cam_orig); + entity_map.insert_or_assign(view_pos, entity_glyph.display); + } + } + }); +} + +void System::render_map(Matrix& tiles, EntityGrid& entity_map, sf::RenderTexture& render, int compass_dir, wchar_t player_display) { + sf::Vector2i tile_sprite_dim{MAP_TILE_DIM,MAP_TILE_DIM}; + unsigned int width = matrix::width(tiles); + unsigned int height = matrix::height(tiles); + sf::Vector2u dim{width * tile_sprite_dim.x, height * tile_sprite_dim.y}; + auto render_size = render.getSize(); + + if(render_size.x != width || render_size.y != height) { + bool worked = render.resize(dim); + dbc::check(worked, "Failed to resize map render target."); + } + + render.clear({0,0,0,255}); + + for(matrix::each_row it{tiles}; it.next();) { + wchar_t display = tiles[it.y][it.x]; + if(display == L' ') continue; // skip for now + auto& sprite = textures::get_map_sprite(display); + sprite.setPosition({float(it.x * tile_sprite_dim.x), float(it.y * tile_sprite_dim.y)}); + render.draw(sprite); + } + + for(auto [point, display] : entity_map) { + auto& sprite = textures::get_map_sprite(display); + + if(display == player_display) { + sf::Vector2f center{float(tile_sprite_dim.x / 2), float(tile_sprite_dim.y / 2)}; + float degrees = (((compass_dir * 45) + PLAYER_SPRITE_DIR_CORRECTION) % 360); + + sprite.setOrigin(center); + sprite.setRotation(sf::degrees(degrees)); + sprite.setPosition({float(point.x * tile_sprite_dim.x) + center.x, float(point.y * tile_sprite_dim.y) + center.y}); + } else { + sprite.setPosition({float(point.x * tile_sprite_dim.x), float(point.y * tile_sprite_dim.y)}); + } + + render.draw(sprite); + } + + render.display(); +} + +bool System::use_item(const string& slot_name) { + auto& level = GameDB::current_level(); + auto& world = *level.world; + auto& inventory = world.get(level.player); + auto& player_combat = world.get(level.player); + + if(player_combat.hp >= player_combat.max_hp) return false; + if(!inventory.has(slot_name)) return false; + + auto what = inventory.get(slot_name); + + if(auto curative = world.get_if(what)) { + inventory.remove(what); + + player_combat.hp += curative->hp; + + if(player_combat.hp > player_combat.max_hp) { + player_combat.hp = player_combat.max_hp; + } + + dbc::log($F("player health now {}", + player_combat.hp)); + + world.remove(what); + return true; + } else { + dbc::log($F("no usable item at {}", what)); + return false; + } +} + +game::Event System::shortest_rotate(Point player_at, Point aiming_at, Point target) { + dbc::check(aiming_at != target, "you're already pointing there."); + dbc::check(player_at != target, "you can't turn on yourself"); + + float target_dx = float(player_at.x) - float(target.x); + float target_dy = float(player_at.y) - float(target.y); + float aiming_dx = float(player_at.x) - float(aiming_at.x); + float aiming_dy = float(player_at.y) - float(aiming_at.y); + + float target_angle = atan2(-target_dy, target_dx) * (180.0 / std::numbers::pi); + float aiming_angle = atan2(-aiming_dy, aiming_dx) * (180.0 / std::numbers::pi); + + float diff = target_angle - aiming_angle; + double normalized = fmod(diff + 360.0, 360.0); + + return normalized < 180.0 ? game::Event::ROTATE_LEFT : game::Event::ROTATE_RIGHT; +} + +void System::clear_attack() { + auto world = GameDB::current_world(); + std::vector dead_anim; + + world->query([&](auto ent, auto& anim, auto&) { + if(!anim.playing) dead_anim.push_back(ent); + }); + + for(auto ent : dead_anim) { + world->remove(ent); + world->remove(ent); + world->remove(ent); + remove_from_world(ent); + } +} + +void System::spawn_attack(World& world, int attack_id, DinkyECS::Entity enemy) { +} diff --git a/src/game/systems.hpp b/src/game/systems.hpp new file mode 100644 index 0000000..2de17f7 --- /dev/null +++ b/src/game/systems.hpp @@ -0,0 +1,74 @@ +#pragma once +#include "game/components.hpp" +#include +#include "game/map.hpp" +#include "algos/spatialmap.hpp" +#include "game/level.hpp" +#include "events.hpp" + +namespace System { + using namespace components; + using namespace DinkyECS; + using std::string, matrix::Matrix; + + void lighting(); + void motion(); + void collision(); + void death(); + void generate_paths(); + void enemy_pathing(); + void enemy_ai_initialize(); + + void device(World &world, Entity actor, Entity item); + void move_player(Position move_to); + Entity spawn_item(World& world, const string& name); + void drop_item(Entity item); + + void enemy_ai(); + void combat(int attack_id); + + std::shared_ptr sprite_effect(Entity entity); + void player_status(); + void distribute_loot(Position target_pos); + + void pickup(); + + bool place_in_container(Entity cont_id, const string& name, Entity world_entity); + + void remove_from_container(Entity cont_id, const std::string& name); + void remove_from_world(Entity entity); + void inventory_swap(Entity container_id, const std::string& a_name, const std::string &b_name); + bool inventory_occupied(Entity container_id, const std::string& name); + + void draw_map(Matrix& grid, EntityGrid& entity_map); + void render_map(Matrix& tiles, EntityGrid& entity_map, sf::RenderTexture& render, int compass_dir, wchar_t player_display); + + void set_position(DinkyECS::World& world, SpatialMap& collision, Entity entity, Position pos); + bool use_item(const std::string& slot_name); + + game::Event shortest_rotate(Point player_at, Point aiming_at, Point turning_to); + + template + void multi_path(GameDB::Level& level, Pathing& paths, Matrix& walls) { + // first, put everything of this type as a target + level.world->query( + [&](const auto ent, auto& position, auto&) { + if(ent != level.player) { + paths.set_target(position.location); + } + }); + + level.world->query( + [&](const auto ent, auto& collision) { + if(collision.has && ent != level.player) { + auto& pos = level.world->get(ent); + walls[pos.location.y][pos.location.x] = WALL_VALUE; + } + }); + + paths.compute_paths(walls); + } + + void clear_attack(); + void spawn_attack(World& world, int attack_id, DinkyECS::Entity enemy); +} diff --git a/src/game/worldbuilder.cpp b/src/game/worldbuilder.cpp new file mode 100644 index 0000000..cac4c5c --- /dev/null +++ b/src/game/worldbuilder.cpp @@ -0,0 +1,256 @@ +#include "game/worldbuilder.hpp" +#include "algos/rand.hpp" +#include +#include +#include "game/components.hpp" +#include "algos/maze.hpp" +#include "graphics/textures.hpp" +#include "game/inventory.hpp" +#include "game/systems.hpp" +#include "graphics/animation.hpp" + +using namespace fmt; +using namespace components; + +void WorldBuilder::stylize_rooms() { + auto& tiles = $map.tiles(); + auto style_config = settings::get("room_themes"); + json& styles = style_config.json(); + + for(auto& room : $map.rooms()) { + auto& style = styles[Random::uniform(size_t(0), styles.size() - 1)]; + + dbc::check(style.contains("floor"), + $F("no floor spec in style {}", (std::string)style["name"])); + dbc::check(style.contains("walls"), + $F("no walls spec in style {}", (std::string)style["name"])); + + auto& floor_name = style["floor"]; + auto& wall_name = style["walls"]; + size_t floor_id = textures::get_id(floor_name); + size_t wall_id = textures::get_id(wall_name); + + for(matrix::box it{tiles, room.x, room.y, room.width+1, room.height+1}; it.next();) { + if(tiles[it.y][it.x] == 1) { + tiles[it.y][it.x] = wall_id; + } else if(tiles[it.y][it.x] == 0) { + tiles[it.y][it.x] = floor_id; + } + } + } +} + +void WorldBuilder::generate_map() { + auto script = R"( + [ + {"action": "hunt_and_kill"}, + {"action": "clear"}, + {"action": "randomize_rooms", "data": [3]}, + {"action": "hunt_and_kill"}, + {"action": "place_doors"} + ] + )"_json; + + int i = 0; + for(; i < 10; i++) { + auto [maze, valid] = maze::script($map, script); + + if(valid) { + break; + } else { + maze.dump(fmt::format("FAILED width={}", $map.width()), true); + } + } + + dbc::check(i < 10, "failed to find a valid map after 10 attempts"); + + $map.init_tiles(); + stylize_rooms(); +} + +bool WorldBuilder::find_open_spot(Point& pos_out) { + size_t i = 0; + + // horribly bad but I need to place things _somewhere_ so just fan out + for(i = 2; i < $map.width(); i++) { + // rando_rect starts at the top/left corner not center + for(matrix::rando_box it{$map.walls(), pos_out.x, pos_out.y, i}; it.next();) { + Point test{size_t(it.x), size_t(it.y)}; + + if($map.can_move(test) && !$collision.something_there(test)) { + pos_out = test; + return true; + } + } + } + + matrix::dump("FAIL PLACE!", $map.walls(), pos_out.x, pos_out.y); + + dbc::sentinel($F("failed to place entity in the entire map?: i={}; width={};", i, $map.width())); + + return false; +} + +DinkyECS::Entity WorldBuilder::configure_entity_in_map(DinkyECS::World &world, json &entity_data, Point pos) { + bool found = find_open_spot(pos); + dbc::check(found, "Failed to find a place for this thing."); + + auto item = world.entity(); + + int inv_count = entity_data.contains("inventory_count") ? (int)entity_data["inventory_count"] : 0; + + if(inv_count > 0) { + world.set(item, {entity_data["inventory_count"], entity_data}); + } + + if(entity_data.contains("components")) { + components::configure_entity(world, item, entity_data["components"]); + } + + System::set_position(world, $collision, item, {pos.x, pos.y}); + animation::configure(world, item); + + return item; +} + +DinkyECS::Entity WorldBuilder::configure_entity_in_room(DinkyECS::World &world, json &entity_data, int in_room) { + Point pos_out; + bool placed = $map.place_entity(in_room, pos_out); + dbc::check(placed, "failed to randomly place item in room"); + auto entity = configure_entity_in_map(world, entity_data, pos_out); + return entity; +} + +inline json &select_entity_type(GameConfig &config, json &gen_config) { + int enemy_test = Random::uniform(0,100); + int device_test = Random::uniform(0, 100); + + if(enemy_test < gen_config["enemy_probability"]) { + return config.enemies.json(); + } else if(device_test < gen_config["device_probability"]) { + return config.devices.json(); + } else { + return config.items.json(); + } +} + +inline json& random_entity_data(GameConfig& config, json& gen_config) { + json& entity_db = select_entity_type(config, gen_config); + + std::vector keys; + for(auto& el : entity_db.items()) { + auto& data = el.value(); + + if(data["placement"] == nullptr) { + keys.push_back(el.key()); + } + } + + int rand_entity = Random::uniform(0, keys.size() - 1); + std::string key = keys[rand_entity]; + + return entity_db[key]; +} + +void WorldBuilder::randomize_entities(DinkyECS::World &world, GameConfig &config) { + auto& gen_config = config.game["worldgen"]; + + for(int room_num = $map.room_count() - 1; room_num > 0; room_num--) { + // pass that to the config as it'll be a generic json + auto& entity_data = random_entity_data(config, gen_config); + configure_entity_in_room(world, entity_data, room_num); + } + + for(auto& at : $map.$dead_ends) { + if($map.$doors.contains(at)) continue; + auto& entity_data = random_entity_data(config, gen_config); + configure_entity_in_map(world, entity_data, at); + } +} + +void WorldBuilder::place_doors(DinkyECS::World& world, GameConfig& config) { + auto& device_config = config.devices.json(); + auto entity_data = device_config["DOOR_PLAIN"]; + auto& tiles = $map.tiles(); + auto& walls = $map.walls(); + + for(auto [door_at, _] : $map.$doors) { + // note, we set this to WALL_VALUE so it renders as a wall but map.iswall will check if its a door for collision + walls[door_at.y][door_at.x] = WALL_VALUE; + + for(matrix::compass it{tiles, door_at.x, door_at.y}; it.next();) { + if(walls[it.y][it.x] == WALL_VALUE) { + // found a wall near the door, and since doors always have n/s/e/w walls it should be the one to use + size_t wall_id = tiles[it.y][it.x]; // this is wall to use + tiles[door_at.y][door_at.x] = textures::door_for_wall(wall_id); + break; + } + } + } +} + +void WorldBuilder::place_stairs(DinkyECS::World& world, GameConfig& config) { + auto& device_config = config.devices.json(); + auto entity_data = device_config["STAIRS_DOWN"]; + + auto at_end = $map.$dead_ends.back(); + configure_entity_in_map(world, entity_data, at_end); +} + +void WorldBuilder::configure_starting_items(DinkyECS::World &world) { + auto& player = world.get_the(); + + auto torch_id = System::spawn_item(world, "TORCH_BAD"); + + auto &inventory = world.get(player.entity); + inventory.add("hand_r", torch_id); + world.make_constant(torch_id); + + auto healing = System::spawn_item(world, "POTION_HEALING_SMALL"); + inventory.add("pocket_l", healing); + world.make_constant(healing); +} + +void WorldBuilder::place_entities(DinkyECS::World &world) { + auto &config = world.get_the(); + // configure a player as a fact of the world + Position player_pos{0,0}; + + if(world.has_the()) { + auto& player = world.get_the(); + + // first get a guess from the map + bool placed = $map.place_entity(0, player_pos.location); + dbc::check(placed, "map.place_entity failed to position player"); + + // then use the collision map to place the player safely + placed = find_open_spot(player_pos.location); + dbc::check(placed, "WorldBuild.find_open_spot also failed to position player"); + + System::set_position(world, $collision, player.entity, player_pos); + } else { + auto player_data = config.enemies["PLAYER_TILE"]; + auto player_ent = configure_entity_in_room(world, player_data, 0); + + player_pos = world.get(player_ent); + + // configure player in the world + Player player{player_ent}; + world.set_the(player); + world.set(player_ent, {}); + configure_starting_items(world); + world.make_constant(player.entity); + } + + dbc::check(player_pos.location.x != 0 && player_pos.location.y != 0, + "failed to place the player correctly"); + + place_doors(world, config); + randomize_entities(world, config); + place_stairs(world, config); +} + +void WorldBuilder::generate(DinkyECS::World &world) { + generate_map(); + place_entities(world); +} diff --git a/src/game/worldbuilder.hpp b/src/game/worldbuilder.hpp new file mode 100644 index 0000000..ff767da --- /dev/null +++ b/src/game/worldbuilder.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "game/map.hpp" +#include "algos/dinkyecs.hpp" +#include "game/components.hpp" +#include "algos/spatialmap.hpp" + +class WorldBuilder { + public: + Map& $map; + SpatialMap& $collision; + + WorldBuilder(Map &map, SpatialMap& collision) : + $map(map), + $collision(collision) + { } + + void generate_map(); + + DinkyECS::Entity configure_entity_in_map(DinkyECS::World &world, nlohmann::json &entity_data, Point pos); + + DinkyECS::Entity configure_entity_in_room(DinkyECS::World &world, nlohmann::json &entity_data, int in_room); + + bool find_open_spot(Point& pos_out); + void place_entities(DinkyECS::World &world); + void generate(DinkyECS::World &world); + void randomize_entities(DinkyECS::World &world, components::GameConfig &config); + void place_stairs(DinkyECS::World& world, components::GameConfig& config); + void place_doors(DinkyECS::World& world, components::GameConfig& config); + void configure_starting_items(DinkyECS::World &world); + void stylize_rooms(); +}; diff --git a/src/graphics/animation.cpp b/src/graphics/animation.cpp new file mode 100644 index 0000000..95e5c10 --- /dev/null +++ b/src/graphics/animation.cpp @@ -0,0 +1,312 @@ +#include "graphics/animation.hpp" +#include +#include +#include "dbc.hpp" +#include "algos/rand.hpp" +#include +#include +#include "game/sound.hpp" +#include "game/components.hpp" + +constexpr float SUB_FRAME_SENSITIVITY = 0.999f; + +namespace animation { + using namespace std::chrono_literals; + + std::vector Animation::calc_frames() { + dbc::check(sequence.frames.size() == sequence.durations.size(), "sequence.frames.size() != sequence.durations.size()"); + + std::vector frames; + + for(int frame_i : sequence.frames) { + dbc::check(frame_i < sheet.frames, "frame index greater than sheet frames"); + frames.emplace_back( + sf::Vector2i{sheet.frame_width * frame_i, 0}, // NOTE: one row only for now + sf::Vector2i{sheet.frame_width, + sheet.frame_height}); + } + + return frames; + } + + void Animation::play() { + dbc::check(!playing, "can't call play while playing?"); + sequence.current = 0; + sequence.subframe = 0.0f; + sequence.loop_count = 0; + playing = true; + sequence.timer.start(); + sequence.INVARIANT(); + } + + void Animation::stop() { + playing = false; + sequence.timer.reset(); + } + + // need one for each kind of thing to animate + // NOTE: possibly find a way to only run apply on frame change? + void Animation::apply(sf::Sprite& sprite) { + dbc::check(sequence.current < $frame_rects.size(), "current frame past $frame_rects"); + // NOTE: pos is not updated yet + auto& rect = $frame_rects.at(sequence.current); + sprite.setTextureRect(rect); + } + + + /* + * Alternative mostly used in raycaster.cpp that + * DOES NOT setTextureRect() but just points + * the rect_io at the correct frame, but leaves + * it's size and base position alone. + */ + void Animation::apply(sf::Sprite& sprite, sf::IntRect& rect_io) { + dbc::check(sequence.current < $frame_rects.size(), "current frame past $frame_rects"); + auto& rect = $frame_rects.at(sequence.current); + rect_io.position.x += rect.position.x; + rect_io.position.y += rect.position.y; + } + + void Animation::motion(sf::Transformable& sprite, sf::Vector2f pos, sf::Vector2f scale) { + sequence.INVARIANT(); + + transform.apply(sequence, pos, scale); + + if(transform.flipped) { + scale.x *= -1; + } + + sprite.setPosition(pos); + + if(transform.scaled) { + sprite.setScale(scale); + } + } + + void Animation::motion(sf::View& view_out, sf::Vector2f pos, sf::Vector2f size) { + dbc::check(size.x > 1.0f && size.y > 1.0f, "motion size must be above 1.0 since it's not a ratio"); + dbc::check(transform.flipped == false, "transform must be false, has no effect on View"); + + sf::Vector2f scale{transform.min_x, transform.min_y}; + + transform.apply(sequence, pos, scale); + + view_out.setCenter(pos); + + if(transform.scaled) { + view_out.setSize({size.x * scale.x, size.y * scale.y}); + } else { + view_out.setSize(size); + } + } + + void Animation::apply_effect(std::shared_ptr effect) { + dbc::check(effect != nullptr, "can't apply null effect"); + effect->setUniform("u_time", sequence.timer.getElapsedTime().asSeconds()); + sf::Vector2f u_resolution{float(sheet.frame_width), float(sheet.frame_height)}; + effect->setUniform("u_resolution", u_resolution); + } + + void Animation::play_sound() { + // BUG: this can be optimized way better + if(sounds.contains(form_name)) { + for(auto& [at_frame, sound_name] : sounds.at(form_name)) { + if(sequence.current == at_frame) { + sound::play(sound_name); + } + } + } else { + fmt::println("Animation has not sound {}", form_name); + } + } + + /* REFACTOR: I believe this is wrong still. If ::commit() determines number of ticks+alpha since last + * render then update needs to be called 1/tick. The Timer will keep track of alpha as the error + * between commit calls, so this function only really needs to care about ticks. But, I'm still + * calling getElapsedTime() when I already did that in commit(), so should I just ignore that and assume + * elapsed is DELTA, or use elapsed here? + */ + void Animation::update() { + dbc::check(playing, "attempt to update animation that's not playing"); + sequence.INVARIANT(); + + auto [ticks, alpha] = sequence.timer.commit(); + int duration = sequence.durations.at(sequence.current); + sequence.subframe += ticks; + + sequence.easing_position += ticks; + + bool frame_change = false; + + if(sequence.subframe >= duration) { + sequence.timer.restart(); + sequence.current++; + sequence.subframe = 0; + frame_change = true; + } + + if(sequence.current >= sequence.frame_count) { + sequence.loop_count++; + sequence.easing_position = 0; + playing = onLoop(sequence, transform); + sequence.INVARIANT(); + } + + if(frame_change) play_sound(); + + if(frame_change && onFrame != nullptr) onFrame(); + } + + void Timer::start() { + clock.start(); + prev_time = clock.getElapsedTime().asSeconds(); + } + + void Timer::reset() { + elapsed_ticks = 0; + clock.reset(); + } + + void Timer::restart() { + elapsed_ticks = 0; + clock.restart(); + prev_time = clock.getElapsedTime().asSeconds(); + } + + sf::Time Timer::getElapsedTime() { + return clock.getElapsedTime(); + } + + std::pair Timer::commit() { + // determine frame duration based on previous time + current_time = clock.getElapsedTime().asSeconds(); + frame_duration = current_time - prev_time; + + // update prev_time for the next call + prev_time = current_time; + + // update accumulator, retaining previous errors + accumulator += frame_duration; + + // find the tick count based on DELTA + double tick_count = floor(accumulator / DELTA); + + // reduce accumulator by the number of DELTAS + accumulator -= tick_count * DELTA; + // that leaves the remaining errors for next loop + + elapsed_ticks += tick_count; + + // alpha is then what we lerp...but WHY?! + alpha = accumulator / DELTA; + + // return the number of even DELTA ticks and the alpha + return {int(tick_count), alpha}; + } + + void Transform::apply(Sequence& seq, sf::Vector2f& pos_out, sf::Vector2f& scale_out) { + float tick = easing_func(seq.easing_position / seq.easing_duration); + + motion_func(*this, pos_out, scale_out, tick, relative); + } + + bool Animation::has_form(const std::string& as_form) { + return forms.contains(as_form); + } + + void Animation::set_form(const std::string& as_form) { + dbc::check(forms.contains(as_form), + $F("form {} does not exist in animation", as_form)); + stop(); + + const auto& [seq_name, tr_name] = forms.at(as_form); + + dbc::check(sequences.contains(seq_name), + $F("sequences do NOT have \"{}\" name", seq_name)); + + dbc::check(transforms.contains(tr_name), + $F("transforms do NOT have \"{}\" name", tr_name)); + + // everything good, do the update + form_name = as_form; + sequence_name = seq_name; + transform_name = tr_name; + sequence = sequences.at(seq_name); + transform = transforms.at(tr_name); + + sequence.frame_count = sequence.frames.size(); + + // BUG: should this be configurable instead? + for(auto duration : sequence.durations) { + sequence.easing_duration += float(duration); + } + dbc::check(sequence.easing_duration > 0.0, "bad easing duration"); + + $frame_rects = calc_frames(); + transform.easing_func = ease2::get_easing(transform.easing); + transform.motion_func = ease2::get_motion(transform.motion); + + sequence.INVARIANT(); + } + + Animation load(const std::string &file, const std::string &anim_name) { + using nlohmann::json; + std::ifstream infile(file); + auto data = json::parse(infile); + + dbc::check(data.contains(anim_name), + $F("{} animation config does not have animation {}", file, anim_name)); + + Animation anim; + animation::from_json(data[anim_name], anim); + anim.name = anim_name; + + dbc::check(anim.forms.contains("idle"), + $F("animation {} must have 'idle' form", anim_name)); + + anim.set_form("idle"); + + return anim; + } + + void Sequence::INVARIANT(const std::source_location location) { + dbc::check(frames.size() == durations.size(), + $F("frames.size={} doesn't match durations.size={}", + frames.size(), durations.size()), location); + + dbc::check(easing_duration > 0.0, $F("bad easing duration: {}", easing_duration), location); + + dbc::check(frame_count == frames.size(), + $F("frame_count={} doesn't match frames.size={}", frame_count, frames.size()), location); + + dbc::check(frame_count == durations.size(), + $F("frame_count={} doesn't match durations.size={}", frame_count, durations.size()), location); + + dbc::check(current < durations.size(), + $F("current={} went past end of fame durations.size={}", current, durations.size()), location); + } + + // BUG: BAAADD REMOVE + bool has(const std::string& name) { + using nlohmann::json; + std::ifstream infile("assets/animation.json"); + auto data = json::parse(infile); + return data.contains(name); + } + + void configure(DinkyECS::World& world, DinkyECS::Entity entity) { + auto sprite = world.get_if(entity); + + if(sprite != nullptr && has(sprite->name)) { + world.set(entity, animation::load("assets/animation.json", sprite->name)); + } + } + + void animate_entity(DinkyECS::World &world, DinkyECS::Entity entity) { + auto anim = world.get_if(entity); + + if(anim != nullptr && !anim->playing) { + anim->play(); + } + } +} diff --git a/src/graphics/animation.hpp b/src/graphics/animation.hpp new file mode 100644 index 0000000..d188eb0 --- /dev/null +++ b/src/graphics/animation.hpp @@ -0,0 +1,150 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "graphics/easing.hpp" +#include +#include "game/json_mods.hpp" +#include +#include "algos/dinkyecs.hpp" + +namespace animation { + + template struct NameOf; + + struct Sheet { + int frames{0}; + int frame_width{0}; + int frame_height{0}; + }; + + struct Timer { + double DELTA = 1.0/60.0; + double accumulator = 0.0; + double prev_time = 0.0; + double current_time = 0.0; + double frame_duration = 0.0; + double alpha = 0.0; + int elapsed_ticks = 0; + sf::Clock clock{}; + + std::pair commit(); + void start(); + void reset(); + void restart(); + sf::Time getElapsedTime(); + }; + + struct Sequence { + std::vector frames{}; + std::vector durations{}; // in ticks + size_t current{0}; + int loop_count{0}; + size_t frame_count{frames.size()}; + Timer timer{}; + int subframe{0}; + float easing_duration{0.0f}; + float easing_position{0.0f}; + + void INVARIANT(const std::source_location location = std::source_location::current()); + }; + + struct Transform { + // how to know when a transform ends? + float min_x{1.0f}; + float min_y{1.0f}; + float max_x{1.0f}; + float max_y{1.0f}; + bool flipped{false}; + bool scaled{false}; + bool relative{false}; + + // handled by onLoop + bool toggled{false}; + bool looped{false}; + std::string easing{"in_out_back"}; + std::string motion{"move_rush"}; + + // change to using a callback function for these + ease2::EaseFunc easing_func{ease2::get_easing(easing)}; + ease2::MotionFunc motion_func{ease2::get_motion(motion)}; + + std::shared_ptr shader{nullptr}; + + void apply(Sequence& seq, sf::Vector2f& pos_out, sf::Vector2f& scale_out); + }; + + /* Gets the number of times it looped, and returns if it should stop. */ + using OnLoopHandler = std::function; + using OnFrameHandler = std::function; + + inline bool DefaultOnLoop(Sequence& seq, Transform& tr) { + if(tr.toggled) { + seq.current = seq.frame_count - 1; + } else { + seq.current = 0; + } + + return tr.looped; + } + + using Form = std::pair; + using Sound = std::pair; + + class Animation { + public: + Sheet sheet; + std::unordered_map sequences; + std::unordered_map transforms; + std::unordered_map forms; + std::unordered_map> sounds; + OnFrameHandler onFrame = nullptr; + + Sequence sequence{}; + Transform transform{}; + + std::vector $frame_rects{calc_frames()}; + OnLoopHandler onLoop = DefaultOnLoop; + bool playing = false; + + // mostly for debugging purposes + std::string form_name="idle"; + std::string sequence_name=""; + std::string transform_name=""; + std::string name=""; + + std::vector calc_frames(); + void play(); + void play_sound(); + void stop(); + bool has_form(const std::string& as_form); + void set_form(const std::string& form); + void apply(sf::Sprite& sprite); + void apply(sf::Sprite& sprite, sf::IntRect& rect_io); + void apply_effect(std::shared_ptr effect); + void update(); + void motion(sf::Transformable& sprite, sf::Vector2f pos, sf::Vector2f scale); + void motion(sf::View& view_out, sf::Vector2f pos, sf::Vector2f scale); + }; + + Animation load(const std::string &file, const std::string &anim_name); + + // BUG: brought over from animation to finish the refactor, but these may not be needed or maybe they go in system.cpp? + bool has(const std::string& name); + + void configure(DinkyECS::World& world, DinkyECS::Entity entity); + + void animate_entity(DinkyECS::World &world, DinkyECS::Entity entity); + + ENROLL_COMPONENT(Sheet, frames, frame_width, frame_height); + ENROLL_COMPONENT(Sequence, frames, durations); + ENROLL_COMPONENT(Transform, min_x, min_y, max_x, max_y, + flipped, scaled, relative, toggled, looped, easing, motion); + ENROLL_COMPONENT(Animation, sheet, sequences, transforms, forms, sounds); +} diff --git a/src/graphics/camera.cpp b/src/graphics/camera.cpp new file mode 100644 index 0000000..aad0ebe --- /dev/null +++ b/src/graphics/camera.cpp @@ -0,0 +1,132 @@ +#include "graphics/camera.hpp" +#include +#include "game/components.hpp" +#include "game/config.hpp" +#include +#include +#include + +namespace cinematic { + using animation::Animation, std::string, std::min, std::clamp; + + struct CameraManager { + std::unordered_map animations; + }; + + static CameraManager MGR; + static bool initialized = false; + + void init() { + if(!initialized) { + // BUG: it should be that you give a camera to load by name, not just one for all cameras + auto data = settings::get("cameras"); + + for(auto [key, value] : data.json().items()) { + auto anim = components::convert(value); + MGR.animations.try_emplace(key, anim); + } + + initialized = true; + } + } + + Camera::Camera(sf::Vector2f size, const std::string &name) : + anim(MGR.animations.at(name)), + size(size), + base_size(size), + aimed_at{size.x/2, size.y/2}, + going_to{size.x/2, size.y/2}, + camera_bounds{{0,0}, size}, + view{aimed_at, size} + { + anim.sheet.frame_width = base_size.x; + anim.sheet.frame_height = base_size.y; + } + + void Camera::update_camera_bounds(sf::Vector2f size) { + // camera bounds now constrains the x/y so that the mid-point + // of the size won't go too far outside of the frame + camera_bounds = { + {size.x / 2.0f, size.y / 2.0f}, + {base_size.x - size.x / 2.0f, base_size.y - size.y / 2.0f} + }; + } + + void Camera::scale(float ratio) { + size.x = base_size.x * ratio; + size.y = base_size.y * ratio; + update_camera_bounds(size); + } + + void Camera::resize(float width) { + dbc::check(width <= base_size.x, "invalid width for camera"); + + size.x = width; + size.y = base_size.y * (width / base_size.x); + update_camera_bounds(size); + } + + void Camera::style(const std::string &name) { + anim.set_form(name); + } + + void Camera::position(float x, float y) { + aimed_at.x = clamp(x, camera_bounds.position.x, camera_bounds.size.x); + aimed_at.y = clamp(y, camera_bounds.position.y, camera_bounds.size.y); + } + + void Camera::move(float x, float y) { + going_to.x = clamp(x, camera_bounds.position.x, camera_bounds.size.x); + going_to.y = clamp(y, camera_bounds.position.y, camera_bounds.size.y); + + if(!anim.transform.relative) { + anim.transform.min_x = aimed_at.x; + anim.transform.min_y = aimed_at.y; + anim.transform.max_x = going_to.x; + anim.transform.max_y = going_to.y; + } + } + + void Camera::reset(sf::RenderTexture& target) { + size = {base_size.x, base_size.y}; + aimed_at = {base_size.x/2, base_size.y/2}; + going_to = {base_size.x/2, base_size.y/2}; + view = {aimed_at, size}; + camera_bounds = {{0,0}, base_size}; + + // BUG: is getDefaultView different from view? + target.setView(target.getDefaultView()); + } + + void Camera::render(sf::RenderTexture& target) { + if(anim.playing) { + anim.motion(view, going_to, size); + target.setView(view); + } + } + + void Camera::update() { + if(anim.playing) anim.update(); + } + + bool Camera::playing() { + return anim.playing; + } + + void Camera::play() { + anim.play(); + } + + void Camera::from_story(components::Storyboard& story) { + anim.sequences.clear(); + anim.forms.clear(); + + for(auto& [timecode, cell, transform, duration] : story.beats) { + animation::Sequence seq{.frames={0}, .durations={std::stoi(duration)}}; + anim.sequences.try_emplace(timecode, seq); + + animation::Form form{timecode, transform}; + anim.forms.try_emplace(timecode, form); + } + } +} diff --git a/src/graphics/camera.hpp b/src/graphics/camera.hpp new file mode 100644 index 0000000..b16090e --- /dev/null +++ b/src/graphics/camera.hpp @@ -0,0 +1,37 @@ +#pragma once +#include "graphics/animation.hpp" +#include "constants.hpp" +#include + +namespace components { + struct Storyboard; +} + +namespace cinematic { + struct Camera { + animation::Animation anim; + sf::Vector2f size{SCREEN_WIDTH, SCREEN_HEIGHT}; + sf::Vector2f base_size{SCREEN_WIDTH, SCREEN_HEIGHT}; + sf::Vector2f aimed_at{0,0}; + sf::Vector2f going_to{0,0}; + sf::FloatRect camera_bounds{{0,0},{SCREEN_WIDTH, SCREEN_HEIGHT}}; + sf::View view; + + Camera(sf::Vector2f size, const std::string &name); + + void resize(float width); + void scale(float ratio); + void position(float x, float y); + void move(float x, float y); + bool playing(); + void update(); + void render(sf::RenderTexture& target); + void play(); + void style(const std::string &name); + void reset(sf::RenderTexture& target); + void update_camera_bounds(sf::Vector2f size); + void from_story(components::Storyboard& story); + }; + + void init(); +} diff --git a/src/graphics/easing.cpp b/src/graphics/easing.cpp new file mode 100644 index 0000000..076a681 --- /dev/null +++ b/src/graphics/easing.cpp @@ -0,0 +1,141 @@ +#include "algos/rand.hpp" +#include "graphics/animation.hpp" +#include +#include +#include "dbc.hpp" + +namespace ease2 { + using namespace animation; + + double none(float tick) { + return 0.0; + } + + double linear(float tick) { + return tick; + } + + double sine(double x) { + // old one? return std::abs(std::sin(seq.subframe * ease_rate)); + return (std::sin(x) + 1.0) / 2.0; + } + + double out_circle(double x) { + return std::sqrt(1.0f - ((x - 1.0f) * (x - 1.0f))); + } + + double out_bounce(double x) { + constexpr const double n1 = 7.5625; + constexpr const double d1 = 2.75; + + if (x < 1 / d1) { + return n1 * x * x; + } else if (x < 2 / d1) { + x -= 1.5; + return n1 * (x / d1) * x + 0.75; + } else if (x < 2.5 / d1) { + x -= 2.25; + return n1 * (x / d1) * x + 0.9375; + } else { + x -= 2.625; + return n1 * (x / d1) * x + 0.984375; + } + } + + double in_out_back(double x) { + constexpr const double c1 = 1.70158; + constexpr const double c2 = c1 * 1.525; + + return x < 0.5 + ? (std::pow(2.0 * x, 2.0) * ((c2 + 1.0) * 2.0 * x - c2)) / 2.0 + : (std::pow(2.0 * x - 2.0, 2.0) * ((c2 + 1.0) * (x * 2.0 - 2.0) + c2) + 2.0) / 2.0; + } + + double random(double tick) { + return Random::uniform_real(0.0001f, 1.0f); + } + + double normal_dist(double tick) { + return Random::normal(0.5f, 0.1f); + } + + void move_shake(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + pos_out.x = std::lerp(tr.min_x, tr.max_x, tick) + (pos_out.x * relative); + } + + void move_bounce(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + pos_out.y = std::lerp(tr.min_y, tr.max_y, tick) + (pos_out.y * relative); + } + + void move_rush(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + scale_out.x = std::lerp(tr.min_x, tr.max_x, tick) + (scale_out.x * relative); + scale_out.y = std::lerp(tr.min_y, tr.max_y, tick) + (scale_out.y * relative); + pos_out.y = pos_out.y - (pos_out.y * scale_out.y - pos_out.y) + (pos_out.y * relative); + } + + void scale_squeeze(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + scale_out.x = std::lerp(tr.min_x, tr.max_x, tick) + (scale_out.x * relative); + } + + void scale_squash(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + scale_out.y = std::lerp(tr.min_y, tr.max_y, tick) + (scale_out.y * relative); + } + + void scale_stretch(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + scale_out.x = std::lerp(tr.min_x, tr.max_x, tick) + (scale_out.x * relative); + } + + void scale_grow(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + scale_out.y = std::lerp(tr.min_y, tr.max_y, tick) + (scale_out.y * relative); + } + + void move_slide(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + pos_out.x = std::lerp(tr.min_x, tr.max_x, tick) + (pos_out.x * relative); + pos_out.y = std::lerp(tr.min_y, tr.max_y, tick) + (pos_out.y * relative); + } + + void move_none(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + } + + void scale_both(Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative) { + scale_out.x = std::lerp(scale_out.x * tr.min_x, scale_out.x * tr.max_x, tick) + (scale_out.x * relative); + scale_out.y = std::lerp(scale_out.y * tr.min_y, scale_out.y * tr.max_y, tick) + (scale_out.y * relative); + } + + std::unordered_map map_of_easings{ + {"sine", sine}, + {"out_circle", out_circle}, + {"out_bounce", out_bounce}, + {"in_out_back", in_out_back}, + {"random", random}, + {"normal_dist", normal_dist}, + {"none", none}, + {"linear", linear}, + }; + + std::unordered_map map_of_motions{ + {"move_bounce", move_bounce}, + {"move_rush", move_rush}, + {"scale_squeeze", scale_squeeze}, + {"scale_squash", scale_squash}, + {"scale_stretch", scale_stretch}, + {"scale_grow", scale_grow}, + {"move_slide", move_slide}, + {"move_none", move_none}, + {"scale_both", scale_both}, + {"move_shake", move_shake}, + }; + + EaseFunc get_easing(const std::string& name) { + dbc::check(map_of_easings.contains(name), + $F("easing name {} does not exist", name)); + return map_of_easings.at(name); + } + + MotionFunc get_motion(const std::string& name) { + dbc::check(map_of_motions.contains(name), + $F("motion name {} does not exist", name)); + return map_of_motions.at(name); + } + +} diff --git a/src/graphics/easing.hpp b/src/graphics/easing.hpp new file mode 100644 index 0000000..14ffb3f --- /dev/null +++ b/src/graphics/easing.hpp @@ -0,0 +1,32 @@ +#include +#include "graphics/animation.hpp" + +namespace animation { + struct Transform; +} + +namespace ease2 { + using EaseFunc = std::function; + using MotionFunc = std::function; + + EaseFunc get_easing(const std::string& name); + MotionFunc get_motion(const std::string& name); + + double sine(double x); + double out_circle(double x); + double out_bounce(double x); + double in_out_back(double x); + double random(double tick); + double normal_dist(double tick); + + void move_bounce(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void move_rush(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void scale_squeeze(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void scale_squash(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void scale_stretch(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void scale_grow(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void move_slide(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void move_none(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void scale_both(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); + void move_shake(animation::Transform &tr, sf::Vector2f& pos_out, sf::Vector2f& scale_out, float tick, bool relative); +} diff --git a/src/graphics/lights.cpp b/src/graphics/lights.cpp new file mode 100644 index 0000000..2b0d76a --- /dev/null +++ b/src/graphics/lights.cpp @@ -0,0 +1,87 @@ +#include "graphics/lights.hpp" +#include "constants.hpp" +#include "graphics/textures.hpp" +#include + +using std::vector; + +namespace lighting { + + LightRender::LightRender(Matrix& tiles) : + $width(matrix::width(tiles)), + $height(matrix::height(tiles)), + $lightmap(matrix::make($width, $height)), + $ambient(matrix::make($width, $height)), + $paths($width, $height), + $fow(matrix::make($width, $height)) + { + auto& tile_ambient = textures::get_ambient_light(); + + for(matrix::each_cell it{tiles}; it.next();) { + size_t tile_id = tiles[it.y][it.x]; + $ambient[it.y][it.x] = MIN + tile_ambient[tile_id]; + } + } + + void LightRender::render_square_light(LightSource source, Point at, PointList &has_light) { + for(matrix::box it{$lightmap, at.x, at.y, (size_t)floor(source.radius)}; it.next();) { + if($paths.$paths[it.y][it.x] != WALL_PATH_LIMIT) { + $lightmap[it.y][it.x] = light_level(source.strength, it.distance(), it.x, it.y); + has_light.emplace_back(it.x, it.y); + } + } + } + + /* + * NOTE: This really doesn't need to calculate light all the time. It doesn't + * change around the light source until the lightsource is changed, so the + * light levels could be placed in a Matrix inside LightSource, calculated once + * and then simply "applied" to the area where the entity is located. The only + * thing that would need to be calculated each time is the walls. + */ + void LightRender::render_light(LightSource source, Point at) { + clear_light_target(at); + PointList has_light; + + render_square_light(source, at, has_light); + + for(auto point : has_light) { + for(matrix::compass it{$lightmap, point.x, point.y}; it.next();) { + if($paths.$paths[it.y][it.x] == WALL_PATH_LIMIT) { + $lightmap[it.y][it.x] = light_level(source.strength, 1.5f, point.x, point.y); + } + } + } + } + + int LightRender::light_level(int strength, float distance, size_t x, size_t y) { + int boosted = strength + BOOST; + int new_level = distance <= 1.0f ? boosted : boosted / sqrt(distance); + int cur_level = $lightmap[y][x]; + return cur_level < new_level ? new_level : cur_level; + } + + void LightRender::reset_light() { + $lightmap = $ambient; + } + + void LightRender::clear_light_target(const Point &at) { + $paths.clear_target(at); + } + + void LightRender::set_light_target(const Point &at, int value) { + $paths.set_target(at, value); + } + + void LightRender::update_fow(Point at, LightSource source) { + for(matrix::circle it{$lightmap, at, source.radius}; it.next();) { + for(auto x = it.left; x < it.right; x++) { + $fow[it.y][x] = $lightmap[it.y][x]; + } + } + } + + void LightRender::path_light(Matrix &walls) { + $paths.compute_paths(walls); + } +} diff --git a/src/graphics/lights.hpp b/src/graphics/lights.hpp new file mode 100644 index 0000000..b413e5c --- /dev/null +++ b/src/graphics/lights.hpp @@ -0,0 +1,40 @@ +#pragma once +#include +#include "dbc.hpp" +#include "algos/point.hpp" +#include +#include "algos/matrix.hpp" +#include "algos/pathing.hpp" +#include "game/components.hpp" + +namespace lighting { + using components::LightSource; + + // THESE ARE PERCENTAGES! + const int MIN = 20; + const int BOOST = 10; + + class LightRender { + public: + size_t $width; + size_t $height; + Matrix $lightmap; + Matrix $ambient; + Pathing $paths; + matrix::Matrix $fow; + + LightRender(Matrix& walls); + + void reset_light(); + void set_light_target(const Point &at, int value=0); + void clear_light_target(const Point &at); + void path_light(Matrix &walls); + void light_box(LightSource source, Point from, Point &min_out, Point &max_out); + int light_level(int level, float distance, size_t x, size_t y); + void render_light(LightSource source, Point at); + void render_square_light(LightSource source, Point at, PointList &has_light); + void update_fow(Point player_pos, LightSource source); + Matrix &lighting() { return $lightmap; } + Matrix &paths() { return $paths.paths(); } + }; +} diff --git a/src/graphics/palette.cpp b/src/graphics/palette.cpp new file mode 100644 index 0000000..286e5e4 --- /dev/null +++ b/src/graphics/palette.cpp @@ -0,0 +1,72 @@ +#include +#include "graphics/palette.hpp" +#include "game/config.hpp" +#include "dbc.hpp" + +namespace palette { + using std::string; + using nlohmann::json; + + struct PaletteMgr { + std::unordered_map palettes; + std::string config; + std::unordered_map pending_refs; + bool initialized = false; + }; + + static PaletteMgr COLOR; + + bool initialized() { + return COLOR.initialized; + } + + void init(const string &json_file) { + if(!COLOR.initialized) { + COLOR.initialized = true; + + COLOR.config = json_file; + auto config = settings::get(json_file); + json& colors = config.json(); + + for(auto [key, value_specs] : colors.items()) { + const string& base_key = key; + + for(auto [value, rgba] : value_specs.items()) { + auto color_path = base_key + ":" + value; + dbc::check(!COLOR.palettes.contains(color_path), + $F("PALLETES config {} already has a color path {}", COLOR.config, color_path)); + + if(rgba.type() == json::value_t::string) { + COLOR.pending_refs.try_emplace(color_path, rgba); + } else { + uint8_t alpha = rgba.size() == 3 ? 255 : (uint8_t)rgba[3]; + sf::Color color{rgba[0], rgba[1], rgba[2], alpha}; + COLOR.palettes.try_emplace(color_path, color); + } + } + } + + for(auto [color_path, ref] : COLOR.pending_refs) { + dbc::check(COLOR.palettes.contains(ref), + $F("In {} you have {} referring to {} but {} doesn't exist.", + COLOR.config, color_path, ref, ref)); + dbc::check(!COLOR.palettes.contains(color_path), + $F("Color {} with ref {} is duplicated.", color_path, ref)); + + auto color = COLOR.palettes.at(ref); + + COLOR.palettes.try_emplace(color_path, color); + } + } + } + + sf::Color get(const string& key) { + dbc::check(COLOR.palettes.contains(key), + $F("COLOR {} is missing from {}", key, COLOR.config)); + return COLOR.palettes.at(key); + } + + sf::Color get(const string& key, const string& value) { + return get(key + ":" + value); + } +} diff --git a/src/graphics/palette.hpp b/src/graphics/palette.hpp new file mode 100644 index 0000000..9844924 --- /dev/null +++ b/src/graphics/palette.hpp @@ -0,0 +1,13 @@ +#include +#include + +namespace palette { + using std::string; + + bool initialized(); + void init(const std::string &config="palette"); + + sf::Color get(const string &key); + + sf::Color get(const string &key, const string &value); +} diff --git a/src/graphics/raycaster.cpp b/src/graphics/raycaster.cpp new file mode 100644 index 0000000..0956a09 --- /dev/null +++ b/src/graphics/raycaster.cpp @@ -0,0 +1,538 @@ +#include "dbc.hpp" +#include "game/components.hpp" +#include "game/systems.hpp" +#include "graphics/animation.hpp" +#include "graphics/raycaster.hpp" +#include "graphics/shaders.hpp" +#include +#include +#include +#include +#include +#include + +using std::make_unique, std::shared_ptr; + +union ColorConv { + struct { + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t a; + } as_color; + RGBA as_int; +}; + +// from: https://permadi.com/1996/05/ray-casting-tutorial-19/ +// Intensity = (kI/(d+do))*(N*L) +// rcr says: kI = intensity coefficient, d = distance, d0 = fudge term to prevent division by zero, N is surface, L is direction to light from surface +// +// That formula is just "Inverse-square law" (except they don't square, which is physically dubious), and "Lambertian reflectance" ("Diffuse reflection") which sounds fancy but is super standard. All the quoted terms have wikipedia articles +// +// Distance means distance to surface from light. +// +// Intensity = Object Intensity/Distance * Multiplier +// +/* It's hard to believe, but this is faster than any bitfiddling + * I could devise. Just use a union with a struct, do the math + * and I guess the compiler can handle it better than shifting + * bits around. + */ +inline RGBA lighting_calc(RGBA pixel, float dist, int level) { + ColorConv conv{.as_int=pixel}; + + if(conv.as_color.b < GLOW_LIMIT + && conv.as_color.r < GLOW_LIMIT + && conv.as_color.g < GLOW_LIMIT) + { + float intensity = (float(level) * PERCENT) / (dist + 1) * LIGHT_MULTIPLIER; + + conv.as_color.r *= intensity; + conv.as_color.g *= intensity; + conv.as_color.b *= intensity; + } + + return conv.as_int; +} + +Raycaster::Raycaster(int x, int y, int width, int height) : + $view_texture(sf::Vector2u{(unsigned int)width, (unsigned int)height}), + $view_sprite($view_texture), + $width(width), $height(height), + $screen_pos_x(x), + $screen_pos_y(y), + $zbuffer(width) +{ + $view_sprite.setPosition({float($screen_pos_x), float($screen_pos_y)}); + $pixels = make_unique($width * $height); + $view_texture.setSmooth(false); + + $camera.target_x = $pos_x; + $camera.target_y = $pos_y; + update_camera_aiming(); +} + +void Raycaster::position_camera(float player_x, float player_y) { + // x and y start position + $pos_x = player_x; + $pos_y = player_y; + $dir_x = 1; + $dir_y = 0; + $plane_x = 0; + $plane_y = 0.66; + + update_camera_aiming(); +} + +void Raycaster::draw_pixel_buffer() { + $view_texture.update((uint8_t *)$pixels.get(), {(unsigned int)$width, (unsigned int)$height}, {0, 0}); +} + +void Raycaster::apply_sprite_effect(shared_ptr effect, float width, float height) { + // BUG: should I use the clock in the animation? + effect->setUniform("u_time", $clock.getElapsedTime().asSeconds()); + sf::Vector2f u_resolution{width, height}; + effect->setUniform("u_resolution", u_resolution); +} + +std::shared_ptr Raycaster::apply_lighting_effect(components::Position& sprite_pos, matrix::Matrix &lights) { + // BUG: this is applying it to all sprites, put bottle far away then another close to see + auto effect = $brightness; + float level = lights[sprite_pos.location.y][sprite_pos.location.x] * PERCENT; + // this boosts the brightness of anything we're aiming at + level += (aiming_at == sprite_pos.location) * AIMED_AT_BRIGHTNESS; + effect->setUniform("darkness", level); + return effect; +} + +inline void step_animation(animation::Animation& anim, sf::Sprite& sprite, sf::Vector2f& position, sf::Vector2f& scale, sf::IntRect& in_texture, sf::Vector2f& origin) { + anim.update(); + anim.apply(sprite, in_texture); + anim.motion(sprite, position, scale); + sprite.setOrigin(origin); + sprite.setTextureRect(in_texture); +} + +inline void set_scale_position(sf::Sprite& sprite, sf::Vector2f& position, sf::Vector2f& scale, sf::IntRect& in_texture, sf::Vector2f& origin) { + sprite.setScale(scale); + sprite.setPosition(position); + sprite.setOrigin(origin); + sprite.setTextureRect(in_texture); +} + + +void Raycaster::sprite_casting() { + auto& lights = $level.lights->lighting(); + auto world = $level.world; + $level.collision->distance_sorted($sprite_order, {(size_t)$pos_x, (size_t)$pos_y}, RENDER_DISTANCE); + $sprites_to_render.clear(); + + // BUG: shaders are shared between sprites + // BUG: sprites seem to be shared too? Put a torch on the floor in a room then another to its left and only one is visible at a time. + // after sorting the sprites, do the projection + for(auto& rec : $sprite_order) { + if(!$sprites.contains(rec.entity)) continue; + + auto& sprite_texture = $sprites.at(rec.entity); + + int texture_width = float(sprite_texture.frame_size.x); + int texture_height = float(sprite_texture.frame_size.y); + int half_height = texture_height / 2; + + auto sf_sprite = sprite_texture.sprite; + auto sprite_pos = world->get(rec.entity); + + double sprite_x = double(sprite_pos.location.x) - rec.wiggle - $pos_x + 0.5; + double sprite_y = double(sprite_pos.location.y) - rec.wiggle - $pos_y + 0.5; + + double inv_det = 1.0 / ($plane_x * $dir_y - $dir_x * $plane_y); // required for correct matrix multiplication + + double transform_x = inv_det * ($dir_y * sprite_x - $dir_x * sprite_y); + + //this is actually the depth inside the screen, that what Z is in 3D, the distance of sprite to player, matching sqrt(spriteDistance[i]) + double transform_y = inv_det * (-$plane_y * sprite_x + $plane_x * sprite_y); + + int sprite_screen_x = int(($width / 2) * (1 + transform_x / transform_y)); + + // calculate the height of the sprite on screen + //using "transform_y" instead of the real distance prevents fisheye + int sprite_height = abs(int($height / transform_y)); + if(sprite_height == 0) continue; + + // calculate width the the sprite + // same as height of sprite, given that it's square + int sprite_width = abs(int($height / transform_y)); + if(sprite_width == 0) continue; + + int draw_start_x = -sprite_width / 2 + sprite_screen_x; + if(draw_start_x < 0) draw_start_x = 0; + int draw_end_x = sprite_width / 2 + sprite_screen_x; + if(draw_end_x > $width) draw_end_x = $width; + + int stripe = draw_start_x; + for(; stripe < draw_end_x; stripe++) { + //the conditions in the if are: + //1) it's in front of camera plane so you don't see things behind you + //2) $zbuffer, with perpendicular distance + if(!(transform_y > 0 && transform_y < $zbuffer[stripe])) break; + } + + int tex_x_end = int(texture_width * (stripe - (-sprite_width / 2 + sprite_screen_x)) * texture_width / sprite_width) / texture_width; + + if(draw_start_x < draw_end_x && transform_y > 0 && transform_y < $zbuffer[draw_start_x]) { + //calculate lowest and highest pixel to fill in current stripe + int draw_start_y = -sprite_height / 2 + $height / 2; + if(draw_start_y < 0) draw_start_y = 0; + + int tex_x = int(texture_width * (draw_start_x - (-sprite_width / 2 + sprite_screen_x)) * texture_width / sprite_width) / texture_width; + int tex_render_width = tex_x_end - tex_x; + + // avoid drawing sprites that are not visible (width < 0) + if(tex_render_width <= 0) continue; + + float x = float(draw_start_x + $screen_pos_x); + float y = float(draw_start_y + $screen_pos_y); + + if(x < $screen_pos_x) dbc::log("X < rayview left bounds"); + if(y < $screen_pos_y) dbc::log("Y < rayview top bounds"); + if(x >= SCREEN_WIDTH) dbc::log("OUT OF BOUNDS X"); + if(y >= $height) dbc::log("OUT OF BOUNDS Y"); + + float sprite_scale_w = float(sprite_width) / float(texture_width); + float sprite_scale_h = float(sprite_height) / float(texture_height); + + int d = y * texture_height - $height * half_height + sprite_height * half_height; + int tex_y = ((d * texture_height) / sprite_height) / texture_height; + + // BUG: this data could be put into the world + // as frame data, then just have a system that + // constantly applies this to any sprite that + // has an animation and is visible + sf::Vector2f origin{texture_width / 2.0f, texture_height / 2.0f}; + sf::Vector2f scale{sprite_scale_w, sprite_scale_h}; + sf::Vector2f position{x + origin.x * scale.x, y + origin.y * scale.y}; + sf::IntRect in_texture{ {tex_x, tex_y}, {tex_render_width, texture_height}}; + + shared_ptr effect = System::sprite_effect(rec.entity); + + if(effect) { + // has an effect, use that instead of lighting + apply_sprite_effect(effect, sprite_width, sprite_height); + } else { + // no effect applied in the system so apply lighting + effect = apply_lighting_effect(sprite_pos, lights); + } + + auto anim = world->get_if(rec.entity); + + if(anim != nullptr && anim->playing) { + step_animation(*anim, *sf_sprite, position, scale, in_texture, origin); + } else { + set_scale_position(*sf_sprite, position, scale, in_texture, origin); + } + + $sprites_to_render.emplace_back(sf_sprite, effect); + } + } +} + + +void Raycaster::cast_rays() { + constexpr static const int texture_width = TEXTURE_WIDTH; + constexpr static const int texture_height = TEXTURE_HEIGHT; + double perp_wall_dist; + auto& lights = $level.lights->lighting(); + + // WALL CASTING + for(int x = 0; x < $width; x++) { + // calculate ray position and direction + double cameraX = 2 * x / double($width) - 1; // x-coord in camera space + double ray_dir_x = $dir_x + $plane_x * cameraX; + double ray_dir_y = $dir_y + $plane_y * cameraX; + + // which box of the map we're in + int map_x = int($pos_x); + int map_y = int($pos_y); + + // length of ray from one x or y-side to next x or y-side + double delta_dist_x = std::abs(1.0 / ray_dir_x); + double delta_dist_y = std::abs(1.0 / ray_dir_y); + + int step_x = 0; + int step_y = 0; + int hit = 0; + int side = 0; + + // length of ray from current pos to next x or y-side + double side_dist_x; + double side_dist_y; + + if(ray_dir_x < 0) { + step_x = -1; + side_dist_x = ($pos_x - map_x) * delta_dist_x; + } else { + step_x = 1; + side_dist_x = (map_x + 1.0 - $pos_x) * delta_dist_x; + } + + if(ray_dir_y < 0) { + step_y = -1; + side_dist_y = ($pos_y - map_y) * delta_dist_y; + } else { + step_y = 1; + side_dist_y = (map_y + 1.0 - $pos_y) * delta_dist_y; + } + + // perform DDA + while(hit == 0) { + if(side_dist_x < side_dist_y) { + side_dist_x += delta_dist_x; + map_x += step_x; + side = 0; + } else { + side_dist_y += delta_dist_y; + map_y += step_y; + side = 1; + } + + if($walls[map_y][map_x] == 1) hit = 1; + } + + if(side == 0) { + perp_wall_dist = (side_dist_x - delta_dist_x); + } else { + perp_wall_dist = (side_dist_y - delta_dist_y); + } + + int line_height = int($height / perp_wall_dist); + + int draw_start = -line_height / 2 + $height / 2 + $pitch; + if(draw_start < 0) draw_start = 0; + + int draw_end = line_height / 2 + $height / 2 + $pitch; + if(draw_end >= $height) draw_end = $height - 1; + + // BUG: I thought I got rid of this + auto texture = textures::get_surface($tiles[map_y][map_x]); + + // calculate value of wall_x + double wall_x; // where exactly the wall was hit + if(side == 0) { + wall_x = $pos_y + perp_wall_dist * ray_dir_y; + } else { + wall_x = $pos_x + perp_wall_dist * ray_dir_x; + } + wall_x -= floor(wall_x); + + // x coorindate on the texture + int tex_x = int(wall_x * double(texture_width)); + if(side == 0 && ray_dir_x > 0) tex_x = texture_width - tex_x - 1; + if(side == 1 && ray_dir_y < 0) tex_x = texture_width - tex_x - 1; + + // LODE: an integer-only bresenham or DDA like algorithm could make the texture coordinate stepping faster + + // How much to increase the texture coordinate per screen pixel + double step = 1.0 * texture_height / line_height; + // Starting texture coordinate + double tex_pos = (draw_start - $pitch - $height / 2 + line_height / 2) * step; + + for(int y = draw_start; y < draw_end; y++) { + int tex_y = (int)tex_pos & (texture_height - 1); + tex_pos += step; + RGBA pixel = texture[texture_height * tex_y + tex_x]; + int light_level = lights[map_y][map_x]; + $pixels[pixcoord(x, y)] = lighting_calc(pixel, perp_wall_dist, light_level); + } + + // SET THE ZBUFFER FOR THE SPRITE CASTING + $zbuffer[x] = perp_wall_dist; + } +} + +void Raycaster::draw_ceiling_floor() { + // BUG: this should come from the texture's config + constexpr const int texture_width = TEXTURE_WIDTH; + constexpr const int texture_height = TEXTURE_HEIGHT; + + auto &lights = $level.lights->lighting(); + size_t surface_i = 0; + const RGBA *floor_texture = textures::get_surface(surface_i); + const RGBA *ceiling_texture = textures::get_ceiling(surface_i); + + for(int y = $height / 2 + 1; y < $height; ++y) { + // rayDir for leftmost ray (x=0) and rightmost (x = w) + float ray_dir_x0 = $dir_x - $plane_x; + float ray_dir_y0 = $dir_y - $plane_y; + float ray_dir_x1 = $dir_x + $plane_x; + float ray_dir_y1 = $dir_y + $plane_y; + + // current y position compared to the horizon + int p = y - $height / 2; + + // vertical position of the camera + // 0.5 will the camera at the center horizon. For a + // different value you need a separate loop for ceiling + // and floor since they're no longer symmetrical. + float pos_z = 0.5 * $height; + + // horizontal distance from the camera to the floor for the current row + // 0.5 is the z position exactly in the middle between floor and ceiling + // See NOTE in Lode's code for more. + float row_distance = pos_z / p; + + // calculate the real world step vector we have to add for each x (parallel to camera plane) + // adding step by step avoids multiplications with a wight in the inner loop + float floor_step_x = row_distance * (ray_dir_x1 - ray_dir_x0) / $width; + float floor_step_y = row_distance * (ray_dir_y1 - ray_dir_y0) / $width; + + // real world coordinates of the leftmost column. + // This will be updated as we step to the right + float floor_x = $pos_x + row_distance * ray_dir_x0; + float floor_y = $pos_y + row_distance * ray_dir_y0; + + for(int x = 0; x < $width; ++x) { + // the cell coord is simply taken from the int parts of + // floor_x and floor_y. + int cell_x = int(floor_x); + int cell_y = int(floor_y); + + // get the texture coordinate from the fractional part + int tx = int(texture_width * (floor_x - cell_x)) & (texture_width - 1); + int ty = int(texture_width * (floor_y - cell_y)) & (texture_height - 1); + + floor_x += floor_step_x; + floor_y += floor_step_y; + + // now get the pixel from the texture + RGBA color; + // this uses the previous ty/tx fractional parts of + // floor_x cell_x to find the texture x/y. How? + int map_x = int(floor_x); + int map_y = int(floor_y); + + if(!matrix::inbounds(lights, map_x, map_y)) continue; + + int light_level = lights[map_y][map_x]; + size_t new_surface_i = $tiles[map_y][map_x]; + + if(new_surface_i != surface_i) { + surface_i = new_surface_i; + floor_texture = textures::get_surface(surface_i); + ceiling_texture = textures::get_ceiling(surface_i); + } + + // NOTE: use map_x/y to get the floor, ceiling texture. + + // FLOOR + color = floor_texture[texture_width * ty + tx]; + $pixels[pixcoord(x, y)] = lighting_calc(color, row_distance, light_level); + + // CEILING + color = ceiling_texture[texture_width * ty + tx]; + $pixels[pixcoord(x, $height - y - 1)] = lighting_calc(color, row_distance, light_level); + } + } +} + +void Raycaster::update() { + draw_ceiling_floor(); + cast_rays(); + draw_pixel_buffer(); + sprite_casting(); +} + +// BUG: if target is a rendertarget then I can say it has to be the viewport only, then I can get rid of $screen_pos_x/y +void Raycaster::render(sf::RenderTarget& target) { + target.draw($view_sprite); + + for(auto [sprite, effect] : $sprites_to_render) { + target.draw(*sprite, effect.get()); + } +} + +void Raycaster::update_sprite(DinkyECS::Entity ent, components::Sprite& sprite) { + auto sprite_txt = textures::get_sprite(sprite.name); + $sprites.insert_or_assign(ent, sprite_txt); +} + +void Raycaster::update_level(GameDB::Level& level) { + $sprites.clear(); + $sprite_order.clear(); + + $level = level; + + $tiles = $level.map->tiles(); + $walls = $level.map->walls(); + + $level.world->query([&](const auto ent, auto& sprite) { + // player doesn't need a sprite + if($level.player != ent) { + update_sprite(ent, sprite); + } + }); +} + +void Raycaster::init_shaders() { + $brightness = shaders::get("rayview_sprites"); +} + + +Point Raycaster::plan_move(int dir, bool strafe) { + $camera.t = 0.0; + if(strafe) { + $camera.target_x = $pos_x + int(-$dir_y * 1.5 * dir); + $camera.target_y = $pos_y + int($dir_x * 1.5 * dir); + } else { + $camera.target_x = $pos_x + int($dir_x * 1.5 * dir); + $camera.target_y = $pos_y + int($dir_y * 1.5 * dir); + } + + return {size_t($camera.target_x), size_t($camera.target_y)}; +} + +void Raycaster::plan_rotate(int dir, float amount) { + $camera.t = 0.0; + double angle_dir = std::numbers::pi * amount * float(dir); + + $camera.target_dir_x = $dir_x * cos(angle_dir) - $dir_y * sin(angle_dir); + $camera.target_dir_y = $dir_x * sin(angle_dir) + $dir_y * cos(angle_dir); + + $camera.target_plane_x = $plane_x * cos(angle_dir) - $plane_y * sin(angle_dir); + $camera.target_plane_y = $plane_x * sin(angle_dir) + $plane_y * cos(angle_dir); +} + +bool Raycaster::play_rotate() { + $camera.t += $camera.rot_speed; + $dir_x = std::lerp($dir_x, $camera.target_dir_x, $camera.t); + $dir_y = std::lerp($dir_y, $camera.target_dir_y, $camera.t); + $plane_x = std::lerp($plane_x, $camera.target_plane_x, $camera.t); + $plane_y = std::lerp($plane_y, $camera.target_plane_y, $camera.t); + update_camera_aiming(); + + return $camera.t >= 1.0; +} + +bool Raycaster::play_move() { + $camera.t += $camera.move_speed; + $pos_x = std::lerp($pos_x, $camera.target_x, $camera.t); + $pos_y = std::lerp($pos_y, $camera.target_y, $camera.t); + update_camera_aiming(); + + return $camera.t >= 1.0; +} + +void Raycaster::abort_plan() { + $camera.target_x = $pos_x; + $camera.target_y = $pos_y; + update_camera_aiming(); +} + +bool Raycaster::is_target(DinkyECS::Entity entity) { + (void)entity; + return false; +} + +void Raycaster::update_camera_aiming() { + aiming_at = { size_t($pos_x + $dir_x), size_t($pos_y + $dir_y) }; + camera_at = { size_t($camera.target_x), size_t($camera.target_y) }; +} diff --git a/src/graphics/raycaster.hpp b/src/graphics/raycaster.hpp new file mode 100644 index 0000000..922eda9 --- /dev/null +++ b/src/graphics/raycaster.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include "algos/matrix.hpp" +#include "algos/spatialmap.hpp" +#include "game/level.hpp" +#include "graphics/textures.hpp" +#include + +using matrix::Matrix; +using RGBA = uint32_t; + +struct CameraLOL { + double t = 0.0; + double move_speed = 0.1; + double rot_speed = 0.06; + double target_x = 0.0; + double target_y = 0.0; + double target_dir_x = 0.0; + double target_dir_y = 0.0; + double target_plane_x = 0.0; + double target_plane_y = 0.0; +}; + +using SpriteRender = std::pair, std::shared_ptr>; + +struct Raycaster { + sf::Texture $view_texture; + sf::Sprite $view_sprite; + int $width; + int $height; + int $screen_pos_x; + int $screen_pos_y; + std::vector $zbuffer; // width + + int $pitch=0; + sf::Clock $clock; + std::shared_ptr $brightness = nullptr; + double $pos_x = 0; + double $pos_y = 0; + + // initial direction vector + double $dir_x = 1; + double $dir_y = 0; + + // the 2d raycaster version of camera plane + double $plane_x = 0.0; + double $plane_y = 0.66; + Point aiming_at{0,0}; + Point camera_at{0,0}; + CameraLOL $camera; + std::unique_ptr $pixels = nullptr; + + std::unordered_map $sprites; + // BUG: this can be way better I think + std::vector $sprites_to_render; + SortedEntities $sprite_order; + + GameDB::Level $level; + Matrix $tiles; + Matrix $walls; + + Raycaster(int x, int y, int width, int height); + + void cast_rays(); + void draw_ceiling_floor(); + void draw_pixel_buffer(); + void sprite_casting(); + void update(); + void render(sf::RenderTarget& target); + + void sort_sprites(std::vector& order, std::vector& dist, int amount); + void set_position(int x, int y); + + inline size_t pixcoord(int x, int y) { + return ((y) * $width) + (x); + } + + void update_level(GameDB::Level& level); + void update_sprite(DinkyECS::Entity ent, components::Sprite& sprite); + void init_shaders(); + + void position_camera(float player_x, float player_y); + Point plan_move(int dir, bool strafe); + void plan_rotate(int dir, float amount); + + bool play_rotate(); + bool play_move(); + + void abort_plan(); + bool is_target(DinkyECS::Entity entity); + void update_camera_aiming(); + + // BUG: these should go away when Bug #42 is solved + void apply_sprite_effect(std::shared_ptr effect, float width, float height); + std::shared_ptr apply_lighting_effect(components::Position& sprite_pos, matrix::Matrix &lights); +}; diff --git a/src/graphics/scene.cpp b/src/graphics/scene.cpp new file mode 100644 index 0000000..d0f4bb8 --- /dev/null +++ b/src/graphics/scene.cpp @@ -0,0 +1,189 @@ +#include "graphics/scene.hpp" +#include "graphics/animation.hpp" +#include "graphics/shaders.hpp" +#include +#include "dbc.hpp" + +const bool DEBUG=false; + +namespace scene { + Element Engine::config_scene_element(nlohmann::json& config, bool duped) { + std::string sprite_name = config["sprite"]; + auto st = textures::get_sprite(sprite_name, duped); + + float scale_x = config["scale_x"]; + float scale_y = config["scale_y"]; + float x = config["x"]; + float y = config["y"]; + bool flipped = config["flipped"]; + + // BUG: put the .json file to load as a default/optional arg + auto anim = animation::load("./assets/animation.json", sprite_name); + anim.play(); + + anim.transform.flipped = flipped; + + std::string cell = config["cell"]; + std::string name = config["name"]; + + bool at_mid = config["at_mid"]; + + sf::Text text(*$ui.$font, "", 60); + + return {name, st, anim, cell, {scale_x, scale_y}, {x, y}, at_mid, flipped, nullptr, text}; + } + + Engine::Engine(components::AnimatedScene& scene) : + $scene(scene) + { + for(auto& config : $scene.actors) { + auto element = config_scene_element(config, false); + dbc::check(!$actor_name_ids.contains(element.name), + $F("actors key {} already exists", element.name)); + $actors.push_back(element); + $actor_name_ids.try_emplace(element.name, $actors.size() - 1); + } + + for(auto& fixture : $scene.fixtures) { + auto element = config_scene_element(fixture, true); + $fixtures.push_back(element); + } + + for(auto& line : $scene.layout) { + $layout.append(line); + } + } + + void Engine::init() { + $ui.position(0,0, BOSS_VIEW_WIDTH, BOSS_VIEW_HEIGHT); + $ui.set($ui.MAIN, {$ui.$parser, guecs::THEME.TRANSPARENT}); + auto& background = $ui.get($ui.MAIN); + background.set_sprite($scene.background, true); + + $ui.layout($layout); + + for(auto& actor : $actors) { + actor.pos = position_sprite(actor.st, actor.cell, + actor.scale, actor.at_mid, actor.pos.x, actor.pos.y); + } + + for(auto& fixture : $fixtures) { + fixture.pos = position_sprite(fixture.st, fixture.cell, + fixture.scale, fixture.at_mid, fixture.pos.x, fixture.pos.y); + } + } + + void Engine::apply_effect(const std::string& actor, const std::string& shader) { + auto& element = actor_config(actor); + element.effect = shaders::get(shader); + } + + void Engine::attach_text(const std::string& actor, const std::string& text) { + auto& element = actor_config(actor); + element.text.setPosition(element.pos); + element.text.setScale(element.scale); + element.text.setFillColor(sf::Color::Red); + element.text.setOutlineThickness(2.0f); + element.text.setOutlineColor(sf::Color::Black); + element.text.setString(text); + } + + bool Engine::mouse(float x, float y, guecs::Modifiers mods) { + return $ui.mouse(x, y, mods); + } + + void Engine::render(sf::RenderTexture& view) { + $ui.render(view); + + for(auto& fixture : $fixtures) { + view.draw(*fixture.st.sprite, fixture.effect.get()); + } + + for(auto& actor : $actors) { + view.draw(*actor.st.sprite, actor.effect.get()); + if(actor.anim.playing) view.draw(actor.text); + } + + $camera.render(view); + if(DEBUG) $ui.debug_layout(view); + } + + Element& Engine::actor_config(const std::string& actor) { + dbc::check($actor_name_ids.contains(actor), $F("scene does not contain actor {}", actor)); + return $actors.at($actor_name_ids.at(actor)); + } + + void Engine::move_actor(const std::string& actor, const std::string& cell_name) { + auto& config = actor_config(actor); + config.cell = cell_name; + config.pos = position_sprite(config.st, config.cell, config.scale, config.at_mid); + } + + void Engine::animate_actor(const std::string& actor, const std::string& form) { + auto& config = actor_config(actor); + config.anim.set_form(form); + + if(!config.anim.playing) { + config.anim.play(); + } + } + + inline void this_is_stupid_refactor(std::vector& elements) { + for(auto& element : elements) { + if(element.anim.playing) { + element.anim.update(); + element.anim.motion(*element.st.sprite, element.pos, element.scale); + element.anim.apply(*element.st.sprite); + if(element.effect != nullptr) element.anim.apply_effect(element.effect); + } + } + } + + void Engine::update() { + this_is_stupid_refactor($fixtures); + this_is_stupid_refactor($actors); + } + + sf::Vector2f Engine::position_sprite(textures::SpriteTexture& st, const std::string& cell_name, sf::Vector2f scale, bool at_mid, float x_diff, float y_diff) { + auto& cell = $ui.cell_for(cell_name); + float x = float(at_mid ? cell.mid_x : cell.x); + float y = float(at_mid ? cell.mid_y : cell.y); + + sf::Vector2f pos{x + x_diff, y + y_diff}; + st.sprite->setPosition(pos); + st.sprite->setScale(scale); + + return pos; + } + + void Engine::zoom(float mid_x, float mid_y, const std::string& style, float scale) { + $camera.style(style); + $camera.scale(scale); + $camera.move(mid_x, mid_y); + $camera.play(); + } + + void Engine::zoom(const std::string &actor, const std::string& style, float scale) { + auto& config = actor_config(actor); + auto bounds = config.st.sprite->getGlobalBounds(); + float mid_x = config.pos.x + bounds.size.x / 2.0f; + float mid_y = config.pos.y + bounds.size.y / 2.0f; + + zoom(mid_x, mid_y, style, scale); + } + + void Engine::set_end_cb(std::function cb) { + for(auto& actor : $actors) { + actor.anim.onLoop = [&,cb](auto& seq, auto& tr) -> bool { + seq.current = tr.toggled ? seq.frame_count - 1 : 0; + cb(); + actor.effect = nullptr; + return tr.looped; + }; + } + } + + void Engine::reset(sf::RenderTexture& view) { + $camera.reset(view); + } +} diff --git a/src/graphics/scene.hpp b/src/graphics/scene.hpp new file mode 100644 index 0000000..2db737a --- /dev/null +++ b/src/graphics/scene.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include "graphics/textures.hpp" +#include +#include +#include "graphics/camera.hpp" +#include +#include "graphics/animation.hpp" +#include "game/components.hpp" + +namespace scene { + using std::shared_ptr; + using namespace textures; + + struct Element { + std::string name; + textures::SpriteTexture st; + animation::Animation anim; + std::string cell; + sf::Vector2f scale{1.0f, 1.0f}; + sf::Vector2f pos{0.0f, 0.0f}; + bool at_mid=false; + bool flipped=false; + std::shared_ptr effect = nullptr; + sf::Text text; + }; + + struct Engine { + sf::Clock $clock; + guecs::UI $ui; + components::AnimatedScene& $scene; + std::string $layout; + std::unordered_map $actor_name_ids; + std::vector $fixtures; + std::vector $actors; + cinematic::Camera $camera{{BOSS_VIEW_WIDTH, BOSS_VIEW_HEIGHT}, "scene"}; + + Engine(components::AnimatedScene& scene); + + void init(); + void render(sf::RenderTexture& view); + void update(); + bool mouse(float x, float y, guecs::Modifiers mods); + void attach_text(const std::string& actor, const std::string& text); + Element config_scene_element(nlohmann::json& config, bool duped); + + sf::Vector2f position_sprite(textures::SpriteTexture& st, const std::string& cell_name, sf::Vector2f scale, bool at_mid, float x_diff=0.0f, float y_diff=0.0f); + + void move_actor(const std::string& actor, const std::string& cell_name); + void animate_actor(const std::string& actor, const std::string& form); + void apply_effect(const std::string& actor, const std::string& shader); + Element& actor_config(const std::string& actor); + void zoom(const std::string& actor, const std::string& style, float scale=0.9f); + void zoom(float mid_x, float mid_y, const std::string& style, float scale); + void reset(sf::RenderTexture& view); + void set_end_cb(std::function cb); + }; +} diff --git a/src/graphics/shaders.cpp b/src/graphics/shaders.cpp new file mode 100644 index 0000000..f57f4fc --- /dev/null +++ b/src/graphics/shaders.cpp @@ -0,0 +1,78 @@ +#include "graphics/shaders.hpp" +#include +#include "dbc.hpp" +#include +#include "game/config.hpp" +#include "constants.hpp" +#include + +namespace shaders { + using std::shared_ptr, std::make_shared; + + static ShaderManager SMGR; + static bool INITIALIZED = false; + static int VERSION = 0; + + inline void configure_shader_defaults(std::shared_ptr ptr) { + ptr->setUniform("source", sf::Shader::CurrentTexture); + } + + bool load_shader(std::string name, nlohmann::json& settings) { + std::string file_name = settings["file_name"]; + auto ptr = std::make_shared(); + bool good = ptr->loadFromFile(file_name, sf::Shader::Type::Fragment); + + if(good) { + configure_shader_defaults(ptr); + SMGR.shaders.try_emplace(name, name, file_name, ptr); + } + return good; + } + + void init() { + if(!INITIALIZED) { + dbc::check(sf::Shader::isAvailable(), "no shaders?!"); + INITIALIZED = true; + auto config = settings::get("shaders"); + bool good = load_shader("ERROR", config["ERROR"]); + dbc::check(good, "Failed to load ERROR shader. Look in assets/shaders.json"); + + for(auto& [name, settings] : config.json().items()) { + if(name == "ERROR") continue; + + dbc::check(!SMGR.shaders.contains(name), + $F("shader name '{}' duplicated in assets/shaders.json", name)); + good = load_shader(name, settings); + + if(!good) { + dbc::log($F("failed to load shader {}", name)); + SMGR.shaders.insert_or_assign(name, SMGR.shaders.at("ERROR")); + } + } + } + } + + std::shared_ptr get(const std::string& name) { + dbc::check(INITIALIZED, "you forgot to shaders::init()"); + dbc::check(SMGR.shaders.contains(name), + $F("shader name '{}' not in assets/shaders.json", name)); + auto& rec = SMGR.shaders.at(name); + return rec.ptr; + } + + int reload() { + VERSION++; + INITIALIZED = false; + SMGR.shaders.clear(); + init(); + return VERSION; + } + + bool updated(int my_version) { + return my_version != VERSION; + } + + int version() { + return VERSION; + } +}; diff --git a/src/graphics/shaders.hpp b/src/graphics/shaders.hpp new file mode 100644 index 0000000..f3975a7 --- /dev/null +++ b/src/graphics/shaders.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "algos/matrix.hpp" +#include + +namespace shaders { + struct Record { + std::string name; + std::string file_name; + std::shared_ptr ptr = nullptr; + }; + + struct ShaderManager { + std::unordered_map shaders; + }; + + std::shared_ptr get(const std::string& name); + void init(); + bool load_shader(std::string& name, nlohmann::json& settings); + bool updated(int my_version); + int reload(); + int version(); +} diff --git a/src/graphics/textures.cpp b/src/graphics/textures.cpp new file mode 100644 index 0000000..0870f68 --- /dev/null +++ b/src/graphics/textures.cpp @@ -0,0 +1,209 @@ +#include "graphics/textures.hpp" +#include +#include "dbc.hpp" +#include +#include "game/config.hpp" +#include "constants.hpp" +#include +#include + +namespace textures { + using std::shared_ptr, std::make_shared, nlohmann::json, std::string; + namespace fs = std::filesystem; + + static TextureManager TMGR; + static bool initialized = false; + static bool failure = false; + + void load_sprite_textures(SpriteTextureMap &mapping, json &config, bool smooth) { + for(auto& [name, settings] : config.items()) { + const string& path = settings["path"]; + dbc::check(fs::exists(path), $F("texture at {} doesn't exist", path)); + auto texture = make_shared(path); + + texture->setSmooth(smooth); + auto sprite = make_shared(*texture); + + int width = settings["frame_width"]; + int height = settings["frame_height"]; + + dbc::check(width % 2 == 0 && height % 2 == 0, + $F("sprite {}:{} has invalid frame size {}:{}", + path, name, width, height)); + + sf::Vector2i frame_size{width, height}; + + sprite->setTextureRect({{0,0}, frame_size}); + + dbc::check(!mapping.contains(name), + $F("duplicate sprite/icon name {}", (string)name)); + mapping.try_emplace(name, sprite, texture, frame_size); + } + } + + void load_sprites() { + auto sprites = settings::get("config"); + bool smooth = sprites["graphics"]["smooth_textures"]; + + load_sprite_textures(TMGR.sprite_textures, sprites["sprites"], smooth); + + auto icons = settings::get("assets/icons.json"); + load_sprite_textures(TMGR.icon_textures, icons.json(), smooth); + } + + inline void resize_shit(size_t size) { + TMGR.surfaces.resize(size); + TMGR.ceilings.resize(size); + TMGR.map_tile_set.resize(size); + TMGR.ambient_light.resize(size); + } + + void load_tiles() { + auto assets = settings::get("tiles"); + auto &tiles = assets.json(); + + resize_shit(tiles.size()); + + for(auto &el : tiles.items()) { + auto &config = el.value(); + const string& texture_fname = config["texture"]; + // BUG: if the tiles.json ids aren't exactly in order this fails, but do I need this? + size_t surface_i = config["id"]; + + dbc::check(!TMGR.name_to_id.contains(el.key()), + $F("duplicate key in textures {}", + (string)el.key())); + + TMGR.name_to_id.insert_or_assign(el.key(), surface_i); + + if(surface_i >= tiles.size()) { + resize_shit(surface_i + 1); + } + + TMGR.map_tile_set[surface_i] = config["display"]; + TMGR.ambient_light[surface_i] = config["light"]; + TMGR.surfaces[surface_i] = load_image(texture_fname); + + // NOTE: ceilings defaults to 0 which is floor texture so only need to update + if(config.contains("ceiling")) { + const string& name = config["ceiling"]; + + dbc::check(tiles.contains(name), $F("invalid ceiling name {} in tile config {}", name, (string)el.key())); + + auto& ceiling = tiles[name]; + TMGR.ceilings[surface_i] = ceiling["id"]; + } + } + } + + void load_map_tiles() { + auto config = settings::get("map_tiles"); + json& tiles = config.json(); + + for(auto tile : tiles) { + sf::Vector2i coords{tile["x"], tile["y"]}; + dbc::check(coords.x % ICONGEN_MAP_TILE_DIM == 0, "x coordinates wrong in map"); + dbc::check(coords.y % ICONGEN_MAP_TILE_DIM == 0, "y coordinates wrong in map"); + + sf::IntRect square{coords, {ICONGEN_MAP_TILE_DIM, ICONGEN_MAP_TILE_DIM}}; + sf::Sprite sprite{TMGR.map_sprite_sheet, square}; + wchar_t display = tile["display"]; + + dbc::check(!TMGR.map_sprites.contains(display), + $F("duplicate tile display {} in map_tiles.json", int(display))); + + TMGR.map_sprites.try_emplace(display, sprite); + } + } + + void init() { + dbc::check(!failure, "YOU HAD A CATASTROPHIC TEXTURES FAILURE, FIX IT"); + + try { + if(!initialized) { + load_tiles(); + load_sprites(); + load_map_tiles(); + initialized = true; + } + } catch(...) { + failure = true; + throw; + } + } + + SpriteTexture& get(const string& name, SpriteTextureMap& mapping) { + dbc::check(initialized, "you forgot to call textures::init()"); + dbc::check(mapping.contains(name), + $F("!!!!! textures do not contain {} sprite", name)); + + auto& result = mapping.at(name); + + dbc::check(result.sprite != nullptr, + $F("bad sprite from textures::get named {}", name)); + dbc::check(result.texture != nullptr, + $F("bad texture from textures::get named {}", name)); + + return result; + } + + SpriteTexture get_sprite(const string& name, bool duped) { + auto& st = get(name, TMGR.sprite_textures); + + if(duped) { + st.sprite = make_shared(*st.sprite); + } + + return st; + } + + SpriteTexture get_icon(const string& name) { + return get(name, TMGR.icon_textures); + } + + sf::Image load_image(const string& filename) { + sf::Image texture; + bool good = texture.loadFromFile(filename); + dbc::check(good, $F("failed to load {}", filename)); + return texture; + } + + std::vector& get_ambient_light() { + return TMGR.ambient_light; + } + + std::vector& get_map_tile_set() { + return TMGR.map_tile_set; + } + + const uint32_t* get_surface(size_t num) { + return (const uint32_t *)TMGR.surfaces[num].getPixelsPtr(); + } + + sf::Image& get_surface_img(size_t num) { + return TMGR.surfaces[num]; + } + + const uint32_t* get_ceiling(size_t num) { + size_t ceiling_num = TMGR.ceilings[num]; + return (const uint32_t *)TMGR.surfaces[ceiling_num].getPixelsPtr(); + } + + size_t get_id(const string& name) { + dbc::check(TMGR.name_to_id.contains(name), + $F("there is no texture named {} in tiles.json", name)); + return TMGR.name_to_id.at(name); + } + + sf::Sprite& get_map_sprite(wchar_t display) { + dbc::check(TMGR.map_sprites.contains(display), + $F("map_sprites.json doesn't have {} sprite", int(display))); + + return TMGR.map_sprites.at(display); + } + + size_t door_for_wall(size_t wall_id) { + size_t plain_door_id = textures::get_id("door_plain"); + return plain_door_id; + } +}; diff --git a/src/graphics/textures.hpp b/src/graphics/textures.hpp new file mode 100644 index 0000000..d3bceab --- /dev/null +++ b/src/graphics/textures.hpp @@ -0,0 +1,56 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include "algos/matrix.hpp" + +namespace textures { + + struct SpriteTexture { + std::shared_ptr sprite = nullptr; + std::shared_ptr texture = nullptr; + sf::Vector2i frame_size; + }; + + using SpriteTextureMap = std::unordered_map; + + struct TextureManager { + std::vector surfaces; + std::vector ceilings; + std::vector map_tile_set; + std::vector ambient_light; + SpriteTextureMap sprite_textures; + SpriteTextureMap icon_textures; + std::unordered_map name_to_id; + std::unordered_map map_sprites; + sf::Texture map_sprite_sheet{"./assets/map_tiles.png"}; + }; + + void init(); + + SpriteTexture get_sprite(const std::string& name, bool duped=false); + SpriteTexture get_icon(const std::string& name); + + sf::Image load_image(const std::string& filename); + + std::vector& get_ambient_light(); + + std::vector& get_map_tile_set(); + + const uint32_t* get_surface(size_t num); + + sf::Image& get_surface_img(size_t num); + + const uint32_t* get_ceiling(size_t num); + + sf::Sprite& get_map_sprite(wchar_t display); + + size_t get_id(const std::string& name); + + size_t door_for_wall(size_t wall_id); +} diff --git a/src/gui/backend.cpp b/src/gui/backend.cpp new file mode 100644 index 0000000..1d02cd4 --- /dev/null +++ b/src/gui/backend.cpp @@ -0,0 +1,78 @@ +#include "backend.hpp" +#include "graphics/shaders.hpp" +#include "game/sound.hpp" +#include "graphics/textures.hpp" +#include "game/config.hpp" +#include "graphics/palette.hpp" + +namespace gui { + using namespace nlohmann; + + guecs::SpriteTexture Backend::get_sprite(const string& name) { + auto sp = textures::get_sprite(name); + return {sp.sprite, sp.texture, sp.frame_size}; + } + + guecs::SpriteTexture Backend::get_icon(const string& name) { + auto sp = textures::get_icon(name); + return {sp.sprite, sp.texture, sp.frame_size}; + } + + Backend::Backend() { + sound::init(); + shaders::init(); + textures::init(); + } + + void Backend::sound_play(const string& name) { + sound::play(name); + } + + void Backend::sound_stop(const string& name) { + sound::stop(name); + } + + std::shared_ptr Backend::get_shader(const std::string& name) { + return shaders::get(name); + } + + bool Backend::shader_updated() { + if(shaders::updated($shaders_version)) { + $shaders_version = shaders::version(); + return true; + } else { + return false; + } + } + + guecs::Theme Backend::theme() { + palette::init(); + auto config = settings::Config("assets/config.json")["theme"]; + + guecs::Theme theme { + .BLACK=palette::get("gui/theme:black"), + .DARK_DARK=palette::get("gui/theme:dark_dark"), + .DARK_MID=palette::get("gui/theme:dark_mid"), + .DARK_LIGHT=palette::get("gui/theme:dark_light"), + .MID=palette::get("gui/theme:mid"), + .LIGHT_DARK=palette::get("gui/theme:light_dark"), + .LIGHT_MID=palette::get("gui/theme:light_mid"), + .LIGHT_LIGHT=palette::get("gui/theme:light_light"), + .WHITE=palette::get("gui/theme:white"), + .TRANSPARENT = palette::get("color:transparent") + }; + + theme.PADDING = config["padding"]; + theme.BORDER_PX = config["border_px"]; + theme.TEXT_SIZE = config["text_size"]; + theme.LABEL_SIZE = config["label_size"]; + theme.FILL_COLOR = palette::get("gui/theme:fill_color"); + theme.TEXT_COLOR = palette::get("gui/theme:text_color"); + theme.BG_COLOR = palette::get("gui/theme:bg_color"); + theme.BORDER_COLOR = palette::get("gui/theme:border_color"); + theme.BG_COLOR_DARK = palette::get("gui/theme:bg_color_dark"); + theme.FONT_FILE_NAME = settings::Config::path_to(config["font_file_name"]).string(); + + return theme; + } +} diff --git a/src/gui/backend.hpp b/src/gui/backend.hpp new file mode 100644 index 0000000..3a5f9ca --- /dev/null +++ b/src/gui/backend.hpp @@ -0,0 +1,20 @@ +#include "guecs/ui.hpp" + +namespace gui { + using std::string; + + class Backend : public guecs::Backend { + int $shaders_version = 0; + + public: + + Backend(); + guecs::SpriteTexture get_sprite(const string& name); + guecs::SpriteTexture get_icon(const string& name); + void sound_play(const string& name); + void sound_stop(const string& name); + std::shared_ptr get_shader(const std::string& name); + bool shader_updated(); + guecs::Theme theme(); + }; +} diff --git a/src/gui/debug_ui.cpp b/src/gui/debug_ui.cpp new file mode 100644 index 0000000..f703a5c --- /dev/null +++ b/src/gui/debug_ui.cpp @@ -0,0 +1,102 @@ +#include "gui/debug_ui.hpp" +#include "constants.hpp" +#include "events.hpp" +#include +#include +#include +#include "game/components.hpp" + +namespace gui { + using namespace guecs; + + void DebugUI::init(lel::Cell cell) { + $gui.position(cell.x, cell.y, cell.w, cell.h); + $gui.layout( + "[*%(100,400)debug_text]" + "[_]" + "[_]" + "[_]" + "[spawn1|spawn2|spawn3]" + "[spawn4|spawn5|spawn6]"); + + add_spawn_button("RAT_GIANT", "rat_with_sword", "spawn4"); + + $gui.init(); + } + + void DebugUI::add_spawn_button(std::string enemy_key, std::string sprite_name, std::string region) { + auto button = $gui.entity(region); + $gui.set(button, { + [this, enemy_key](auto){ spawn(enemy_key); } + }); + $gui.set(button, { sprite_name}); + } + + void DebugUI::spawn(const std::string& enemy_key) { + (void)enemy_key; + dbc::log("THIS FUNCTION NEEDS A REWRITE"); + // auto ent = $level_mgr.spawn_enemy(enemy_key); + // auto& level = $level_mgr.current(); + // level.world->send(game::Event::ENTITY_SPAWN, ent, {}); + } + + void DebugUI::render(sf::RenderWindow& window) { + if(active) { + auto& level = GameDB::current_level(); + auto player = level.world->get_the(); + auto player_combat = level.world->get(player.entity); + auto map = level.map; + + std::wstring stats = $F(L"STATS\n" + L"HP: {}\n" + L"mean:{:>8.5}\n" + L"sdev: {:>8.5}\n" + L"min: {:>8.5}\n" + L"max: {:>8.5}\n" + L"count:{:<10}\n" + L"level: {} size: {}x{}\n\n" + L"VSync? {}\n" + L"FR Limit: {}\n" + L"Debug? {}\n\n", + player_combat.hp, $stats.mean(), $stats.stddev(), $stats.min, + $stats.max, $stats.n, level.index, map->width(), map->height(), + VSYNC, FRAME_LIMIT, DEBUG_BUILD); + + $gui.show_text("debug_text", stats); + $gui.render(window); + // $gui.debug_layout(window); + } + } + + void DebugUI::debug() { + active = !active; + + if(active) { + auto& level = GameDB::current_level(); + // it's on now, enable things + auto player = level.world->get_the(); + auto& player_combat = level.world->get(player.entity); + player_combat.hp = player_combat.max_hp; + $gui.show_text("debug_text", L"STATS"); + } else { + // it's off now, close it + $gui.close("debug_text"); + } + } + + bool DebugUI::mouse(float x, float y, guecs::Modifiers mods) { + return $gui.mouse(x, y, mods); + } + + Stats::TimeBullshit DebugUI::time_start() { + return $stats.time_start(); + } + + void DebugUI::sample_time(Stats::TimeBullshit start) { + $stats.sample_time(start); + } + + void DebugUI::reset_stats() { + $stats.reset(); + } +} diff --git a/src/gui/debug_ui.hpp b/src/gui/debug_ui.hpp new file mode 100644 index 0000000..5474d84 --- /dev/null +++ b/src/gui/debug_ui.hpp @@ -0,0 +1,26 @@ +#pragma once +#include "game/level.hpp" +#include +#include +#include +#include "algos/stats.hpp" + +namespace gui { + class DebugUI { + public: + Stats $stats; + guecs::UI $gui; + bool active = false; + + void init(lel::Cell cell); + void render(sf::RenderWindow& window); + bool mouse(float x, float y, guecs::Modifiers mods); + void debug(); + void spawn(const std::string& enemy_key); + void add_spawn_button(std::string enemy_key, std::string sprite_name, std::string region); + + Stats::TimeBullshit time_start(); + void sample_time(Stats::TimeBullshit start); + void reset_stats(); + }; +} diff --git a/src/gui/dnd_loot.cpp b/src/gui/dnd_loot.cpp new file mode 100644 index 0000000..f3a999f --- /dev/null +++ b/src/gui/dnd_loot.cpp @@ -0,0 +1,328 @@ +#include "gui/guecstra.hpp" +#include "gui/dnd_loot.hpp" + +namespace gui { + using Event = game::Event; + + DNDLoot::DNDLoot(StatusUI& status_ui, LootUI& loot_ui, sf::RenderWindow &window, routing::Router& router) : + $status_ui(status_ui), + $loot_ui(loot_ui), + $window(window), + $router(router) + { + event(Event::START); + } + + bool DNDLoot::event(Event ev, std::any data) { + switch($state) { + FSM_STATE(DNDState, START, ev); + FSM_STATE(DNDState, LOOTING, ev, data); + FSM_STATE(DNDState, LOOT_GRAB, ev, data); + FSM_STATE(DNDState, INV_GRAB, ev, data); + FSM_STATE(DNDState, ITEM_PICKUP, ev, data); + FSM_STATE(DNDState, INV_PICKUP, ev, data); + FSM_STATE(DNDState, END, ev, data); + default: + dbc::log($F("event received with data but state={} is not handled", int($state))); + } + + return !in_state(DNDState::END); + } + + void DNDLoot::START(Event ev) { + using enum Event; + dbc::check(ev == START, "START not given a STARTED event."); + END(CLOSE); + } + + void DNDLoot::LOOTING(Event ev, std::any data) { + using enum Event; + + switch(ev) { + case LOOT_OPEN: + END(CLOSE); + break; + case LOOT_SELECT: + $grab_source = start_grab($loot_ui.$gui, data); + if($grab_source) state(DNDState::LOOT_GRAB); + break; + case INV_SELECT: + $grab_source = start_grab($status_ui.$gui, data); + if($grab_source) state(DNDState::INV_GRAB); + break; + default: + break; // ignore + } + } + + void DNDLoot::LOOT_GRAB(Event ev, std::any data) { + using enum Event; + + switch(ev) { + case LOOT_OPEN: + END(CLOSE); + break; + case LOOT_SELECT: { + auto drop_id = std::any_cast(data); + + if(move_or_swap($loot_ui, drop_id)) { + state(DNDState::LOOTING); + } + } break; + case INV_SELECT: + if(commit_drop($loot_ui.$gui, + $status_ui.$gui, $grab_source, data)) + { + state(DNDState::LOOTING); + } + break; + default: + handle_mouse(ev, $loot_ui.$gui); + } + } + + void DNDLoot::INV_GRAB(Event ev, std::any data) { + using enum Event; + + switch(ev) { + case LOOT_OPEN: + END(CLOSE); + break; + case LOOT_SELECT: + if(commit_drop($status_ui.$gui, + $loot_ui.$gui, $grab_source, data)) + { + state(DNDState::LOOTING); + } + break; + case INV_SELECT: { + auto drop_id = std::any_cast(data); + if(move_or_swap($status_ui, drop_id)) { + state(DNDState::LOOTING); + } + } break; + default: + handle_mouse(ev, $status_ui.$gui); + } + } + + void DNDLoot::INV_PICKUP(Event ev, std::any data) { + using enum Event; + + switch(ev) { + case AIM_CLICK: { + // take from inventory, drop on floor + throw_on_floor($status_ui.$gui, true); + END(CLOSE); + } break; + case INV_SELECT: { + auto drop_id = std::any_cast(data); + if(move_or_swap($status_ui, drop_id)) { + END(CLOSE); + } + } break; + default: + handle_mouse(ev, $status_ui.$gui); + } + } + + void DNDLoot::ITEM_PICKUP(Event ev, std::any data) { + using enum Event; + + switch(ev) { + case INV_SELECT: + if(commit_drop($loot_ui.$gui, $status_ui.$gui, $grab_source, data)) + { + END(CLOSE); + } + break; + case AIM_CLICK: { + // THIS IS PUT IT BACK ON THE FLOOR + throw_on_floor($loot_ui.$gui, false); + END(CLOSE); + } break; + default: + handle_mouse(ev, $loot_ui.$gui); + } + } + + void DNDLoot::END(Event ev, std::any data) { + using enum Event; + + switch(ev) { + case LOOT_ITEM: { + auto gui_id = $loot_ui.$gui.entity("item_0"); + if(hold_item($loot_ui.$gui, gui_id)) { + state(DNDState::ITEM_PICKUP); + } + } break; + case INV_SELECT: { + auto gui_id = std::any_cast(data); + if(hold_item($status_ui.$gui, gui_id)) { + state(DNDState::INV_PICKUP); + } + } break; + case LOOT_OPEN: + open(); + state(DNDState::LOOTING); + break; + case CLOSE: + // called the first time transitioning to END + close(); + state(DNDState::END); + break; + case TICK: // ignored + break; + default: + dbc::log($F("invalid event: {}", int(ev))); + } + } + + void DNDLoot::handle_mouse(Event ev, guecs::UI& gui) { + using enum Event; + + switch(ev) { + case MOUSE_DRAG: + case MOUSE_MOVE: { + if($grab_source) { + auto& source = gui.get(*$grab_source); + source.move($window.mapPixelToCoords($router.position)); + } + } break; + default: + break; // ignored + } + } + + void DNDLoot::clear_grab() { + $grab_source = std::nullopt; + $grab_sprite = nullptr; + } + + void DNDLoot::open() { + $loot_ui.active = true; + } + + void DNDLoot::close() { + $loot_ui.active = false; + } + + void DNDLoot::render() { + if($grab_source && $grab_sprite) { + $window.draw(*$grab_sprite); + } + } + + std::optional DNDLoot::start_grab(guecs::UI& gui, std::any data) { + auto gui_id = std::any_cast(data); + + if(auto source = gui.get_if(gui_id)) { + $grab_sprite = source->sprite; + source->grab(); + return gui_id; + } else { + return std::nullopt; + } + } + + bool DNDLoot::commit_drop(guecs::UI& source, guecs::UI& target, + std::optional source_id, std::any data) + { + if(!source_id) return false; + auto target_id = std::any_cast(data); + + dbc::check(target.has(target_id), + "gui does not have a DropTarget at that slot"); + dbc::check(source.has(*source_id), + "gui does not have a GrabSource at that slot"); + + auto& grab = source.get(*source_id); + auto& drop = target.get(target_id); + + if(drop.commit(grab.world_entity)) { + grab.commit(); + clear_grab(); + return true; + } else { + return false; + } + } + + bool DNDLoot::commit_move(guecs::UI& gui, std::optional source_id, guecs::Entity drop_id) { + dbc::check(source_id != std::nullopt, "source_id must exist"); + + auto& grab = gui.get(*source_id); + grab.commit(); + + auto& drop = gui.get(drop_id); + + if(drop.commit(grab.world_entity)) { + clear_grab(); + return true; + } else { + // swap with the target instead + return false; + } + } + + bool DNDLoot::hold_item(guecs::UI &gui, guecs::Entity gui_id) { + // NOTE: if > 1 items, go to LOOT_OPEN instead + $grab_source = start_grab(gui, gui_id); + + if($grab_source) { + auto& source = gui.get(*$grab_source); + $grab_sprite = source.sprite; + // call this once to properly position the sprite + handle_mouse(Event::MOUSE_MOVE, gui); + } + + return $grab_source != std::nullopt; + } + + /* + * Dropping on the ground is only possible from the + * status_ui for now. + */ + void DNDLoot::throw_on_floor(guecs::UI& gui, bool from_status) { + dbc::check($grab_source != std::nullopt, "attempt to commit_drop but no grab_source set"); + dbc::check(gui.has(*$grab_source), + "StatusUI doesn't actually have that GrabSource in the gui."); + + auto& grab = gui.get(*$grab_source); + + if(from_status) { + $status_ui.drop_item(grab.world_entity); + } else { + $loot_ui.drop_item(grab.world_entity); + } + + grab.commit(); + clear_grab(); + } + + /* + * If I refactored everything to use a levelmanager module then + * this and many other things could go away. Access to $level is + * making this too complicated. Do this for now, but fix bug #59. + */ + bool DNDLoot::move_or_swap(StatusUI& ui, guecs::Entity drop_id) { + if(ui.occupied(drop_id)) { + ui.swap(*$grab_source, drop_id); + clear_grab(); + return true; + } else { + return commit_move(ui.$gui, $grab_source, drop_id); + } + } + + bool DNDLoot::move_or_swap(LootUI& ui, guecs::Entity drop_id) { + if(ui.occupied(drop_id)) { + ui.swap(*$grab_source, drop_id); + clear_grab(); + return true; + } else { + return commit_move(ui.$gui, $grab_source, drop_id); + } + } + +} diff --git a/src/gui/dnd_loot.hpp b/src/gui/dnd_loot.hpp new file mode 100644 index 0000000..0efd0bc --- /dev/null +++ b/src/gui/dnd_loot.hpp @@ -0,0 +1,66 @@ +#pragma once +#include "algos/simplefsm.hpp" +#include +#include "gui/status_ui.hpp" +#include "gui/loot_ui.hpp" +#include "gui/event_router.hpp" +#include "events.hpp" + +namespace gui { + enum class DNDState { + START=100, + LOOTING=101, + LOOT_GRAB=102, + INV_GRAB=103, + ITEM_PICKUP=104, + INV_PICKUP=105, + END=106 + }; + + class DNDLoot : public DeadSimpleFSM { + public: + std::optional $grab_source = std::nullopt; + std::shared_ptr $grab_sprite = nullptr; + StatusUI& $status_ui; + LootUI& $loot_ui; + sf::RenderWindow& $window; + routing::Router& $router; + + DNDLoot(StatusUI& status_ui, + LootUI& loot_ui, sf::RenderWindow& window, + routing::Router& router); + + bool event(game::Event ev, std::any data={}); + + void START(game::Event ev); + void LOOTING(game::Event ev, std::any data); + void LOOT_GRAB(game::Event ev, std::any data); + void INV_GRAB(game::Event ev, std::any data); + void END(game::Event ev, std::any data={}); + void ITEM_PICKUP(game::Event ev, std::any data); + void INV_PICKUP(game::Event ev, std::any data); + + void handle_mouse(game::Event ev, guecs::UI& gui); + void render(); + void open(); + void close(); + + std::optional start_grab(guecs::UI& gui, std::any data); + + bool commit_drop(guecs::UI& source, guecs::UI& target, + std::optional source_id, std::any data); + + bool commit_move(guecs::UI& gui, + std::optional source_id, guecs::Entity drop_id); + + bool hold_item(guecs::UI& gui, guecs::Entity gui_id); + void throw_on_floor(guecs::UI& gui, bool from_status); + + void clear_grab(); + + bool move_or_swap(StatusUI& status_ui, guecs::Entity drop_id); + bool move_or_swap(LootUI& ui, guecs::Entity drop_id); + + sf::Vector2f mouse_position(); + }; +} diff --git a/src/gui/event_router.cpp b/src/gui/event_router.cpp new file mode 100644 index 0000000..9ea1b9f --- /dev/null +++ b/src/gui/event_router.cpp @@ -0,0 +1,143 @@ +#include "event_router.hpp" +#include "dbc.hpp" +#include "events.hpp" + +namespace gui { + namespace routing { + using enum Event; + using enum State; + + game::Event Router::process_event(std::optional ev) { + $next_event = game::Event::TICK; + + if(ev->is()) { + return game::Event::QUIT; + } + + if(const auto* mouse = ev->getIf()) { + if(mouse->button == sf::Mouse::Button::Left || mouse->button == sf::Mouse::Button::Right) { + left_button = mouse->button == sf::Mouse::Button::Left; + position = mouse->position; + event(MOUSE_DOWN); + } + } else if(const auto* mouse = ev->getIf()) { + // need to sort this out but if you don't do this it thinks you're always pressing it + if(mouse->button == sf::Mouse::Button::Left || mouse->button == sf::Mouse::Button::Right) { + left_button = mouse->button == sf::Mouse::Button::Left; + position = mouse->position; + event(MOUSE_UP); + } + } else if(const auto* mouse = ev->getIf()) { + position = mouse->position; + event(MOUSE_MOVE); + } + + if(const auto* key = ev->getIf()) { + scancode = key->scancode; + event(KEY_PRESS); + } + + return $next_event; + } + + void Router::event(Event ev) { + switch($state) { + FSM_STATE(State, START, ev); + FSM_STATE(State, IDLE, ev); + FSM_STATE(State, MOUSE_ACTIVE, ev); + FSM_STATE(State, MOUSE_MOVING, ev); + FSM_STATE(State, MOUSE_DRAGGING, ev); + } + } + + void Router::START(Event ) { + state(State::IDLE); + } + + void Router::IDLE(Event ev) { + switch(ev) { + case MOUSE_DOWN: + move_count=0; + set_event(game::Event::TICK); + state(State::MOUSE_ACTIVE); + break; + case MOUSE_UP: + set_event(game::Event::MOUSE_CLICK); + state(State::IDLE); + break; + case MOUSE_MOVE: + set_event(game::Event::MOUSE_MOVE); + break; + case KEY_PRESS: + set_event(game::Event::KEY_PRESS); + break; + default: + dbc::sentinel($F("invalid event: {}", int(ev))); + } + } + + void Router::MOUSE_ACTIVE(Event ev) { + switch(ev) { + case MOUSE_UP: + set_event(game::Event::MOUSE_CLICK); + state(State::IDLE); + break; + case MOUSE_MOVE: + move_count++; + set_event(game::Event::MOUSE_DRAG); + state(State::MOUSE_MOVING); + break; + case KEY_PRESS: + set_event(game::Event::KEY_PRESS); + state(State::IDLE); + break; + default: + dbc::sentinel("invalid event"); + } + } + + void Router::MOUSE_MOVING(Event ev) { + switch(ev) { + case MOUSE_UP: { + dbc::check(move_count < $drag_tolerance, "mouse up but not in dragging state"); + set_event(game::Event::MOUSE_CLICK); + state(State::IDLE); + } break; + case MOUSE_MOVE: + move_count++; + + if(move_count < $drag_tolerance) { + set_event(game::Event::MOUSE_DRAG); + } else { + set_event(game::Event::MOUSE_DRAG_START); + state(State::MOUSE_DRAGGING); + } + break; + case KEY_PRESS: + set_event(game::Event::KEY_PRESS); + break; + default: + dbc::sentinel("invalid event"); + } + } + + void Router::MOUSE_DRAGGING(Event ev) { + switch(ev) { + case MOUSE_UP: + set_event(game::Event::MOUSE_DROP); + state(State::IDLE); + break; + case MOUSE_MOVE: + move_count++; + set_event(game::Event::MOUSE_DRAG); + break; + case KEY_PRESS: + set_event(game::Event::KEY_PRESS); + break; + default: + // invalid events: 1 + dbc::sentinel($F("invalid events: {}", int(ev))); + } + } + } +} diff --git a/src/gui/event_router.hpp b/src/gui/event_router.hpp new file mode 100644 index 0000000..2985889 --- /dev/null +++ b/src/gui/event_router.hpp @@ -0,0 +1,49 @@ +#pragma once +#include "events.hpp" +#include "events.hpp" +#include "algos/simplefsm.hpp" +#include + +namespace gui { + namespace routing { + enum class State { + START, + IDLE, + MOUSE_ACTIVE, + MOUSE_MOVING, + MOUSE_DRAGGING + }; + + enum class Event { + STARTED=0, + MOUSE_DOWN=1, + MOUSE_UP=2, + MOUSE_MOVE=3, + KEY_PRESS=4 + }; + + class Router : public DeadSimpleFSM { + public: + sf::Vector2i position; + sf::Keyboard::Scancode scancode; + game::Event $next_event = game::Event::TICK; + int move_count = 0; + bool left_button = true; + int $drag_tolerance = 4; + + void event(Event ev); + + void START(Event ev); + void IDLE(Event ev); + void MOUSE_ACTIVE(Event ev); + void MOUSE_MOVING(Event ev); + void MOUSE_DRAGGING(Event ev); + + game::Event process_event(std::optional ev); + + void set_event(game::Event ev) { + $next_event = ev; + } + }; + } +} diff --git a/src/gui/fsm.cpp b/src/gui/fsm.cpp new file mode 100644 index 0000000..59c1e9d --- /dev/null +++ b/src/gui/fsm.cpp @@ -0,0 +1,515 @@ +#include "gui/fsm.hpp" +#include +#include +#include +#include +#include "game/components.hpp" +#include +#include "game/systems.hpp" +#include "events.hpp" +#include "game/sound.hpp" +#include "graphics/shaders.hpp" +#include +#include "gui/guecstra.hpp" +#include "game/level.hpp" + +namespace gui { + using namespace components; + using game::Event; + + FSM::FSM() : + $window(sf::VideoMode({SCREEN_WIDTH, SCREEN_HEIGHT}), "Zed's Raycaster Thing"), + $main_ui($window), + $dnd_loot($status_ui, $loot_ui, $window, $router) + { + $window.setVerticalSyncEnabled(VSYNC); + if(FRAME_LIMIT) $window.setFramerateLimit(FRAME_LIMIT); + $window.setPosition({0,0}); + } + + void FSM::event(Event ev, std::any data) { + switch($state) { + FSM_STATE(State, START, ev); + FSM_STATE(State, MOVING, ev); + FSM_STATE(State, ATTACKING, ev, data); + FSM_STATE(State, ROTATING, ev); + FSM_STATE(State, IDLE, ev, data); + FSM_STATE(State, IN_COMBAT, ev); + FSM_STATE(State, COMBAT_ROTATE, ev); + FSM_STATE(State, LOOTING, ev, data); + FSM_STATE(State, END, ev); + } + } + + void FSM::START(Event ) { + $main_ui.update_level(); + $main_ui.init(); + $loot_ui.init(); + + // BUG: maybe this is a function on main_ui? + auto cell = $main_ui.overlay_cell("left"); + $debug_ui.init(cell); + + $status_ui.init(); + $map_ui.init(); + $map_ui.log(L"Welcome to the game!"); + + run_systems(); + + state(State::IDLE); + } + + void FSM::MOVING(Event ) { + // this should be an optional that returns a point + if(auto move_to = $main_ui.play_move()) { + System::move_player(*move_to); + run_systems(); + $main_ui.dirty(); + state(State::IDLE); + } + } + + void FSM::ATTACKING(Event ev, std::any data) { + using enum Event; + switch(ev) { + case TICK: { + dbc::log("!!!!!! FIX System::combat(0) doesn't use any weapons, only first"); + System::combat(0); + run_systems(); + state(State::IN_COMBAT); + } break; + case COMBAT_STOP: + state(State::IDLE); + break; + case ATTACK: { + int attack_id = std::any_cast(data); + System::combat(attack_id); + run_systems(); + } break; + default: + dbc::log($F("In ATTACKING state, unhandled event {}", (int)ev)); + state(State::IDLE); + } + } + + void FSM::ROTATING(Event) { + if(auto aim = $main_ui.play_rotate()) { + auto& player_pos = GameDB::player_position(); + player_pos.aiming_at = *aim; + state(State::IDLE); + } + } + + void FSM::COMBAT_ROTATE(Event) { + if(auto aim = $main_ui.play_rotate()) { + auto& player_pos = GameDB::player_position(); + player_pos.aiming_at = *aim; + state(State::IN_COMBAT); + } + } + + void FSM::LOOTING(Event ev, std::any data) { + using enum Event; + + switch(ev) { + case MOUSE_DRAG_START: + case MOUSE_CLICK: + case MOUSE_DROP: + mouse_action(guecs::NO_MODS); + break; + default: + if(!$dnd_loot.event(ev, data)) { + state(State::IDLE); + } + } + } + + void FSM::IDLE(Event ev, std::any data) { + using enum Event; + + sound::stop("walk"); + + switch(ev) { + case QUIT: + $window.close(); + state(State::END); + return; // done + case MOVE_FORWARD: + try_move(1, false); + break; + case MOVE_BACK: + try_move(-1, false); + break; + case MOVE_LEFT: + try_move(-1, true); + break; + case MOVE_RIGHT: + try_move(1, true); + break; + case ROTATE_LEFT: + $main_ui.plan_rotate(-1, DEFAULT_ROTATE); + state(State::ROTATING); + break; + case ROTATE_RIGHT: + $main_ui.plan_rotate(1, DEFAULT_ROTATE); + state(State::ROTATING); + break; + case MAP_OPEN: + $map_open = !$map_open; + break; + case ATTACK: + state(State::ATTACKING); + break; + case COMBAT_START: + $map_open = false; + state(State::IN_COMBAT); + break; + case CLOSE: + dbc::log("Nothing to close."); + break; + case LOOT_ITEM: + $dnd_loot.event(Event::LOOT_ITEM); + state(State::LOOTING); + break; + case LOOT_OPEN: + $dnd_loot.event(Event::LOOT_OPEN); + state(State::LOOTING); + break; + case INV_SELECT: + $dnd_loot.event(Event::INV_SELECT, data); + state(State::LOOTING); + break; + case USE_ITEM: { + auto gui_id = std::any_cast(data); + auto& slot_name = $status_ui.$gui.name_for(gui_id); + + if(System::use_item(slot_name)) { + $status_ui.update(); + } + } break; + case MOUSE_CLICK: + mouse_action(guecs::NO_MODS); + break; + case MOUSE_MOVE: { + mouse_action({1 << guecs::ModBit::hover}); + } break; + case AIM_CLICK: + System::pickup(); + break; + default: + break; // ignore everything else + } + } + + void FSM::IN_COMBAT(Event ev) { + using enum Event; + + switch(ev) { + case MOUSE_CLICK: + mouse_action(guecs::NO_MODS); + break; + case MOUSE_MOVE: { + mouse_action({1 << guecs::ModBit::hover}); + } break; + case TICK: + run_systems(); + break; + case ATTACK: + $main_ui.play_hands(); + $main_ui.dirty(); + sound::play("Sword_Hit_1"); + state(State::ATTACKING); + break; + case ROTATE_LEFT: + $main_ui.plan_rotate(-1, DEFAULT_ROTATE); + state(State::COMBAT_ROTATE); + break; + case ROTATE_RIGHT: + $main_ui.plan_rotate(1, DEFAULT_ROTATE); + state(State::COMBAT_ROTATE); + break; + case COMBAT_STOP: + $main_ui.$overlay_ui.close_sprite("top_right"); + state(State::IDLE); + break; + case QUIT: + $window.close(); + state(State::END); + return; + default: + break; + } + } + + void FSM::try_move(int dir, bool strafe) { + auto& level = GameDB::current_level(); + using enum State; + // prevent moving into occupied space + Point move_to = $main_ui.plan_move(dir, strafe); + + if(level.map->can_move(move_to) && !level.collision->occupied(move_to)) { + sound::play("walk"); + state(MOVING); + } else { + state(IDLE); + $main_ui.abort_plan(); + } + } + + void FSM::END(Event ev) { + dbc::log($F("END: received event after done: {}", int(ev))); + } + + sf::Vector2f FSM::mouse_position() { + return $window.mapPixelToCoords($router.position); + } + + void FSM::mouse_action(guecs::Modifiers mods) { + sf::Vector2f pos = mouse_position(); + if($debug_ui.active) $debug_ui.mouse(pos.x, pos.y, mods); + $status_ui.mouse(pos.x, pos.y, mods); + + if($loot_ui.active) { + $loot_ui.mouse(pos.x, pos.y, mods); + } else { + $main_ui.mouse(pos.x, pos.y, mods); + } + } + + void FSM::handle_keyboard_mouse() { + while(const auto ev = $window.pollEvent()) { + auto gui_ev = $router.process_event(ev); + + if(gui_ev == Event::KEY_PRESS) { + using KEY = sf::Keyboard::Scan; + + switch($router.scancode) { + case KEY::W: + event(Event::MOVE_FORWARD); + break; + case KEY::S: + event(Event::MOVE_BACK); + break; + case KEY::Q: + event(Event::ROTATE_LEFT); + break; + case KEY::E: + event(Event::ROTATE_RIGHT); + break; + case KEY::D: + event(Event::MOVE_RIGHT); + break; + case KEY::A: + event(Event::MOVE_LEFT); + break; + case KEY::R: + dbc::log("HEY! DIPSHIT! You need to move debug ui so you can rest stats."); + break; + case KEY::M: + event(Event::MAP_OPEN); + break; + case KEY::Escape: + event(Event::CLOSE); + break; + case KEY::Space: + event(Event::ATTACK); + break; + case KEY::P: + sound::mute(false); + if(!sound::playing("ambient_1")) sound::play("ambient_1", true); + $debug_ui.debug(); + shaders::reload(); + break; + case KEY::O: + autowalking = true; + break; + case KEY::L: + // This will go away as soon as containers work + $loot_ui.set_target($loot_ui.$temp_loot); + $loot_ui.update(); + event(Event::LOOT_OPEN); + break; + case KEY::Z: + $main_ui.toggle_mind_reading(); + break; + case KEY::F5: + take_screenshot(); + break; + default: + break; // ignored + } + } else { + event(gui_ev); + } + } + } + + void FSM::debug_render() { + auto start = $debug_ui.time_start(); + $main_ui.render(); + $debug_ui.sample_time(start); + $debug_ui.render($window); + } + + void FSM::draw_gui() { + if($debug_ui.active) { + debug_render(); + } else { + $main_ui.render(); + } + + $status_ui.render($window); + if($loot_ui.active) $loot_ui.render($window); + + if(in_state(State::LOOTING)) $dnd_loot.render(); + + if($map_open) { + $map_ui.render($window, $main_ui.$compass_dir); + } + } + + void FSM::update() { + } + + void FSM::render() { + $window.clear(); + + // this clears any attack animations, like fire + System::clear_attack(); + // BUG: this is the render for this class, and where I add an update + draw_gui(); + + $window.display(); + } + + void FSM::run_systems() { + System::generate_paths(); + System::enemy_ai_initialize(); + System::enemy_pathing(); + System::motion(); + System::collision(); + System::lighting(); + System::death(); + } + + bool FSM::active() { + return !in_state(State::END); + } + + void FSM::handle_world_events() { + using eGUI = game::Event; + auto world = GameDB::current_world(); + + while(world->has_event()) { + auto [evt, entity, data] = world->recv(); + auto player = world->get_the(); + + // HERE: this has to go, unify these events and just use them in the state machine directly + + switch(evt) { + case eGUI::COMBAT: { + auto &damage = std::any_cast(data); + + if(damage.enemy_did > 0) { + $map_ui.log($F(L"Enemy HIT YOU for {} damage!", damage.enemy_did)); + } else { + $map_ui.log(L"Enemy MISSED YOU."); + } + + if(damage.player_did > 0) { + $map_ui.log($F(L"You HIT enemy for {} damage!", damage.player_did)); + } else { + $map_ui.log(L"You MISSED the enemy."); + } + } + break; + case eGUI::COMBAT_START: + event(Event::COMBAT_START); + break; + case eGUI::ENTITY_SPAWN: { + auto& sprite = world->get(entity); + $main_ui.$rayview->update_sprite(entity, sprite); + $main_ui.dirty(); + run_systems(); + } break; + case eGUI::NO_NEIGHBORS: + event(Event::COMBAT_STOP); + break; + case eGUI::LOOT_CLOSE: + // BUG: need to resolve GUI events vs. FSM events better + event(Event::LOOT_OPEN); + break; + case eGUI::LOOT_SELECT: + event(Event::LOOT_SELECT, data); + break; + case eGUI::INV_SELECT: { + if($router.left_button) { + event(Event::INV_SELECT, data); + } else { + event(Event::USE_ITEM, data); + } + } break; + case eGUI::AIM_CLICK: + event(Event::AIM_CLICK); + break; + case eGUI::LOOT_ITEM: { + dbc::check(world->has(entity), + "INVALID LOOT_ITEM, that entity has no InventoryItem"); + $loot_ui.add_loose_item(entity); + event(Event::LOOT_ITEM); + } break; + case eGUI::LOOT_CONTAINER: { + $loot_ui.set_target($loot_ui.$temp_loot); + $loot_ui.update(); + event(Event::LOOT_OPEN); + } break; + case eGUI::HP_STATUS: + System::player_status(); + break; + case eGUI::ATTACK: + event(Event::ATTACK, data); + break; + case eGUI::STAIRS_DOWN: + dbc::sentinel("make me!"); + break; + case eGUI::DEATH: { + $status_ui.update(); + if(entity != player.entity) { + $main_ui.dead_entity(entity); + } else { + dbc::log("NEED TO HANDLE PLAYER DYING."); + } + } break; + case eGUI::NOOP: { + if(data.type() == typeid(std::string)) { + auto name = std::any_cast(data); + $map_ui.log($F(L"NOOP EVENT! {},{}", evt, entity)); + } + } break; + default: + dbc::log($F("Unhandled event: evt={}; enemy={}; data={}", + evt, entity, data.type().name())); + event(game::Event(evt), data); + } + } + } + + void FSM::take_screenshot() { + auto size = $window.getSize(); + sf::Texture shot{size}; + shot.update($window); + sf::Image out_img = shot.copyToImage(); + + bool worked = out_img.saveToFile("./screenshot.png"); + dbc::check(worked, "Failed to write screenshot.png"); + } + + void FSM::next_level() { + GameDB::create_level(); + $status_ui.update_level(); + $main_ui.update_level(); + $loot_ui.update_level(); + + run_systems(); + } +} diff --git a/src/gui/fsm.hpp b/src/gui/fsm.hpp new file mode 100644 index 0000000..791183b --- /dev/null +++ b/src/gui/fsm.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "constants.hpp" +#include "algos/simplefsm.hpp" +#include "gui/debug_ui.hpp" +#include "gui/main_ui.hpp" +#include "gui/status_ui.hpp" +#include "gui/loot_ui.hpp" +#include "gui/map_view.hpp" +#include "events.hpp" +#include "gui/event_router.hpp" +#include "gui/dnd_loot.hpp" +#include "events.hpp" + +namespace gui { + enum class State { + START=__LINE__, + MOVING=__LINE__, + IN_COMBAT=__LINE__, + COMBAT_ROTATE=__LINE__, + ATTACKING=__LINE__, + ROTATING=__LINE__, + LOOTING=__LINE__, + IDLE=__LINE__, + END=__LINE__, + }; + + class FSM : public DeadSimpleFSM { + public: + sf::RenderWindow $window; + bool $draw_stats = false; + bool autowalking = false; + bool $map_open = false; + DebugUI $debug_ui; + MainUI $main_ui; + StatusUI $status_ui{STATUS_UI_X, STATUS_UI_Y, STATUS_UI_WIDTH, STATUS_UI_HEIGHT}; + MapViewUI $map_ui; + LootUI $loot_ui; + gui::routing::Router $router; + DNDLoot $dnd_loot; + + FSM(); + + void event(game::Event ev, std::any data={}); + void autowalk(); + void start_autowalk(double rot_speed); + + void START(game::Event ev); + void MOVING(game::Event ev); + void ATTACKING(game::Event ev, std::any data); + void MAPPING(game::Event ev); + void ROTATING(game::Event ev); + void IDLE(game::Event ev, std::any data); + void IN_COMBAT(game::Event ev); + void COMBAT_ROTATE(game::Event ev); + void LOOTING(game::Event ev, std::any data); + void END(game::Event ev); + + void try_move(int dir, bool strafe); + sf::Vector2f mouse_position(); + void mouse_action(guecs::Modifiers mods); + void handle_keyboard_mouse(); + void draw_gui(); + void update(); + void render(); + bool active(); + void run_systems(); + void handle_world_events(); + void next_level(); + void debug_render(); + void take_screenshot(); + }; +} diff --git a/src/gui/guecstra.cpp b/src/gui/guecstra.cpp new file mode 100644 index 0000000..b4a58a1 --- /dev/null +++ b/src/gui/guecstra.cpp @@ -0,0 +1,40 @@ +#include "gui/guecstra.hpp" +#include "game/level.hpp" + +namespace guecs { + + Clickable make_action(guecs::Entity gui_id, game::Event event) { + return {[&, gui_id, event](auto){ + auto world = GameDB::current_world(); + world->send(event, gui_id, {}); + }}; + } + + Clickable make_action(guecs::Entity gui_id, game::Event event, std::any data) { + return {[&, event, data](auto){ + auto world = GameDB::current_world(); + world->send(event, gui_id, data); + }}; + } + + DinkyECS::Entity GrabSource::grab() { + fmt::println("> Grab entity {}", world_entity); + return world_entity; + } + + void GrabSource::setSprite(guecs::UI& gui, guecs::Entity gui_id) { + if(auto sp = gui.get_if(gui_id)) { + sprite = sp->sprite; + } else if(auto sp = gui.get_if(gui_id)) { + sprite = sp->sprite; + } else { + dbc::sentinel("GrabSource given sprite gui_id that doesn't exist"); + } + } + + void GrabSource::move(sf::Vector2f pos) { + if(sprite) { + sprite->setPosition(pos); + } + } +} diff --git a/src/gui/guecstra.hpp b/src/gui/guecstra.hpp new file mode 100644 index 0000000..63f7d6d --- /dev/null +++ b/src/gui/guecstra.hpp @@ -0,0 +1,24 @@ +#pragma once +#include "game/components.hpp" +#include "events.hpp" +#include +#include "graphics/textures.hpp" + +namespace guecs { + Clickable make_action(guecs::Entity gui_id, game::Event event); + Clickable make_action(guecs::Entity gui_id, game::Event event, std::any data); + + struct GrabSource { + DinkyECS::Entity world_entity; + std::function commit; + std::shared_ptr sprite = nullptr; + + DinkyECS::Entity grab(); + void setSprite(guecs::UI& gui, guecs::Entity gui_id); + void move(sf::Vector2f pos); + }; + + struct DropTarget { + std::function commit; + }; +} diff --git a/src/gui/loot_ui.cpp b/src/gui/loot_ui.cpp new file mode 100644 index 0000000..c76049a --- /dev/null +++ b/src/gui/loot_ui.cpp @@ -0,0 +1,154 @@ +#include "gui/loot_ui.hpp" +#include "constants.hpp" +#include +#include "game/systems.hpp" +#include "game/level.hpp" + +namespace gui { + using namespace guecs; + + LootUI::LootUI() : + $temp_loot(GameDB::current_world()->entity()), + $target($temp_loot) + { + $gui.position(RAY_VIEW_X+RAY_VIEW_WIDTH/2-200, + RAY_VIEW_Y+RAY_VIEW_HEIGHT/2-200, 400, 400); + + $gui.layout( + "[=item_0 | =item_1 |=item_2 |=item_3 ]" + "[=item_4 | =item_5 |=item_6 |=item_7 ]" + "[=item_8 | =item_9 |=item_10|=item_11]" + "[=item_12| =item_13|=item_14|=item_15 ]" + "[ =take_all | =close| =destroy]"); + + auto world = GameDB::current_world(); + world->set($temp_loot, {}); + world->make_constant($temp_loot); + } + + void LootUI::make_button(const std::string &name, const std::wstring& label, game::Event event) { + + auto button = $gui.entity(name); + $gui.set(button, {}); + $gui.set(button, {label}); + $gui.set(button, + guecs::make_action(button, event)); + } + + void LootUI::init() { + using guecs::THEME; + auto bg_color = THEME.DARK_LIGHT; + bg_color.a = 140; + $gui.set($gui.MAIN, {$gui.$parser, bg_color}); + + make_button("close", L"CLOSE", game::Event::LOOT_CLOSE); + make_button("take_all", L"TAKE ALL", game::Event::LOOT_CLOSE); + make_button("destroy", L"DESTROY", game::Event::LOOT_CLOSE); + + for(int i = 0; i < INV_SLOTS; i++) { + auto name = fmt::format("item_{}", i); + auto id = $gui.entity(name); + + $gui.set(id, {THEME.PADDING, + THEME.TRANSPARENT, THEME.LIGHT_MID }); + $gui.set(id, {0.4f, "ui_shader"}); + $gui.set(id, { + guecs::make_action(id, game::Event::LOOT_SELECT, {id}) + }); + } + + $gui.init(); + update(); + } + + void LootUI::update() { + auto world = GameDB::current_world(); + + dbc::check(world->has($target), + "update called but $target isn't in world"); + + auto& contents = world->get($target); + + for(size_t i = 0; i < INV_SLOTS; i++) { + auto id = $gui.entity("item_", int(i)); + auto& slot_name = $gui.name_for(id); + + if(contents.has(slot_name)) { + auto item = contents.get(slot_name); + dbc::check(world->has(item), + "item in inventory UI doesn't exist in world. New level?"); + auto& sprite = world->get(item); + $gui.set_init(id, {sprite.name}); + + guecs::GrabSource grabber{ + item, [&, id]() { return remove_slot(id); }}; + grabber.setSprite($gui, id); + $gui.set(id, grabber); + } else { + // BUG: fix remove so it's safe to call on empty + if($gui.has(id)) { + $gui.remove(id); + $gui.remove(id); + } + + $gui.set(id, { + [&, id](DinkyECS::Entity world_entity) -> bool { return place_slot(id, world_entity); } + }); + } + } + } + + void LootUI::remove_slot(guecs::Entity slot_id) { + auto& name = $gui.name_for(slot_id); + fmt::println("LootUI remove slot inv::Model id={} slot={}", $target, name); + System::remove_from_container($target, name); + update(); + } + + bool LootUI::place_slot(guecs::Entity id, DinkyECS::Entity world_entity) { + fmt::println("LootUI target={} placing world entity {} in slot id {}", + $target, id, world_entity); + auto& name = $gui.name_for(id); + + bool worked = System::place_in_container($target, name, world_entity); + if(worked) update(); + return worked; + } + + void LootUI::render(sf::RenderWindow& window) { + $gui.render(window); + } + + void LootUI::update_level() { + init(); + } + + void LootUI::add_loose_item(DinkyECS::Entity entity) { + System::place_in_container($temp_loot, "item_0", entity); + set_target($temp_loot); + update(); + } + + void LootUI::drop_item(DinkyECS::Entity item_id) { + System::drop_item(item_id); + update(); + } + + bool LootUI::mouse(float x, float y, guecs::Modifiers mods) { + return $gui.mouse(x, y, mods); + } + + bool LootUI::occupied(guecs::Entity slot) { + return System::inventory_occupied($target, $gui.name_for(slot)); + } + + void LootUI::swap(guecs::Entity gui_a, guecs::Entity gui_b) { + if(gui_a != gui_b) { + auto& a_name = $gui.name_for(gui_a); + auto& b_name = $gui.name_for(gui_b); + System::inventory_swap($target, a_name, b_name); + } + + update(); + } +} diff --git a/src/gui/loot_ui.hpp b/src/gui/loot_ui.hpp new file mode 100644 index 0000000..51cf3c7 --- /dev/null +++ b/src/gui/loot_ui.hpp @@ -0,0 +1,37 @@ +#pragma once +#include "gui/guecstra.hpp" +#include +#include +#include +#include "events.hpp" +#include "game/inventory.hpp" + +namespace gui { + class LootUI { + public: + bool active = false; + guecs::UI $gui; + DinkyECS::Entity $temp_loot = DinkyECS::NONE; + DinkyECS::Entity $target = DinkyECS::NONE; + + LootUI(); + + void set_target(DinkyECS::Entity entity) { + $target = entity; + } + + void init(); + void update(); + void render(sf::RenderWindow& window); + void update_level(); + bool mouse(float x, float y, guecs::Modifiers mods); + void make_button(const std::string &name, const std::wstring& label, game::Event event); + + void remove_slot(guecs::Entity slot_id); + bool place_slot(guecs::Entity gui_id, DinkyECS::Entity world_entity); + void add_loose_item(DinkyECS::Entity entity); + void drop_item(DinkyECS::Entity item_id); + bool occupied(guecs::Entity gui_id); + void swap(guecs::Entity gui_a, guecs::Entity gui_b); + }; +} diff --git a/src/gui/main_ui.cpp b/src/gui/main_ui.cpp new file mode 100644 index 0000000..1d5cd2b --- /dev/null +++ b/src/gui/main_ui.cpp @@ -0,0 +1,155 @@ +#include "gui/main_ui.hpp" +#include "game/components.hpp" +#include +#include "graphics/animation.hpp" +#include "constants.hpp" +#include "game/level.hpp" +#include "ai/ai.hpp" + +namespace gui { + using namespace components; + + MainUI::MainUI(sf::RenderWindow& window) : + $window(window), + $rayview(std::make_shared(RAY_VIEW_X, RAY_VIEW_Y, RAY_VIEW_WIDTH, RAY_VIEW_HEIGHT)) + { + $window.setVerticalSyncEnabled(VSYNC); + $window.setFramerateLimit(FRAME_LIMIT); + + auto config = settings::get("config"); + + $hand = textures::get_sprite(config["player"]["hands"]); + $hand_anim = animation::load("assets/animation.json", config["player"]["hands"]); + } + + void MainUI::dirty() { + $needs_render = true; + } + + void MainUI::init() { + auto& player_position = GameDB::player_position(); + auto player = player_position.location; + + $rayview->init_shaders(); + $rayview->position_camera(player.x + 0.5, player.y + 0.5); + + $overlay_ui.init(); + } + + void MainUI::render() { + // BUG: bring back the $needs_render optimization + $rayview->update(); + $rayview->render($window); + + if($mind_reading) render_mind_reading(); + $overlay_ui.render($window); + + if($hand_anim.playing) render_hands(); + } + + lel::Cell MainUI::overlay_cell(const std::string& name) { + return $overlay_ui.$gui.cell_for(name); + } + + std::optional MainUI::play_rotate() { + if($rayview->play_rotate()) { + $needs_render = false; + return std::make_optional($rayview->aiming_at); + } else { + $needs_render = true; + return std::nullopt; + } + } + + std::optional MainUI::play_move() { + if($rayview->play_move()) { + $needs_render = false; + return std::make_optional( + $rayview->camera_at, + $rayview->aiming_at); + } else { + $needs_render = true; + return std::nullopt; + } + } + + void MainUI::plan_rotate(int dir, float amount) { + // -1 is left, 1 is right + int extra = (amount == 0.5) * dir; + $compass_dir = ($compass_dir + dir + extra) % COMPASS.size(); + $rayview->plan_rotate(dir, amount); + } + + Point MainUI::plan_move(int dir, bool strafe) { + return $rayview->plan_move(dir, strafe); + } + + void MainUI::abort_plan() { + $rayview->abort_plan(); + } + + void MainUI::dead_entity(DinkyECS::Entity entity) { + auto world = GameDB::current_world(); + if(world->has(entity)) { + auto &sprite = world->get(entity); + $rayview->update_sprite(entity, sprite); + } + } + + void MainUI::toggle_mind_reading() { + $mind_reading = !$mind_reading; + + if($mind_reading) { + render_mind_reading(); + } else { + $overlay_ui.close_text("left"); + } + } + + void MainUI::render_mind_reading() { + auto level = GameDB::current_level(); + if(auto entity = level.collision->occupied_by($rayview->aiming_at)) { + if(auto enemy_ai = level.world->get_if(entity)) { + $overlay_ui.show_text("left", fmt::format(L"AI: {}", + guecs::to_wstring(enemy_ai->to_string()))); + } else { + $overlay_ui.show_text("left", L"no mind to read"); + } + } else { + $overlay_ui.show_text("left", L"nothing there"); + } + } + + void MainUI::update_level() { + auto& level = GameDB::current_level(); + auto& player_position = GameDB::player_position(); + auto player = player_position.location; + + $rayview->update_level(level); + $rayview->position_camera(player.x + 0.5, player.y + 0.5); + + player_position.aiming_at = $rayview->aiming_at; + + $compass_dir = 0; + + $overlay_ui.update_level(); + dirty(); + } + + void MainUI::mouse(int x, int y, guecs::Modifiers mods) { + $overlay_ui.$gui.mouse(x, y, mods); + } + + void MainUI::play_hands() { + if(!$hand_anim.playing) $hand_anim.play(); + } + + void MainUI::render_hands() { + if($hand_anim.playing) { + $hand_anim.update(); + $hand_anim.apply(*$hand.sprite); + $hand.sprite->setPosition({RAY_VIEW_X, RAY_VIEW_Y}); + $window.draw(*$hand.sprite); + } + } +} diff --git a/src/gui/main_ui.hpp b/src/gui/main_ui.hpp new file mode 100644 index 0000000..7d1589b --- /dev/null +++ b/src/gui/main_ui.hpp @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#include "algos/stats.hpp" +#include +#include "gui/overlay_ui.hpp" +#include "gui/debug_ui.hpp" +#include "graphics/raycaster.hpp" +#include "graphics/animation.hpp" +#include "game/components.hpp" +#include + +namespace animation { + class Animation; +} + +namespace gui { + class MainUI { + public: + int $compass_dir = 0; + bool $needs_render = true; + bool $mind_reading = false; + sf::Clock $clock; + sf::RenderWindow& $window; + OverlayUI $overlay_ui; + std::shared_ptr $rayview; + textures::SpriteTexture $hand; + animation::Animation $hand_anim; + + MainUI(sf::RenderWindow& window); + + void mouse(int x, int y, guecs::Modifiers mods); + void debug(); + void render_debug(); + + void plan_rotate(int dir, float amount); + std::optional play_rotate(); + std::optional play_move(); + Point plan_move(int dir, bool strafe); + void abort_plan(); + void update_level(); + + void init(); + void render(); + void dirty(); + lel::Cell overlay_cell(const std::string& name); + + void dead_entity(DinkyECS::Entity entity); + void toggle_mind_reading(); + void render_mind_reading(); + void play_hands(); + void render_hands(); + }; +} diff --git a/src/gui/map_view.cpp b/src/gui/map_view.cpp new file mode 100644 index 0000000..8b21214 --- /dev/null +++ b/src/gui/map_view.cpp @@ -0,0 +1,75 @@ +#include "map_view.hpp" +#include +#include +#include "dbc.hpp" +#include "game/components.hpp" +#include "algos/rand.hpp" +#include "game/systems.hpp" +#include "algos/rand.hpp" +#include +#include +#include +#include +#include "graphics/palette.hpp" +#include "game/level.hpp" + +constexpr const int MAP_WIDTH=13; +constexpr const int MAP_HEIGHT=13; + +namespace gui { + using namespace components; + using namespace guecs; + + MapViewUI::MapViewUI() : + $map_render(std::make_shared()), + $map_sprite($map_render->getTexture()), + $map_tiles(matrix::make(MAP_WIDTH, MAP_HEIGHT)) + { + auto world = GameDB::current_world(); + auto player = GameDB::the_player(); + $player_display = world->get(player).display; + } + + void MapViewUI::init() { + $gui.position(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); + $gui.layout("[log_view| *%(200)map_grid | _ ]"); + $gui.set($gui.MAIN, {$gui.$parser, palette::get("tiles/fg:wall_plain")}); + + $log_to = $gui.entity("log_view"); + $gui.set($log_to, {10, THEME.DARK_MID, THEME.BORDER_COLOR, 10}); + $gui.set($log_to, {L"Welcome to the Game!", 25, THEME.TEXT_COLOR, 10}); + + auto map_cell = lel::center(MAP_TILE_DIM * MAP_WIDTH, MAP_TILE_DIM * MAP_HEIGHT, $gui.cell_for("map_grid")); + $map_sprite.setPosition({(float)map_cell.x, (float)map_cell.y + 30}); + + $gui.init(); + } + + void MapViewUI::render(sf::RenderWindow &window, int compass_dir) { + $gui.render(window); + System::draw_map($map_tiles, $entity_map); + System::render_map($map_tiles, $entity_map, *$map_render, compass_dir, $player_display); + $map_sprite.setTexture($map_render->getTexture(), true); + window.draw($map_sprite); + // $gui.debug_layout(window); + } + + void MapViewUI::update() { + if(auto text = $gui.get_if($log_to)) { + //BUG: I'm calling this what it is, fix it + wstring log_garbage; + for(auto msg : $messages) { + log_garbage += msg + L"\n"; + } + text->update(log_garbage); + } + } + + void MapViewUI::log(wstring msg) { + $messages.push_front(msg); + if($messages.size() > MAX_LOG_MESSAGES) { + $messages.pop_back(); + } + update(); + } +} diff --git a/src/gui/map_view.hpp b/src/gui/map_view.hpp new file mode 100644 index 0000000..8d64e35 --- /dev/null +++ b/src/gui/map_view.hpp @@ -0,0 +1,27 @@ +#pragma once +#include "graphics/textures.hpp" +#include "algos/matrix.hpp" +#include +#include +#include "algos/dinkyecs.hpp" +#include "game/map.hpp" + +namespace gui { + class MapViewUI { + public: + guecs::UI $gui; + wchar_t $player_display = L'@'; + DinkyECS::Entity $log_to; + EntityGrid $entity_map; + std::deque $messages; + std::shared_ptr $map_render; + sf::Sprite $map_sprite; + matrix::Matrix $map_tiles; + + MapViewUI(); + void init(); + void render(sf::RenderWindow &window, int compass_dir); + void log(std::wstring msg); + void update(); + }; +} diff --git a/src/gui/overlay_ui.cpp b/src/gui/overlay_ui.cpp new file mode 100644 index 0000000..8d797b0 --- /dev/null +++ b/src/gui/overlay_ui.cpp @@ -0,0 +1,63 @@ +#include "gui/overlay_ui.hpp" +#include "gui/guecstra.hpp" +#include "constants.hpp" +#include "events.hpp" +#include +#include "game/level.hpp" + +namespace gui { + using namespace guecs; + + OverlayUI::OverlayUI() { + $gui.position(RAY_VIEW_X, RAY_VIEW_Y, RAY_VIEW_WIDTH, RAY_VIEW_HEIGHT); + $gui.layout( + "[*%(100,300)left|=top|>(170,170)top_right]" + "[_|=middle|=middle_right]" + "[_|=bottom|=bottom_right]" + ); + $gui.init(); + } + + inline void make_clickable_area(guecs::UI &gui, const std::string &name) { + auto area = gui.entity(name); + + gui.set(area, { + [&](auto) { + auto world = GameDB::current_world(); + world->send(game::Event::AIM_CLICK, area, {}); + } + }); + } + + void OverlayUI::init() { + // gui.init is in the constructor + make_clickable_area($gui, "top"); + make_clickable_area($gui, "middle"); + make_clickable_area($gui, "bottom"); + } + + void OverlayUI::render(sf::RenderWindow& window) { + $gui.render(window); + // $gui.debug_layout(window); + } + + void OverlayUI::show_sprite(string region, string sprite_name) { + $gui.show_sprite(region, sprite_name); + } + + void OverlayUI::close_sprite(string region) { + $gui.close(region); + } + + void OverlayUI::show_text(string region, wstring content) { + $gui.show_text(region, content); + } + + void OverlayUI::close_text(string region) { + $gui.close(region); + } + + void OverlayUI::update_level() { + init(); + } +} diff --git a/src/gui/overlay_ui.hpp b/src/gui/overlay_ui.hpp new file mode 100644 index 0000000..4cbf21f --- /dev/null +++ b/src/gui/overlay_ui.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include + +namespace gui { + using std::string; + + class OverlayUI { + public: + guecs::UI $gui; + + OverlayUI(); + + void init(); + void update_level(); + + void render(sf::RenderWindow& window); + void show_sprite(string region, string sprite_name); + void close_sprite(string region); + void show_text(std::string region, std::wstring content); + void update_text(std::string region, std::wstring content); + void close_text(std::string region); + }; +} diff --git a/src/gui/status_ui.cpp b/src/gui/status_ui.cpp new file mode 100644 index 0000000..3dbdeb5 --- /dev/null +++ b/src/gui/status_ui.cpp @@ -0,0 +1,132 @@ +#include "gui/status_ui.hpp" +#include "game/components.hpp" +#include +#include "algos/rand.hpp" +#include +#include "gui/guecstra.hpp" +#include "game/systems.hpp" +#include "game/inventory.hpp" +#include "game/level.hpp" + +namespace gui { + using namespace guecs; + using std::any, std::any_cast, std::string, std::make_any; + + StatusUI::StatusUI(size_t x, size_t y, size_t width, size_t height) + { + $gui.position(x, y, width, height); + $gui.layout( + "[=slot1]" + "[=slot2]" + "[=hand_r]" + "[=pocket_l]"); + } + + void StatusUI::init() { + $gui.set($gui.MAIN, {$gui.$parser, }); + + for(auto& [name, cell] : $gui.cells()) { + auto gui_id = $gui.entity(name); + + $gui.set(gui_id, {}); + + $gui.set(gui_id, {guecs::to_wstring(name)}); + $gui.set(gui_id, { + guecs::make_action(gui_id, game::Event::INV_SELECT, {gui_id}) + }); + $gui.set(gui_id, { + .commit=[&, gui_id](DinkyECS::Entity world_target) -> bool { + return place_slot(gui_id, world_target); + } + }); + } + + $gui.init(); + update(); + } + + bool StatusUI::mouse(float x, float y, guecs::Modifiers mods) { + return $gui.mouse(x, y, mods); + } + + void StatusUI::update() { + auto world = GameDB::current_world(); + auto player = world->get_the(); + auto& inventory = world->get(player.entity); + + for(const auto& [slot, cell] : $gui.cells()) { + + if(inventory.has(slot)) { + auto gui_id = $gui.entity(slot); + auto world_entity = inventory.get(slot); + + auto& sprite = world->get(world_entity); + $gui.set_init(gui_id, {sprite.name}); + guecs::GrabSource grabber{ world_entity, + [&, gui_id]() { return remove_slot(gui_id); }}; + grabber.setSprite($gui, gui_id); + $gui.set(gui_id, grabber); + } else { + auto gui_id = $gui.entity(slot); + + if($gui.has(gui_id)) { + $gui.remove(gui_id); + $gui.remove(gui_id); + } + } + } + } + + void StatusUI::render(sf::RenderWindow &window) { + $gui.render(window); + // $gui.debug_layout(window); + } + + void StatusUI::update_level() { + init(); + } + + bool StatusUI::place_slot(guecs::Entity gui_id, DinkyECS::Entity world_entity) { + auto& level = GameDB::current_level(); + auto& slot_name = $gui.name_for(gui_id); + auto& inventory = level.world->get(level.player); + + if(inventory.add(slot_name, world_entity)) { + level.world->make_constant(world_entity); + update(); + return true; + } else { + dbc::log("there's something there already"); + return false; + } + } + + void StatusUI::drop_item(DinkyECS::Entity item_id) { + System::drop_item(item_id); + update(); + } + + // NOTE: do I need this or how does it relate to drop_item? + void StatusUI::remove_slot(guecs::Entity slot_id) { + auto player = GameDB::the_player(); + auto& slot_name = $gui.name_for(slot_id); + System::remove_from_container(player, slot_name); + update(); + } + + void StatusUI::swap(guecs::Entity gui_a, guecs::Entity gui_b) { + if(gui_a != gui_b) { + auto player = GameDB::the_player(); + auto& a_name = $gui.name_for(gui_a); + auto& b_name = $gui.name_for(gui_b); + System::inventory_swap(player, a_name, b_name); + } + + update(); + } + + bool StatusUI::occupied(guecs::Entity slot) { + auto player = GameDB::the_player(); + return System::inventory_occupied(player, $gui.name_for(slot)); + } +} diff --git a/src/gui/status_ui.hpp b/src/gui/status_ui.hpp new file mode 100644 index 0000000..b64ebcf --- /dev/null +++ b/src/gui/status_ui.hpp @@ -0,0 +1,32 @@ +#pragma once +#include "constants.hpp" +#include +#include "graphics/textures.hpp" +#include +#include "gui/guecstra.hpp" + +namespace gui { + class StatusUI { + public: + guecs::UI $gui; + + explicit StatusUI(size_t x, size_t y, size_t width, size_t height); + + StatusUI(const StatusUI& other) = delete; + StatusUI(StatusUI&& other) = delete; + ~StatusUI() = default; + + void update_level(); + void init(); + void render(sf::RenderWindow &window); + void update(); + bool mouse(float x, float y, guecs::Modifiers mods); + + void remove_slot(guecs::Entity slot_id); + bool place_slot(guecs::Entity gui_id, DinkyECS::Entity world_entity); + void drop_item(DinkyECS::Entity item_id); + + void swap(guecs::Entity gui_a, guecs::Entity gui_b); + bool occupied(guecs::Entity slot); + }; +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..6f356c7 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,63 @@ +#include "gui/fsm.hpp" +#include "graphics/textures.hpp" +#include "game/sound.hpp" +#include "game/autowalker.hpp" +#include "ai/ai.hpp" +#include +#include "graphics/shaders.hpp" +#include "gui/backend.hpp" +#include "game/level.hpp" +#include "graphics/camera.hpp" + +int main(int argc, char* argv[]) { + try { + gui::Backend backend; + + shaders::init(); + components::init(); + guecs::init(&backend); + ai::init("ai"); + GameDB::init(); + cinematic::init(); + + sound::mute(true); + + gui::FSM main; + main.event(game::Event::START); + Autowalker walker(main); + + sound::play("ambient_1", true); + + if(argc > 1 && argv[1][0] == 't') { + walker.start_autowalk(); + } + + while(main.active()) { + main.update(); + main.render(); + + // BUG: need to sort out how to deal with this in the FSM + if(main.in_state(gui::State::IDLE) + || main.in_state(gui::State::LOOTING) + || main.in_state(gui::State::IN_COMBAT)) + { + if(main.autowalking) { + walker.autowalk(); + } else { + main.handle_keyboard_mouse(); + } + } else{ + main.event(game::Event::TICK); + } + + main.handle_world_events(); + } + + return 0; + } catch(const std::system_error& e) { + std::cout << "WARNING: On OSX you'll get this error on shutdown.\n"; + std::cout << "Caught system_error with code " + "[" << e.code() << "] meaning " + "[" << e.what() << "]\n"; + } +} diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 0000000..a04566c --- /dev/null +++ b/src/meson.build @@ -0,0 +1,56 @@ +sources = files( + # ai + 'ai/ai.cpp', + 'ai/ai_debug.cpp', + 'ai/goap.cpp', + + # combat + 'combat/battle.cpp', + 'combat/combat.cpp', + + # gui + 'gui/backend.cpp', + 'gui/debug_ui.cpp', + 'gui/dnd_loot.cpp', + 'gui/event_router.cpp', + 'gui/fsm.cpp', + 'gui/guecstra.cpp', + 'gui/loot_ui.cpp', + 'gui/status_ui.cpp', + 'gui/main_ui.cpp', + 'gui/map_view.cpp', + 'gui/overlay_ui.cpp', + + # graphics + 'graphics/animation.cpp', + 'graphics/camera.cpp', + 'graphics/easing.cpp', + 'graphics/lights.cpp', + 'graphics/palette.cpp', + 'graphics/raycaster.cpp', + 'graphics/scene.cpp', + 'graphics/shaders.cpp', + 'graphics/textures.cpp', + + # algos + 'algos/matrix.cpp', + 'algos/maze.cpp', + 'algos/pathing.cpp', + 'algos/rand.cpp', + 'algos/spatialmap.cpp', + 'algos/stats.cpp', + + # game + 'game/worldbuilder.cpp', + 'game/map.cpp', + 'game/level.cpp', + 'game/inventory.cpp', + 'game/autowalker.cpp', + 'game/sound.cpp', + 'game/systems.cpp', + 'game/components.cpp', + 'game/config.cpp', + + # root + 'dbc.cpp', +) diff --git a/tests/ai.cpp b/tests/ai.cpp new file mode 100644 index 0000000..4e16ecb --- /dev/null +++ b/tests/ai.cpp @@ -0,0 +1,208 @@ +#include +#include "dbc.hpp" +#include "ai/ai.hpp" +#include "ai/ai_debug.hpp" +#include + +using namespace dbc; +using namespace nlohmann; + +TEST_CASE("state and actions work", "[ai]") { + enum StateNames { + ENEMY_IN_RANGE, + ENEMY_DEAD + }; + + ai::State goal; + ai::State start; + std::vector actions; + + // start off enemy not dead and not in range + start[ENEMY_DEAD] = false; + start[ENEMY_IN_RANGE] = false; + + // end goal is enemy is dead + goal[ENEMY_DEAD] = true; + + ai::Action move_closer("move_closer", 10); + move_closer.needs(ENEMY_IN_RANGE, false); + move_closer.effect(ENEMY_IN_RANGE, true); + + REQUIRE(move_closer.can_effect(start)); + auto after_move_state = move_closer.apply_effect(start); + REQUIRE(start[ENEMY_IN_RANGE] == false); + REQUIRE(after_move_state[ENEMY_IN_RANGE] == true); + REQUIRE(after_move_state[ENEMY_DEAD] == false); + // start is clean but after move is dirty + REQUIRE(move_closer.can_effect(start)); + REQUIRE(!move_closer.can_effect(after_move_state)); + REQUIRE(ai::distance_to_goal(start, after_move_state) == 1); + + ai::Action kill_it("kill_it", 10); + kill_it.needs(ENEMY_IN_RANGE, true); + kill_it.needs(ENEMY_DEAD, false); + kill_it.effect(ENEMY_DEAD, true); + + REQUIRE(!kill_it.can_effect(start)); + REQUIRE(kill_it.can_effect(after_move_state)); + + auto after_kill_state = kill_it.apply_effect(after_move_state); + REQUIRE(!kill_it.can_effect(after_kill_state)); + REQUIRE(ai::distance_to_goal(after_move_state, after_kill_state) == 1); + + kill_it.ignore(ENEMY_IN_RANGE); + REQUIRE(kill_it.can_effect(after_move_state)); + + actions.push_back(kill_it); + actions.push_back(move_closer); + + REQUIRE(start != goal); +} + +TEST_CASE("basic feature tests", "[ai]") { + enum StateNames { + ENEMY_IN_RANGE, + ENEMY_DEAD + }; + + ai::State goal; + ai::State start; + std::vector actions; + + // start off enemy not dead and not in range + start[ENEMY_DEAD] = false; + start[ENEMY_IN_RANGE] = false; + + // end goal is enemy is dead + goal[ENEMY_DEAD] = true; + + ai::Action move_closer("move_closer", 10); + move_closer.needs(ENEMY_IN_RANGE, false); + move_closer.effect(ENEMY_IN_RANGE, true); + + ai::Action kill_it("kill_it", 10); + kill_it.needs(ENEMY_IN_RANGE, true); + // this is duplicated on purpose to confirm that setting + // a positive then a negative properly cancels out + kill_it.needs(ENEMY_DEAD, true); + kill_it.needs(ENEMY_DEAD, false); + + // same thing with effects + kill_it.effect(ENEMY_DEAD, false); + kill_it.effect(ENEMY_DEAD, true); + + // order seems to matter which is wrong + actions.push_back(kill_it); + actions.push_back(move_closer); + + auto result = ai::plan_actions(actions, start, goal); + REQUIRE(result.complete); + + auto state = start; + + for(auto& action : result.script) { + state = action.apply_effect(state); + } + + REQUIRE(state[ENEMY_DEAD]); +} + + +TEST_CASE("ai as a module like sound/sprites", "[ai]") { + ai::reset(); + ai::init("tests/ai_fixture.json"); + + auto start = ai::load_state("test_start"); + auto goal = ai::load_state("test_goal"); + + auto a_plan = ai::plan("test1", start, goal); + REQUIRE(a_plan.complete); + + auto state = start; + for(auto& action : a_plan.script) { + fmt::println("ACTION: {}", action.name); + state = action.apply_effect(state); + } + + REQUIRE(ai::test(state, "target_dead")); +} + +TEST_CASE("ai autowalker ai test", "[ai]") { + ai::reset(); + ai::init("ai"); + auto start = ai::load_state("Host::initial_state"); + auto goal = ai::load_state("Host::final_state"); + int enemy_count = 5; + + ai::set(start, "no_more_enemies", enemy_count == 0); + + // find an enemy and kill them + auto a_plan = ai::plan("Host::actions", start, goal); + REQUIRE(!a_plan.complete); + + auto result = ai::dump_script("\n\nWALKER KILL STUFF", start, a_plan.script); + REQUIRE(ai::test(result, "enemy_found")); + REQUIRE(!ai::test(result, "no_more_enemies")); + + // health is low, go heal + ai::set(result, "health_good", false); + ai::set(result, "in_combat", false); + ai::set(result, "enemy_found", false); + ai::set(result, "have_healing", true); + ai::set(result, "have_item", true); + REQUIRE(!ai::test(result, "health_good")); + + auto health_plan = ai::plan("Host::actions", result, goal); + result = ai::dump_script("\n\nWALKER NEED HEALTH", result, health_plan.script); + REQUIRE(!health_plan.complete); + REQUIRE(ai::test(result, "health_good")); + + // health is good, enemies dead, go get stuff + ai::set(result, "no_more_enemies", true); + REQUIRE(ai::test(result, "no_more_enemies")); + + auto new_plan = ai::plan("Host::actions", result, goal); + result = ai::dump_script("\n\nWALKER COLLECT ITEMS", result, new_plan.script); + REQUIRE(ai::test(result, "no_more_items")); + REQUIRE(ai::test(result, "no_more_enemies")); +} + +TEST_CASE("Confirm EntityAI behaves as expected", "[ai]") { + ai::reset(); + ai::init("ai"); + auto ai_start = ai::load_state("Enemy::initial_state"); + auto ai_goal = ai::load_state("Enemy::final_state"); + + ai::EntityAI enemy("Enemy::actions", ai_start, ai_goal); + + enemy.set_state("detect_enemy", true); + enemy.update(); + REQUIRE(enemy.wants_to("find_enemy")); + + enemy.set_state("enemy_found", true); + enemy.set_state("in_combat", true); + enemy.update(); + REQUIRE(enemy.wants_to("kill_enemy")); + + enemy.set_state("have_item", true); + enemy.set_state("have_healing", true); + enemy.set_state("in_combat", false); + enemy.set_state("health_good", false); + enemy.update(); + REQUIRE(enemy.wants_to("use_healing")); + + enemy.set_state("have_healing", false); + enemy.set_state("tough_personality", true); + enemy.set_state("in_combat", true); + enemy.set_state("health_good", true); + enemy.update(); + REQUIRE(enemy.wants_to("kill_enemy")); + + fmt::println("\n\n\n\n=============================\n\n\n\n"); + enemy.set_state("have_healing", false); + enemy.set_state("tough_personality", false); + enemy.set_state("in_combat", true); + enemy.set_state("health_good", false); + enemy.update(); + REQUIRE(enemy.wants_to("run_away")); +} diff --git a/tests/ai_fixture.json b/tests/ai_fixture.json new file mode 100644 index 0000000..6dbb6df --- /dev/null +++ b/tests/ai_fixture.json @@ -0,0 +1,85 @@ +{ + "profile": { + "target_acquired": 0, + "target_lost": 1, + "target_in_warhead_range": 2, + "target_dead": 3 + }, + "actions": [ + { + "name": "searchSpiral", + "cost": 10, + "needs": { + "target_acquired": false, + "target_lost": true + }, + "effects": { + "target_acquired": true + } + }, + { + "name": "searchSerpentine", + "cost": 5, + "needs": { + "target_acquired": false, + "target_lost": false + }, + "effects": { + "target_acquired": true + } + }, + { + "name": "searchSpiral", + "cost": 5, + "needs": { + "target_acquired": false, + "target_lost": true + }, + "effects": { + "target_acquired": true + } + }, + { + "name": "interceptTarget", + "cost": 5, + "needs": { + "target_acquired": true, + "target_dead": false + }, + "effects": { + "target_in_warhead_range": true + } + }, + { + "name": "detonateNearTarget", + "cost": 5, + "needs": { + "target_in_warhead_range": true, + "target_acquired": true, + "target_dead": false + }, + "effects": { + "target_dead": true + } + } + ], + "states": { + "test_start": { + "target_acquired": false, + "target_lost": true, + "target_in_warhead_range": false, + "target_dead": false + }, + "test_goal": { + "target_dead": true + } + }, + "scripts": { + "test1": [ + "searchSpiral", + "searchSerpentine", + "searchSpiral", + "interceptTarget", + "detonateNearTarget"] + } +} diff --git a/tests/animation.cpp b/tests/animation.cpp new file mode 100644 index 0000000..27b6fc2 --- /dev/null +++ b/tests/animation.cpp @@ -0,0 +1,169 @@ +#include +#include "graphics/textures.hpp" +#include "algos/dinkyecs.hpp" +#include "game/config.hpp" +#include +#include +#include +#include +#include "algos/rand.hpp" +#include "graphics/animation.hpp" +#include "game/sound.hpp" +#include "game/components.hpp" + +using namespace components; +using namespace textures; +using namespace std::chrono_literals; +using namespace animation; + +Animation load_animation(const string& name) { + auto anim = animation::load("assets/animation.json", "rat_king_boss"); + anim.set_form("attack"); + + anim.transform.looped = false; + + for(size_t i = 0; i < anim.sequence.durations.size(); i++) { + anim.sequence.durations[i] = Random::uniform(1, 5); + } + + return anim; +} + +void FAKE_RENDER() { + std::this_thread::sleep_for(Random::milliseconds(5, 32)); +} + +void PLAY_TEST(Animation &anim) { + REQUIRE(anim.transform.looped == false); + anim.play(); + + while(anim.playing) { + anim.update(); + FAKE_RENDER(); + } + + REQUIRE(anim.playing == false); +} + +// /* +// * Animation is a Sheet + Sequence + Transform. +// * +// * A Sheet is just a grid of images with a predefined size for each cell. Arbitrary sized cells not supported. +// * +// * A Sequence is a list of Sheet cells _in any order_. See https://github.com/yottahmd/ganim8-lib. Sequences have a timing element for the cells, possibly a list of durations or a single duration. +// * +// * A Transform is combinations of scale and/or position easing/motion, shader effects, and things like if it's looped or toggled. +// * +// * I like the ganim8 onLoop concept, just a callback that says what to do when the animation has looped. +// */ +// TEST_CASE("new animation system", "[animation-new]") { +// textures::init(); +// sound::init(); +// sound::mute(true); +// +// auto anim = load_animation("rat_king_boss"); +// PLAY_TEST(anim); +// +// // test that toggled works +// anim.transform.toggled = true; +// PLAY_TEST(anim); +// REQUIRE(anim.sequence.current == anim.sequence.frames.size() - 1); +// anim.transform.toggled = false; +// +// bool onLoop_ran = false; +// anim.onLoop = [&](auto& seq, auto& tr) -> bool { +// seq.current = 0; +// onLoop_ran = true; +// return tr.looped; +// }; +// +// PLAY_TEST(anim); +// REQUIRE(onLoop_ran == true); +// +// // only runs twice +// anim.onLoop = [](auto& seq, auto& tr) -> bool { +// if(seq.loop_count == 2) { +// seq.current = 0; +// return false; +// } else { +// seq.current = seq.current % seq.frame_count; +// return true; +// } +// }; +// +// PLAY_TEST(anim); +// REQUIRE(anim.sequence.loop_count == 2); +// } +// +// +// TEST_CASE("confirm frame sequencing works", "[animation-new]") { +// textures::init(); +// sound::init(); +// sound::mute(true); +// +// auto anim = load_animation("rat_king_boss"); +// +// auto boss = textures::get_sprite("rat_king_boss"); +// sf::IntRect init_rect{{0,0}, {anim.sheet.frame_width, anim.sheet.frame_height}}; +// +// anim.play(); +// bool loop_ran = false; +// +// // this will check that it moved to the next frame +// anim.onLoop = [&](auto& seq, auto& tr) -> bool { +// seq.current = 0; +// loop_ran = true; +// return false; +// }; +// +// anim.onFrame = [&](){ +// anim.apply(*boss.sprite); +// }; +// +// while(anim.playing) { +// anim.update(); +// FAKE_RENDER(); +// } +// +// REQUIRE(loop_ran == true); +// REQUIRE(anim.playing == false); +// } +// +// TEST_CASE("confirm transition changes work", "[animation-new]") { +// textures::init(); +// sound::init(); +// sound::mute(true); +// +// auto sprite = *textures::get_sprite("rat_king_boss").sprite; +// sf::Vector2f pos{100,100}; +// sprite.setPosition(pos); +// auto scale = sprite.getScale(); +// auto anim = load_animation("rat_king_boss"); +// +// // also testing that onFrame being null means it's not run +// REQUIRE(anim.onFrame == nullptr); +// +// anim.play(); +// REQUIRE(anim.playing == true); +// +// while(anim.playing) { +// anim.update(); +// anim.motion(sprite, pos, scale); +// FAKE_RENDER(); +// } +// +// REQUIRE(anim.playing == false); +// REQUIRE(pos == sf::Vector2f{100, 100}); +// REQUIRE(scale != sf::Vector2f{0,0}); +// } + +TEST_CASE("playing with delta time", "[animation-new]") { + animation::Timer timer; + timer.start(); + + for(int i = 0; i < 20; i++) { + FAKE_RENDER(); + auto [tick_count, alpha] = timer.commit(); + // fmt::println("tick: {}, alpha: {}", tick_count, alpha); + } +} diff --git a/tests/base.cpp b/tests/base.cpp new file mode 100644 index 0000000..4097258 --- /dev/null +++ b/tests/base.cpp @@ -0,0 +1,9 @@ +#include +#include +#include + +using namespace fmt; + +TEST_CASE("base test", "[base]") { + REQUIRE(1 == 1); +} diff --git a/tests/battle.cpp b/tests/battle.cpp new file mode 100644 index 0000000..eb46d09 --- /dev/null +++ b/tests/battle.cpp @@ -0,0 +1,92 @@ +#include +#include +#include +#include "combat/battle.hpp" +#include "algos/simplefsm.hpp" +#include "algos/dinkyecs.hpp" +#include "gui/backend.hpp" +#include "game/level.hpp" +#include "game/components.hpp" +#include "ai/ai.hpp" +#include "graphics/palette.hpp" + +using namespace combat; +using namespace components; + +TEST_CASE("battle operations fantasy", "[combat-battle]") { + ai::reset(); + ai::init("ai"); + + auto ai_start = ai::load_state("Enemy::initial_state"); + auto ai_goal = ai::load_state("Enemy::final_state"); + auto host_start = ai::load_state("Host::initial_state"); + auto host_goal = ai::load_state("Host::final_state"); + BattleEngine battle; + + DinkyECS::Entity host = 0; + ai::EntityAI host_ai("Host::actions", host_start, host_goal); + components::Combat host_combat{ + .hp=100, .max_hp=100, .ap_delta=6, .max_ap=12, .damage=20}; + battle.add_enemy({host, &host_ai, &host_combat, true}); + + DinkyECS::Entity axe_ranger = 1; + ai::EntityAI axe_ai("Enemy::actions", ai_start, ai_goal); + components::Combat axe_combat{ + .hp=20, .max_hp=20, .ap_delta=8, .max_ap=12, .damage=20}; + battle.add_enemy({axe_ranger, &axe_ai, &axe_combat}); + + DinkyECS::Entity rat = 2; + ai::EntityAI rat_ai("Enemy::actions", ai_start, ai_goal); + components::Combat rat_combat{ + .hp=10, .max_hp=10, .ap_delta=2, .max_ap=10, .damage=10}; + battle.add_enemy({rat, &rat_ai, &rat_combat}); + + battle.set_all("enemy_found", true); + battle.set_all("in_combat", true); + battle.set_all("tough_personality", true); + battle.set_all("health_good", true); + + battle.set(rat, "tough_personality", false); + battle.set(host, "have_healing", false); + battle.set(host, "tough_personality", false); + + while(host_combat.hp > 0) { + battle.set(host, "health_good", host_combat.hp > 20); + + battle.player_request("use_healing"); + battle.player_request("kill_enemy"); + + battle.ap_refresh(); + battle.plan(); + + while(auto act = battle.next()) { + auto& [enemy, wants_to, cost, enemy_state] = *act; + + // fmt::println(">>>>> entity: {} wants to {} cost={}; has {} HP; {} ap", + // enemy.entity, wants_to, + // cost, enemy.combat->hp, + // enemy.combat->ap); + + switch(enemy_state) { + case BattleHostState::agree: + // fmt::println("HOST and PLAYER requests match {}, doing it.", wants_to); + break; + case BattleHostState::disagree: + // fmt::println("REBELIOUS ACT: {}", wants_to); + battle.clear_requests(); + REQUIRE(battle.$player_requests.size() == 0); + break; + case BattleHostState::not_host: + if(wants_to == "kill_enemy") { + enemy.combat->attack(host_combat); + } + break; + case BattleHostState::out_of_ap: + // fmt::println("ENEMY OUT OF AP"); + break; + } + } + + REQUIRE(!battle.next()); + } +} diff --git a/tests/camera.cpp b/tests/camera.cpp new file mode 100644 index 0000000..dea5f16 --- /dev/null +++ b/tests/camera.cpp @@ -0,0 +1,6 @@ +#include +#include + +TEST_CASE("view based camera system", "[camera]") { + REQUIRE(1 == 1); +} diff --git a/tests/components.cpp b/tests/components.cpp new file mode 100644 index 0000000..f2122ff --- /dev/null +++ b/tests/components.cpp @@ -0,0 +1,50 @@ +#include +#include "game/components.hpp" +#include "algos/dinkyecs.hpp" +#include "game/config.hpp" +#include + +using namespace components; +using namespace DinkyECS; + +TEST_CASE("confirm component loading works", "[components]") { + std::vector test_list{ + "assets/enemies.json", "assets/items.json", "assets/devices.json"}; + + components::init(); + DinkyECS::World world; + + for(auto test_data : test_list) { + auto config = settings::get(test_data); + auto data_list = config.json(); + + for(auto& [key, data] : data_list.items()) { + auto& components = data["components"]; + fmt::println("TEST COMPONENT: {} from file {}", key, test_data); + auto ent = world.entity(); + components::configure_entity(world, ent, components); + auto tile = components::get(components[0]); + REQUIRE(tile.display != L' '); + } + } +} + +// TEST_CASE("make sure json_mods works", "[components]") { +// auto config = settings::get("bosses"); +// // this confirms that loading something with an optional +// // field works with the json conversions in json_mods.hpp +// for(auto& comp_data : config["RAT_KING"]["components"]) { +// if(comp_data["_type"] == "AnimatedScene") { +// auto comp = components::convert(comp_data); +// } +// } +// +// // this then confirms everything else about the json conversion +// components::init(); +// +// DinkyECS::World world; +// auto rat_king = world.entity(); +// +// components::configure_entity(world, rat_king, config["RAT_KING"]["components"]); +// auto boss = world.get(rat_king); +// } diff --git a/tests/config.cpp b/tests/config.cpp new file mode 100644 index 0000000..cb7875f --- /dev/null +++ b/tests/config.cpp @@ -0,0 +1,28 @@ +#include +#include "game/config.hpp" +#include + +TEST_CASE("confirm basic config loader ops", "[config]") { + settings::Config::set_base_dir("./"); + auto config = settings::get("devices"); + auto data_list = config.json(); + auto the_keys = config.keys(); + + REQUIRE(the_keys.size() > 0); + + for(auto& [key, data] : data_list.items()) { + auto wide1 = config.wstring(key, "name"); + auto& comps = data["components"]; + + for(auto& comp_data : comps) { + REQUIRE(comp_data.contains("_type")); + } + } + + auto indexed = settings::get("tests/config_test.json"); + auto& test_0 = indexed[0]; + REQUIRE(test_0["test"] == 0); + + auto& test_1 = indexed[1]; + REQUIRE(test_1["test"] == 1); +} diff --git a/tests/config_test.json b/tests/config_test.json new file mode 100644 index 0000000..2b58790 --- /dev/null +++ b/tests/config_test.json @@ -0,0 +1,4 @@ +[ + {"test": 0}, + {"test": 1} +] diff --git a/tests/cyclic_rituals.json b/tests/cyclic_rituals.json new file mode 100644 index 0000000..cb2cac8 --- /dev/null +++ b/tests/cyclic_rituals.json @@ -0,0 +1,137 @@ +{ + "profile": { + "has_spikes": 0, + "has_magick": 1, + "shiny_bauble": 2, + "cursed_item": 3, + "$does_physical": 4, + "$does_magick": 5, + "$does_damage": 6, + "$user_cursed": 7, + "$does_healing": 8, + "$damage_boost": 9, + "$large_boost": 10, + "$is_complete": 11 + }, + "actions": [ + { + "name": "pierce_type", + "cost": 100, + "needs": { + "has_spikes": true, + "$is_complete": false + }, + "effects": { + "$does_physical": true, + "$does_damage": true + } + }, + { + "name": "magick_type", + "cost": 100, + "needs": { + "$is_complete": false, + "has_magick": true + }, + "effects": { + "$does_magick": true, + "$does_damage": true + } + }, + { + "name": "combined", + "cost": 0, + "needs": { + "$does_damage": true + }, + "effects": { + "$is_complete": true + } + }, + { + "name": "boost_magick", + "cost": 0, + "needs": { + "shiny_bauble": true, + "$does_magick": true, + "$does_damage": true, + "$is_complete": false, + "$user_cursed": false + }, + "effects": { + "$damage_boost": true + } + }, + { + "name": "boost_damage_large", + "cost": 0, + "needs": { + "cursed_item": true, + "$is_complete": false, + "$does_damage": true + }, + "effects": { + "$large_boost": true + } + }, + { + "name": "curses_user", + "cost": 0, + "needs": { + "cursed_item": true + }, + "effects": { + "$user_cursed": true + } + }, + { + "name": "heals_user", + "cost": 0, + "needs": { + "cursed_item": true, + "$does_damage": false + }, + "effects": { + "$does_healing": true, + "$is_complete": true + } + } + ], + "states": { + "initial": { + "shiny_bauble": false, + "cursed_item": false, + "has_spikes": false, + "has_magick": false, + "$user_cursed": false, + "$does_damage": false, + "$is_complete": false, + "$does_healing": false, + "$does_magick": false, + "$does_physical": false, + "$large_boost": false, + "$damage_boost": false + }, + "final": { + "$user_cursed": true, + "$does_damage": true, + "$is_complete": true, + "$does_healing": true, + "$does_magick": true, + "$does_physical": true, + "$large_boost": true, + "$damage_boost": true + } + }, + "scripts": { + "actions": [ + "boost_magick", + "pierce_type", + "magick_type", + "heals_user", + "curses_user", + "boost_damage_large", + "combined" + ] + } +} diff --git a/tests/dbc.cpp b/tests/dbc.cpp new file mode 100644 index 0000000..5c7263d --- /dev/null +++ b/tests/dbc.cpp @@ -0,0 +1,18 @@ +#include +#include "dbc.hpp" + +using namespace dbc; + +TEST_CASE("basic feature tests", "[dbc]") { + log("Logging a message."); + + pre("confirm positive cases work", 1 == 1); + + pre("confirm positive lambda", [&]{ return 1 == 1;}); + + post("confirm positive post", 1 == 1); + + post("confirm postitive post with lamdba", [&]{ return 1 == 1;}); + + check(1 == 1, "one equals 1"); +} diff --git a/tests/dinkyecs.cpp b/tests/dinkyecs.cpp new file mode 100644 index 0000000..a0fb1fe --- /dev/null +++ b/tests/dinkyecs.cpp @@ -0,0 +1,212 @@ +#include +#include "algos/dinkyecs.hpp" +#include +#include + +using namespace fmt; +using DinkyECS::Entity; +using std::string; + +struct Point { + size_t x; + size_t y; +}; + +struct Player { + string name; + Entity eid; +}; + +struct Position { + Point location; +}; + +struct Motion { + int dx; + int dy; + bool random=false; +}; + +struct Velocity { + double x, y; +}; + +struct Gravity { + double level; +}; + +struct DaGUI { + int event; +}; + +/* + * Using a function catches instances where I'm not copying + * the data into the world. + */ +void configure(DinkyECS::World &world, Entity &test) { + println("---Configuring the base system."); + Entity test2 = world.entity(); + + world.set(test, {10,20}); + world.set(test, {1,2}); + + world.set(test2, {1,1}); + world.set(test2, {9,19}); + + println("---- Setting up the player as a fact in the system."); + + auto player_eid = world.entity(); + Player player_info{"Zed", player_eid}; + // just set some player info as a fact with the entity id + world.set_the(player_info); + + world.set(player_eid, {0,0}); + world.set(player_eid, {0,0}); + + auto enemy = world.entity(); + world.set(enemy, {0,0}); + world.set(enemy, {0,0}); + + println("--- Creating facts (singletons)"); + world.set_the({0.9}); +} + +TEST_CASE("confirm ECS system works", "[ecs]") { + DinkyECS::World world; + Entity test = world.entity(); + + configure(world, test); + + Position &pos = world.get(test); + REQUIRE(pos.location.x == 10); + REQUIRE(pos.location.y == 20); + + Velocity &vel = world.get(test); + REQUIRE(vel.x == 1); + REQUIRE(vel.y == 2); + + world.query([](const auto &ent, auto &pos) { + REQUIRE(ent > 0); + REQUIRE(pos.location.x >= 0); + REQUIRE(pos.location.y >= 0); + }); + + world.query([](const auto &ent, auto &vel) { + REQUIRE(ent > 0); + REQUIRE(vel.x >= 0); + REQUIRE(vel.y >= 0); + }); + + println("--- Manually get the velocity in position system:"); + world.query([&](const auto &ent, auto &pos) { + Velocity &vel = world.get(ent); + + REQUIRE(ent > 0); + REQUIRE(pos.location.x >= 0); + REQUIRE(pos.location.y >= 0); + REQUIRE(ent > 0); + REQUIRE(vel.x >= 0); + REQUIRE(vel.y >= 0); + }); + + println("--- Query only entities with Position and Velocity:"); + world.query([&](const auto &ent, auto &pos, auto &vel) { + Gravity &grav = world.get_the(); + REQUIRE(grav.level <= 1.0f); + REQUIRE(grav.level > 0.5f); + REQUIRE(ent > 0); + REQUIRE(pos.location.x >= 0); + REQUIRE(pos.location.y >= 0); + REQUIRE(ent > 0); + REQUIRE(vel.x >= 0); + REQUIRE(vel.y >= 0); + }); + + // now remove Velocity + REQUIRE(world.has(test)); + world.remove(test); + REQUIRE_THROWS(world.get(test)); + REQUIRE(!world.has(test)); + + println("--- After remove test, should only result in test2:"); + world.query([&](const auto &ent, auto &pos, auto &vel) { + auto &in_position = world.get(ent); + auto &in_velocity = world.get(ent); + REQUIRE(pos.location.x >= 0); + REQUIRE(pos.location.y >= 0); + REQUIRE(in_position.location.x == pos.location.x); + REQUIRE(in_position.location.y == pos.location.y); + REQUIRE(in_velocity.x == vel.x); + REQUIRE(in_velocity.y == vel.y); + }); +} + +enum GUIEvent { + HIT, MISS +}; + +TEST_CASE("confirm that the event system works", "[ecs]") { + DinkyECS::World world; + DinkyECS::Entity player = world.entity(); + + // this confirms we can send these in a for-loop and get them out + int i = 0; + for(; i < 10; i++) { + world.send(GUIEvent::HIT, player, string{"hello"}); + } + + // just count down and should get the same number + while(world.has_event()) { + auto [event, entity, data] = world.recv(); + REQUIRE(event == GUIEvent::HIT); + REQUIRE(entity == player); + auto &str_data = std::any_cast(data); + REQUIRE(string{"hello"} == str_data); + i--; + } + + REQUIRE(i == 0); +} + + +TEST_CASE("confirm copying and constants", "[ecs-constants]") { + DinkyECS::World world1; + + Player player_info{"Zed", world1.entity()}; + world1.set_the(player_info); + + world1.set(player_info.eid, {10,10}); + world1.make_constant(player_info.eid); + + DinkyECS::World world2; + world1.clone_into(world2); + + auto &test1 = world1.get(player_info.eid); + auto &test2 = world2.get(player_info.eid); + + REQUIRE(test2.location.x == test1.location.x); + REQUIRE(test2.location.y == test1.location.y); + + // check for accidental reference + test1.location.x = 100; + REQUIRE(test2.location.x != test1.location.x); + + // test the facts copy over + auto &player2 = world2.get_the(); + REQUIRE(player2.eid == player_info.eid); +} + +TEST_CASE("can destroy all entity", "[ecs-destroy]") { + DinkyECS::World world; + auto entity = world.entity(); + + world.set(entity, {10,10}); + world.set(entity, {1}); + world.set(entity, {0,0}); + + world.destroy(entity); + + REQUIRE(!world.has(entity)); + REQUIRE(!world.has(entity)); + REQUIRE(!world.has(entity)); +} diff --git a/tests/event_router.cpp b/tests/event_router.cpp new file mode 100644 index 0000000..588ad35 --- /dev/null +++ b/tests/event_router.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include "gui/event_router.hpp" + +using namespace fmt; +using namespace gui; +using enum gui::routing::Event; +using enum gui::routing::State; + +using EventScript = std::vector; + +void run_script(routing::Router& router, routing::State expected, EventScript script) { + for(auto ev : script) { + router.event(ev); + } + + REQUIRE(router.in_state(expected)); +} + +TEST_CASE("basic router operations test", "[event_router]") { + routing::Router router; + + // start goes to idle + run_script(router, IDLE, { + STARTED + }); + + // simulate drag and drop + run_script(router, IDLE, { + MOUSE_DOWN, + MOUSE_MOVE, + MOUSE_UP, + KEY_PRESS + }); + + // moving the mouse outside dnd + run_script(router, IDLE, { + MOUSE_MOVE, + KEY_PRESS, + MOUSE_MOVE + }); + + // regular mouse click + run_script(router, IDLE, { + MOUSE_DOWN, + MOUSE_UP + }); + + // possible bad key press in a move? + run_script(router, IDLE, { + MOUSE_DOWN, + MOUSE_MOVE, + KEY_PRESS, + MOUSE_UP, + }); +} diff --git a/tests/fsm.cpp b/tests/fsm.cpp new file mode 100644 index 0000000..e3a1798 --- /dev/null +++ b/tests/fsm.cpp @@ -0,0 +1,67 @@ +#include +#include +#include +#include "algos/simplefsm.hpp" + +using namespace fmt; +using std::string; + +enum class MyState { + START, RUNNING, END +}; + +enum class MyEvent { + STARTED, PUSH, QUIT +}; + +class MyFSM : public DeadSimpleFSM { +public: + void event(MyEvent ev, string data="") { + switch($state) { + FSM_STATE(MyState, START, ev); + FSM_STATE(MyState, RUNNING, ev, data); + FSM_STATE(MyState, END, ev); + } + } + + void START(MyEvent ev) { + println("<<< START {}", (int)ev); + state(MyState::RUNNING); + } + + void RUNNING(MyEvent ev, string &data) { + if(ev == MyEvent::QUIT) { + println("<<< QUITTING {}", data); + state(MyState::END); + } else { + println("<<< RUN: {}", data); + state(MyState::RUNNING); + } + } + + void END(MyEvent ev) { + println("<<< STOP {}", (int)ev); + state(MyState::END); + } +}; + +TEST_CASE("confirm fsm works with optional data", "[utils]") { + MyFSM fsm; + + REQUIRE(fsm.in_state(MyState::START)); + + fsm.event(MyEvent::STARTED); + REQUIRE(fsm.in_state(MyState::RUNNING)); + + fsm.event(MyEvent::PUSH); + REQUIRE(fsm.in_state(MyState::RUNNING)); + + fsm.event(MyEvent::PUSH); + REQUIRE(fsm.in_state(MyState::RUNNING)); + + fsm.event(MyEvent::PUSH); + REQUIRE(fsm.in_state(MyState::RUNNING)); + + fsm.event(MyEvent::QUIT, "DONE!"); + REQUIRE(fsm.in_state(MyState::END)); +} diff --git a/tests/inventory.cpp b/tests/inventory.cpp new file mode 100644 index 0000000..ce93daa --- /dev/null +++ b/tests/inventory.cpp @@ -0,0 +1,52 @@ +#include +#include +#include +#include "game/inventory.hpp" + +using namespace fmt; + +TEST_CASE("base test", "[inventory]") { + return; + inventory::Model inv; + DinkyECS::Entity test_ent = 1; + + bool good = inv.add("hand_l", test_ent); + inv.invariant(); + REQUIRE(good); + + auto& slot = inv.get(test_ent); + REQUIRE(slot == "hand_l"); + + // confirm that we get false when trying to do it again + // BUG: this dies + good = inv.add("hand_l", test_ent); + REQUIRE(!good); + + auto ent = inv.get(slot); + REQUIRE(ent == test_ent); + + REQUIRE(inv.has(ent)); + REQUIRE(inv.has(slot)); + + // test base remove + inv.remove(ent); + REQUIRE(!inv.has(slot)); + REQUIRE(!inv.has(ent)); +} + +TEST_CASE("test swapping items", "[inventory]") { + inventory::Model inv; + DinkyECS::Entity hand_l_ent = 10; + DinkyECS::Entity hand_r_ent = 20; + + inv.add("hand_l", hand_l_ent); + inv.add("hand_r", hand_r_ent); + REQUIRE(inv.count() == 2); + + inv.swap(hand_l_ent, hand_r_ent); + + REQUIRE(inv.get("hand_l") == hand_r_ent); + REQUIRE(inv.get("hand_r") == hand_l_ent); + + REQUIRE(inv.count() == 2); +} diff --git a/tests/lighting.cpp b/tests/lighting.cpp new file mode 100644 index 0000000..b8cd308 --- /dev/null +++ b/tests/lighting.cpp @@ -0,0 +1,42 @@ +#include +#include +#include +#include +#include "game/map.hpp" +#include "game/level.hpp" +#include "graphics/lights.hpp" +#include "algos/point.hpp" + +using namespace lighting; + +TEST_CASE("lighting a map works", "[lighting]") { + GameDB::init(); + auto& level = GameDB::current_level(); + auto& map = *level.map; + + Point light1, light2; + + REQUIRE(map.place_entity(0, light1)); + REQUIRE(map.place_entity(0, light1)); + + LightSource source1{6, 1.0}; + LightSource source2{4,3}; + + LightRender lr(map.walls()); + + lr.reset_light(); + + lr.set_light_target(light1); + lr.set_light_target(light2); + + lr.path_light(map.walls()); + + lr.render_light(source1, light1); + lr.render_light(source2, light2); + + lr.clear_light_target(light1); + lr.clear_light_target(light2); + + Matrix &lighting = lr.lighting(); + (void)lighting; +} diff --git a/tests/loot.cpp b/tests/loot.cpp new file mode 100644 index 0000000..6f72bd2 --- /dev/null +++ b/tests/loot.cpp @@ -0,0 +1,21 @@ +#include +#include +#include +#include "game/components.hpp" +#include "algos/dinkyecs.hpp" + +using namespace fmt; +using namespace components; + +TEST_CASE("test the loot ui", "[loot]") { + auto items = settings::get("assets/items.json"); + DinkyECS::World world; + auto torch = world.entity(); + auto& data = items["TORCH_BAD"]; + + components::init(); + components::configure_entity(world, torch, data["components"]); + + auto& torch_sprite = world.get(torch); + REQUIRE(torch_sprite.name == "torch_horizontal_floor"); +} diff --git a/tests/map.cpp b/tests/map.cpp new file mode 100644 index 0000000..cf9ef69 --- /dev/null +++ b/tests/map.cpp @@ -0,0 +1,85 @@ +#include +#include +#include +#include +#include "game/map.hpp" +#include "game/level.hpp" +#include "game/systems.hpp" +#include +#include "graphics/textures.hpp" +#include "algos/rand.hpp" + +using namespace fmt; +using namespace nlohmann; +using std::string; + +json load_test_data(const string &fname) { + std::ifstream infile(fname); + return json::parse(infile); +} + +TEST_CASE("camera control", "[map]") { + GameDB::init(); + + auto& level = GameDB::current_level(); + auto& map = *level.map; + + Point center = map.center_camera({10,10}, 5, 5); + + // map.dump(center.x, center.y); + REQUIRE(center.x == 8); + REQUIRE(center.y == 8); + + Point translation = map.map_to_camera({10,10}, center); + + REQUIRE(translation.x == 2); + REQUIRE(translation.y == 2); +} + +TEST_CASE("map placement test", "[map-fail]") { + GameDB::init(); + + for(int i = 0; i < 10; i++) { + auto& level = GameDB::create_level(); + + for(size_t rnum = 0; rnum < level.map->room_count(); rnum++) { + Point pos; + + REQUIRE(level.map->place_entity(rnum, pos)); + + REQUIRE(!level.map->iswall(pos.x, pos.y)); + REQUIRE(level.map->inmap(pos.x, pos.y)); + } + } +} + +TEST_CASE("map image test", "[map]") { + GameDB::init(); + + auto& level = GameDB::current_level(); + Matrix map_tiles = matrix::make(7,7); + EntityGrid entity_map; + + auto render = std::make_shared(); + sf::Sprite sprite{render->getTexture()}; + auto player = level.world->get_the(); + auto& player_pos = level.world->get(player.entity); + auto player_display = level.world->get(player.entity).display; + + for(matrix::each_row it{level.map->walls()}; it.next();) { + player_pos.location.x = it.x; + player_pos.location.y = it.y; + System::draw_map(map_tiles, entity_map); + System::render_map(map_tiles, entity_map, *render, 2, player_display); + + // randomly test about 80% of them + if(Random::uniform(0, 100) < 20) break; + +#ifdef TEST_RENDER + // confirm we get two different maps + auto out_img = render->getTexture().copyToImage(); + bool worked = out_img.saveToFile(fmt::format("tmp/map_render{}{}.png", it.x, it.y)); + REQUIRE(worked); +#endif + } +} diff --git a/tests/matrix.cpp b/tests/matrix.cpp new file mode 100644 index 0000000..993f52b --- /dev/null +++ b/tests/matrix.cpp @@ -0,0 +1,277 @@ +#include +#include +#include +#include "game/config.hpp" +#include "algos/matrix.hpp" +#include "algos/rand.hpp" +#include "game/level.hpp" +#include +#include +#include "game/map.hpp" +#include +#include "graphics/textures.hpp" + +using namespace nlohmann; +using namespace fmt; +using std::string, std::shared_ptr; +using matrix::Matrix; + +std::shared_ptr make_map() { + GameDB::init(); + return GameDB::current_level().map; +} + +// BUG: create a test that randomizes a map then does matrix ops on it + +inline void random_matrix(Matrix &out) { + for(size_t y = 0; y < out.size(); y++) { + for(size_t x = 0; x < out[0].size(); x++) { + out[y][x] = Random::uniform(-10,10); + } + } +} + +TEST_CASE("thrash matrix iterators", "[matrix]") { + for(int count = 0; count < 5; count++) { + size_t width = Random::uniform(1, 100); + size_t height = Random::uniform(1, 100); + + Matrix test(height, matrix::Row(width)); + random_matrix(test); + + // first make a randomized matrix + matrix::each_cell cells{test}; + cells.next(); // kick off the other iterator + + for(matrix::each_row it{test}; + it.next(); cells.next()) + { + REQUIRE(test[cells.y][cells.x] == test[it.y][it.x]); + } + } +} + +TEST_CASE("thrash box distance iterators", "[matrix]") { + size_t width = Random::uniform(10, 21); + size_t height = Random::uniform(10, 25); + + Matrix result(height, matrix::Row(width)); + matrix::assign(result, 0); + + size_t size = Random::uniform(4, 10); + + Point target{width/2, height/2}; + matrix::box box{result, target.x, target.y, size}; + while(box.next()) { + result[box.y][box.x] = box.distance(); + } + + // matrix::dump(format("MAP {}x{} @ {},{}; BOX {}x{}; size: {}", + // matrix::width(result), matrix::height(result), + // target.x, target.y, box.right - box.left, box.bottom - box.top, size), + // result, target.x, target.y); +} + +TEST_CASE("thrash box iterators", "[matrix]") { + for(int count = 0; count < 5; count++) { + size_t width = Random::uniform(1, 25); + size_t height = Random::uniform(1, 33); + + Matrix test(height, matrix::Row(width)); + random_matrix(test); + + // this will be greater than the random_matrix cells + int test_i = Random::uniform(20,30); + + // go through every cell + for(matrix::each_cell target{test}; target.next();) { + PointList result; + // make a random size box + size_t size = Random::uniform(1, 33); + matrix::box box{test, target.x, target.y, size}; + + while(box.next()) { + test[box.y][box.x] = test_i; + result.push_back({box.x, box.y}); + } + + for(auto point : result) { + REQUIRE(test[point.y][point.x] == test_i); + test[point.y][point.x] = 10; // kind of reset it for another try + } + } + } +} + +TEST_CASE("thrash compass iterators", "[matrix]") { + for(int count = 0; count < 5; count++) { + size_t width = Random::uniform(1, 25); + size_t height = Random::uniform(1, 33); + + Matrix test(height, matrix::Row(width)); + random_matrix(test); + + // this will be greater than the random_matrix cells + int test_i = Random::uniform(20,30); + + // go through every cell + for(matrix::each_cell target{test}; target.next();) { + PointList result; + // make a random size box + matrix::compass compass{test, target.x, target.y}; + + while(compass.next()) { + test[compass.y][compass.x] = test_i; + result.push_back({compass.x, compass.y}); + } + + for(auto point : result) { + REQUIRE(test[point.y][point.x] == test_i); + test[point.y][point.x] = 10; // kind of reset it for another try + } + } + } +} + +TEST_CASE("prototype line algorithm", "[matrix]") { + size_t width = Random::uniform(10, 12); + size_t height = Random::uniform(10, 15); + Map map(width,height); + // create a target for the paths + Point start{.x=map.width() / 2, .y=map.height()/2}; + + for(matrix::box box{map.walls(), start.x, start.y, 3}; + box.next();) + { + Matrix result = map.walls(); + result[start.y][start.x] = 1; + Point end{.x=box.x, .y=box.y}; + + for(matrix::line it{start, end}; it.next();) + { + REQUIRE(map.inmap(it.x, it.y)); + result[it.y][it.x] = 15; + } + + result[start.y][start.x] = 15; + + // matrix::dump("RESULT AFTER LINE", result, end.x, end.y); + + bool f_found = false; + for(matrix::each_cell it{result}; it.next();) { + if(result[it.y][it.x] == 15) { + f_found = true; + break; + } + } + + REQUIRE(f_found); + } +} + +TEST_CASE("prototype circle algorithm", "[matrix]") { + for(int count = 0; count < 5; count++) { + size_t width = Random::uniform(10, 13); + size_t height = Random::uniform(10, 15); + int pos_mod = Random::uniform(-3,3); + Map map(width,height); + + // create a target for the paths + Point start{.x=map.width() / 2 + pos_mod, .y=map.height()/2 + pos_mod}; + + for(float radius = 1.0f; radius < 4.0f; radius += 0.1f) { + // use an empty map + Matrix result = map.walls(); + + for(matrix::circle it{result, start, radius}; it.next();) { + for(int x = it.left; x < it.right; x++) { + // println("top={}, bottom={}, center.y={}, dy={}, left={}, right={}, x={}, y={}", it.top, it.bottom, it.center.y, it.dy, it.left, it.right, x, it.y); + // println("RESULT {},{}", matrix::width(result), matrix::height(result)); + REQUIRE(it.y >= 0); + REQUIRE(x >= 0); + REQUIRE(it.y < int(matrix::height(result))); + REQUIRE(x < int(matrix::width(result))); + result[it.y][x] += 1; + } + } + + // matrix::dump(format("RESULT AFTER CIRCLE radius {}", radius), result, start.x, start.y); + } + } +} + +TEST_CASE("viewport iterator", "[matrix]") { + components::init(); + textures::init(); + GameDB::init(); + size_t width = Random::uniform(20, 22); + size_t height = Random::uniform(21, 25); + shared_ptr map = make_map(); + + size_t view_width = width/2; + size_t view_height = height/2; + Point player; + REQUIRE(map->place_entity(1, player)); + Point start = map->center_camera(player, view_width, view_height); + + size_t end_x = std::min(view_width, map->width() - start.x); + size_t end_y = std::min(view_height, map->height() - start.y); + + matrix::viewport it{map->walls(), start, int(view_width), int(view_height)}; + + for(size_t y = 0; y < end_y; ++y) { + for(size_t x = 0; x < end_x && it.next(); ++x) { + // still working on this + } + } +} + +TEST_CASE("random rectangle", "[matrix]") { + components::init(); + for(int i = 0; i < 5; i++) { + shared_ptr map = make_map(); + map->invert_space(); + auto wall_copy = map->walls(); + + for(size_t rnum = 0; rnum < map->room_count(); rnum++) { + Room &room = map->room(rnum); + Point pos; + + for(matrix::rando_rect it{map->walls(), room.x, room.y, room.width, room.height}; it.next();) + { + REQUIRE(size_t(it.x) >= room.x); + REQUIRE(size_t(it.y) >= room.y); + REQUIRE(size_t(it.x) <= room.x + room.width); + REQUIRE(size_t(it.y) <= room.y + room.height); + + wall_copy[it.y][it.x] = wall_copy[it.y][it.x] + 5; + } + } + // matrix::dump("WALLS FILLED", wall_copy); + } +} + +TEST_CASE("standard rectangle", "[matrix]") { + components::init(); + for(int i = 0; i < 5; i++) { + shared_ptr map = make_map(); + auto wall_copy = map->walls(); + + for(size_t rnum = 0; rnum < map->room_count(); rnum++) { + Room &room = map->room(rnum); + Point pos; + + for(matrix::rectangle it{map->walls(), room.x, room.y, room.width, room.height}; it.next();) + { + REQUIRE(size_t(it.x) >= room.x); + REQUIRE(size_t(it.y) >= room.y); + REQUIRE(size_t(it.x) <= room.x + room.width); + REQUIRE(size_t(it.y) <= room.y + room.height); + + wall_copy[it.y][it.x] = wall_copy[it.y][it.x] + 5; + } + } + + // matrix::dump("WALLS FILLED", wall_copy); + } +} diff --git a/tests/mazes.cpp b/tests/mazes.cpp new file mode 100644 index 0000000..d66a71b --- /dev/null +++ b/tests/mazes.cpp @@ -0,0 +1,182 @@ +#include +#include +#include +#include "algos/matrix.hpp" +#include "algos/rand.hpp" +#include "constants.hpp" +#include "algos/maze.hpp" +#include "algos/stats.hpp" + +#define DUMP 0 + +using std::string; +using matrix::Matrix; + +TEST_CASE("hunt-and-kill", "[mazes]") { + Map map(21, 21); + maze::Builder maze(map); + + maze.hunt_and_kill(); + REQUIRE(maze.repair() == true); + + if(DUMP) maze.dump("BASIC MAZE"); + + maze.randomize_rooms(ROOM_SIZE); + maze.hunt_and_kill(); + maze.place_doors(); + REQUIRE(maze.repair() == true); + + if(DUMP) maze.dump("ROOM MAZE"); + + REQUIRE(map.$dead_ends.size() > 0); + REQUIRE(map.$rooms.size() > 0); +} + +TEST_CASE("hunt-and-kill box", "[mazes]") { + for(int i = 25; i < 65; i += 2) { + Map map(i, i); + maze::Builder maze(map); + + maze.hunt_and_kill(); + maze.clear(); + maze.inner_box(6, 4); + maze.randomize_rooms(ROOM_SIZE); + + maze.hunt_and_kill(); + maze.open_box(6); + maze.place_doors(); + auto valid = maze.repair(); + + if(i == 41 && DUMP) { + maze.dump(valid ? "INNER BOX" : "FAILED BOX"); + } + } +} + +TEST_CASE("hunt-and-kill ring", "[mazes]") { + Map map(21, 21); + maze::Builder maze(map); + + maze.inner_donut(5.5, 3.5); + maze.hunt_and_kill(); + REQUIRE(maze.repair() == true); + + if(DUMP) maze.dump("INNER RING"); + + REQUIRE(maze.$rooms.size() == 0); +} + +TEST_CASE("hunt-and-kill fissure", "[mazes]") { + Map map(21, 21); + maze::Builder maze(map); + + maze.divide({3,3}, {19,18}); + maze.hunt_and_kill(); + REQUIRE(maze.repair() == true); + + if(DUMP) maze.dump("FISSURE MAZE"); + + REQUIRE(maze.$rooms.size() == 0); +} + +TEST_CASE("hunt-and-kill no-dead-ends", "[mazes]") { + Map map(21, 21); + maze::Builder maze(map); + + maze.hunt_and_kill(); + maze.remove_dead_ends(); + REQUIRE(maze.repair() == true); + + if(DUMP) maze.dump("NO DEAD ENDS"); +} + +TEST_CASE("hunt-and-kill too much", "[mazes]") { + for(int i = 25; i < 65; i += 2) { + Map map(i, i); + maze::Builder maze(map); + + maze.hunt_and_kill(); + maze.randomize_rooms(ROOM_SIZE); + maze.clear(); + maze.inner_donut(9, 4); + maze.divide({3,3}, {15,16}); + maze.hunt_and_kill(); + maze.place_doors(); + auto valid = maze.repair(); + + if(i == 41 && DUMP && valid) { + maze.dump("COMBINED"); + } + } +} + +TEST_CASE("hunt-and-kill validator", "[mazes]") { + bool valid = true; + Stats mofm; + + for(int i = 0; i < 10; i++) { + Stats door_prob; + + do { + Map map(33, 33); + maze::Builder maze(map); + + maze.hunt_and_kill(); + maze.clear(); + maze.inner_box(6, 4); + maze.randomize_rooms(ROOM_SIZE); + maze.hunt_and_kill(); + maze.open_box(6); + maze.place_doors(); + valid = maze.repair(); + + if(i == 9 && DUMP) { + maze.dump(valid ? "VALIDATED" : "FAILED!"); + } + door_prob.sample(valid); + } while(!valid); + + if(DUMP) door_prob.dump(); + mofm.sample(door_prob.mean()); + } + + if(DUMP) { + fmt::println("FINAL m-of-m"); + mofm.dump(); + } + + REQUIRE(mofm.mean() > 0.20); +} + + +TEST_CASE("hunt-and-kill scripting", "[mazes]") { + using namespace nlohmann::literals; + + // go up by 2 to keep odd + for(int i = 0; i < 20; i+=2) { + auto script = R"( + [ + {"action": "hunt_and_kill"}, + {"action": "clear"}, + {"action": "inner_box", "data": [6, 4]}, + {"action": "randomize_rooms", "data": [4]}, + {"action": "divide", "data": [3, 3, 10, 10]}, + {"action": "inner_donut", "data": [5.5,4.5]}, + {"action": "hunt_and_kill"}, + {"action": "open_box", "data": [6]}, + {"action": "remove_dead_ends"}, + {"action": "place_doors"} + ] + )"_json; + + Map map(23+i, 23+i); + auto [maze, valid] = maze::script(map, script); + + if(valid) { + REQUIRE(maze.validate() == true); + REQUIRE(map.INVARIANT() == true); + } + + if(DUMP) maze.dump(valid ? "SCRIPTED" : "SCRIPTED FAIL!"); + } +} diff --git a/tests/meson.build b/tests/meson.build new file mode 100644 index 0000000..a8e465b --- /dev/null +++ b/tests/meson.build @@ -0,0 +1,27 @@ +tests = files( + 'ai.cpp', + 'animation.cpp', + 'base.cpp', + 'battle.cpp', + 'camera.cpp', + 'components.cpp', + 'config.cpp', + 'dbc.cpp', + 'dinkyecs.cpp', + 'event_router.cpp', + 'fsm.cpp', + 'inventory.cpp', + 'lighting.cpp', + 'loot.cpp', + 'map.cpp', + 'matrix.cpp', + 'mazes.cpp', + 'palette.cpp', + 'pathing.cpp', + 'shaders.cpp', + 'sound.cpp', + 'spatialmap.cpp', + 'stats.cpp', + 'systems.cpp', + 'textures.cpp', +) diff --git a/tests/palette.cpp b/tests/palette.cpp new file mode 100644 index 0000000..a96ad81 --- /dev/null +++ b/tests/palette.cpp @@ -0,0 +1,24 @@ +#include +#include +#include +#include "graphics/palette.hpp" + +using namespace fmt; + +TEST_CASE("color palette test", "[color-palette]") { + palette::init(); + REQUIRE(palette::initialized() == true); + // confirm it's idempotent + palette::init(); + + sf::Color expect{10, 10, 10, 255}; + + auto gui_text = palette::get("gui/theme:dark_dark"); + REQUIRE(gui_text == expect); + + gui_text = palette::get("gui/theme", "mid"); + REQUIRE(gui_text != expect); + + expect = {100, 100, 100, 255}; + REQUIRE(gui_text == expect); +} diff --git a/tests/pathing.cpp b/tests/pathing.cpp new file mode 100644 index 0000000..1265dc1 --- /dev/null +++ b/tests/pathing.cpp @@ -0,0 +1,53 @@ +#include +#include +#include +#include +#include "algos/pathing.hpp" +#include "algos/matrix.hpp" +#include "ai/ai.hpp" +#include "game/level.hpp" +#include +#include +#include "algos/rand.hpp" +#include "game/systems.hpp" +#include "constants.hpp" + +using namespace fmt; +using namespace nlohmann; +using std::string; +using namespace components; +using namespace std::chrono_literals; + +json load_test_pathing(const string &fname) { + std::ifstream infile(fname); + return json::parse(infile); +} + +TEST_CASE("multiple targets can path", "[pathing]") { + GameDB::init(); + auto level = GameDB::create_level(); + auto walls_copy = level.map->$walls; + Pathing paths{matrix::width(walls_copy), matrix::height(walls_copy)}; + + System::multi_path(level, paths, walls_copy); + + bool diag = Random::uniform(0, 1); + auto pos = GameDB::player_position().location; + auto found = paths.find_path(pos, PATHING_TOWARD, diag); + + while(found == PathingResult::CONTINUE) { + // fmt::println("\033[2J\033[1;1H"); + // matrix::dump(diag ? "diag" : "simple", paths.$paths, pos.x, pos.y); + // std::this_thread::sleep_for(200ms); + found = paths.find_path(pos, PATHING_TOWARD, diag); + } + + // fmt::println("\033[2J\033[1;1H"); + // matrix::dump(diag ? "diag" : "simple", paths.$paths, pos.x, pos.y); + + if(found == PathingResult::FOUND) { + fmt::println("FOUND!"); + } else if(found == PathingResult::FAIL && !diag) { + REQUIRE(found != PathingResult::FAIL); + } +} diff --git a/tests/save.cpp b/tests/save.cpp new file mode 100644 index 0000000..3d4eef2 --- /dev/null +++ b/tests/save.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include "algos/dinkyecs.hpp" +#include "game/components.hpp" +#include "save.hpp" +#include +#include +#include "game/map.hpp" +#include "game/worldbuilder.hpp" +#include "tser.hpp" + +using namespace fmt; +using std::string; +using namespace components; + +TEST_CASE("basic save a world", "[save]") { + /* + DinkyECS::World world; + Map map(20, 20); + WorldBuilder builder(map); + builder.generate_map(); + + // configure a player as a fact of the world + Player player{world.entity()}; + world.set_the(player); + + world.set(player.entity, {10,10}); + world.set(player.entity, {0, 0}); + world.set(player.entity, {100, 10}); + world.set(player.entity, {"@"}); + world.set(player.entity, {102}); + + save::to_file("./savetest.world", world, map); + + DinkyECS::World in_world; + Map in_map(0, 0); // this will be changed on load + save::from_file("./savetest.world", in_world, in_map); + + Position &position1 = world.get(player.entity); + Position &position2 = in_world.get(player.entity); + REQUIRE(position1.location.x == position2.location.x); + REQUIRE(position1.location.y == position2.location.y); + + Combat &combat1 = world.get(player.entity); + Combat &combat2 = in_world.get(player.entity); + REQUIRE(combat1.hp == combat2.hp); + + Motion &motion1 = world.get(player.entity); + Motion &motion2 = in_world.get(player.entity); + REQUIRE(motion1.dx == motion2.dx); + REQUIRE(motion1.dy == motion2.dy); + + Tile &tile1 = world.get(player.entity); + Tile &tile2 = in_world.get(player.entity); + REQUIRE(tile1.chr == tile2.chr); + + REQUIRE(map.width() == in_map.width()); + REQUIRE(map.height() == in_map.height()); + REQUIRE(map.$walls == in_map.$walls); + + Inventory &inv = world.get(player.entity); + REQUIRE(inv.gold == 102); + */ +} diff --git a/tests/shaders.cpp b/tests/shaders.cpp new file mode 100644 index 0000000..cf86e4f --- /dev/null +++ b/tests/shaders.cpp @@ -0,0 +1,26 @@ +#include +#include +#include +#include "graphics/shaders.hpp" + +using namespace fmt; + +TEST_CASE("shader loading/init works", "[shaders]") { + shaders::init(); + int version = shaders::version(); + + std::shared_ptr ui_shader = shaders::get("ui_shader"); + auto other_test = shaders::get("ui_shader"); + + REQUIRE(ui_shader != nullptr); + REQUIRE(ui_shader == other_test); + REQUIRE(shaders::updated(version) == false); + + int new_version = shaders::reload(); + REQUIRE(version != shaders::version()); + REQUIRE(version != new_version); + REQUIRE(shaders::version() == new_version); + REQUIRE(shaders::updated(version) == true); + version = new_version; + +} diff --git a/tests/sound.cpp b/tests/sound.cpp new file mode 100644 index 0000000..0d5d13d --- /dev/null +++ b/tests/sound.cpp @@ -0,0 +1,14 @@ +#include +#include +#include +#include "game/sound.hpp" + +using namespace fmt; + +TEST_CASE("test sound manager", "[sound]") { + sound::init(); + + sound::play("blank"); + + sound::play_at("blank", 0.1, 0.1, 0.1); +} diff --git a/tests/spatialmap.cpp b/tests/spatialmap.cpp new file mode 100644 index 0000000..1c366a5 --- /dev/null +++ b/tests/spatialmap.cpp @@ -0,0 +1,252 @@ +#include +#include +#include +#include "algos/spatialmap.hpp" +#include "algos/dinkyecs.hpp" +#include "algos/rand.hpp" +#include +#include + +using DinkyECS::Entity; +using namespace fmt; + + +TEST_CASE("SpatialMap::insert", "[spatialmap]") { + DinkyECS::World world; + SpatialMap map; + + auto player = world.entity(); + auto item = world.entity(); + auto potion = world.entity(); + auto enemy = world.entity(); + Point at{10,10}; + Point enemy_at{11,11}; + + map.insert(at, item, false); + map.insert(at, potion, false); + REQUIRE(!map.occupied(at)); + + map.insert(at, player, true); + REQUIRE(map.occupied_by(at) == player); + + REQUIRE_THROWS(map.insert(at, enemy, true)); + + map.insert(enemy_at, enemy, true); + REQUIRE(map.occupied_by(enemy_at) == enemy); + REQUIRE(map.occupied(enemy_at)); +} + +TEST_CASE("SpatialMap::remove", "[spatialmap]") { + DinkyECS::World world; + SpatialMap map; + + auto player = world.entity(); + auto item = world.entity(); + Point at{120, 120}; + + // confirm that things can be in any order + map.insert(at, player, true); + map.insert(at, item, false); + REQUIRE(map.occupied(at)); + REQUIRE(map.occupied_by(at) == player); + + auto data = map.remove(at, player); + REQUIRE(!map.occupied(at)); + REQUIRE(data.entity == player); + REQUIRE(data.collision == true); + + REQUIRE_THROWS(map.remove(at, player)); +} + + +TEST_CASE("SpatialMap::move", "[spatialmap]") { + DinkyECS::World world; + SpatialMap map; + + auto player = world.entity(); + auto item = world.entity(); + Point at{10, 320}; + map.insert(at, player, true); + map.insert(at, item, false); + REQUIRE(map.occupied(at)); + + auto enemy = world.entity(); + auto potion = world.entity(); + Point enemy_at{11, 320}; + map.insert(enemy_at, enemy, true); + map.insert(enemy_at, potion, false); + REQUIRE(map.occupied(enemy_at)); + REQUIRE(map.occupied_by(enemy_at) == enemy); + + Point target{at.x + 1, at.y}; + + // try bad move with a slot that's empty + REQUIRE_THROWS(map.move({0,0}, target, player)); + + // try move into an occupied spot also fails + REQUIRE_THROWS(map.move(at, target, player)); + + // now move to a new spot, need to add them back + map.insert(at, player, true); + target.x++; // just move farther + map.move(at, target, player); + + REQUIRE(map.occupied(target)); + REQUIRE(map.occupied_by(target) == player); + auto data = map.remove(target, player); + REQUIRE(data.entity == player); + REQUIRE(data.collision == true); +} + + +TEST_CASE("SpatialMap::occupied/something_there", "[spatialmap]") { + DinkyECS::World world; + SpatialMap map; + + auto player = world.entity(); + auto item = world.entity(); + + Point at{1000, 20}; + // first test empty locations + REQUIRE(!map.something_there(at)); + REQUIRE(!map.occupied(at)); + + // then when there's something without collision + map.insert(at, item, false); + REQUIRE(map.something_there(at)); + REQUIRE(!map.occupied(at)); + + // finally with collision and an item there + map.insert(at, player, true); + REQUIRE(map.something_there(at)); + REQUIRE(map.occupied(at)); + REQUIRE(map.occupied_by(at) == player); + + // then remove the item and still have collision + + map.remove(at, item); + REQUIRE(map.something_there(at)); + REQUIRE(map.occupied(at)); + REQUIRE(map.occupied_by(at) == player); + + // remove player and back to no collision + map.remove(at, player); + REQUIRE(!map.something_there(at)); + REQUIRE(!map.occupied(at)); + + // last thing, put just the player in at a new spot + Point target{at.x+1, at.y+10}; + map.insert(target, player, true); + REQUIRE(map.something_there(target)); + REQUIRE(map.occupied(target)); + REQUIRE(map.occupied_by(target) == player); +} + + +TEST_CASE("SpatialMap::get", "[spatialmap]") { + DinkyECS::World world; + SpatialMap map; + + auto player = world.entity(); + auto item = world.entity(); + Point at{101, 31}; + + // finally with collision and an item there + map.insert(at, player, true); + REQUIRE(map.occupied(at)); + REQUIRE(map.occupied_by(at) == player); + + auto entity = map.get(at); + REQUIRE(player == entity); + + // This probably doesn't work so need to + // rethink how get works. + map.insert(at, item, false); + entity = map.get(at); + REQUIRE(entity == item); +} + +TEST_CASE("SpatialMap::find", "[spatialmap-find]") { + DinkyECS::World world; + SpatialMap map; + Point at{101, 31}; + DinkyECS::Entity should_collide = DinkyECS::NONE; + + for(int i = 0; i < 10; i++) { + auto ent = world.entity(); + map.insert(at, ent, i == 8); + + if(i == 8) { + should_collide = ent; + } + } + + auto collision = map.find(at, [&](auto data) -> bool { + return data.collision; + }); + + REQUIRE(collision == should_collide); + + auto no_collide = map.find(at, [&](auto data) -> bool { + return !data.collision; + }); + + REQUIRE(no_collide != should_collide); +} + +TEST_CASE("SpatialMap::neighbors", "[spatialmap-neighbors]") { + DinkyECS::World world; + SpatialMap map; + + auto player = world.entity(); + auto enemy1 = world.entity(); + auto enemy2 = world.entity(); + //auto item1 = world.entity(); + //auto item2 = world.entity(); + Point at{101, 31}; + + map.insert(at, player, true); + map.insert({at.x+1, at.y}, enemy1, true); + map.insert({at.x-1, at.y+1}, enemy2, true); + + auto result = map.neighbors(at, true); + REQUIRE(result.found); + REQUIRE(result.nearby.size() == 2); + + bool maybe = result.nearby[0] == enemy1 || result.nearby[1] == enemy1; + REQUIRE(maybe); + + maybe = result.nearby[0] == enemy2 || result.nearby[1] == enemy2; + REQUIRE(maybe); + + result = map.neighbors(at, false); + REQUIRE(result.found); + REQUIRE(result.nearby.size() == 1); + REQUIRE(result.nearby[0] == enemy1); +} + +TEST_CASE("SpatialMap::distance_sorted", "[spatialmap]") { + DinkyECS::World world; + SpatialMap map; + + auto player = world.entity(); + auto enemy1 = world.entity(); + auto item = world.entity(); + + map.insert({1,1}, player, true); + map.insert({4,4}, enemy1, true); + map.insert({3, 3}, item, false); + + SortedEntities result; + map.distance_sorted(result, {1, 1}, 100); + REQUIRE(result.size() == 3); + REQUIRE(result[0].entity == enemy1); + REQUIRE(result[1].entity == item); + REQUIRE(result[2].entity == player); + + int prev_dist = std::numeric_limits::max(); + for(auto rec : result) { + REQUIRE(rec.dist_square < prev_dist); + prev_dist = rec.dist_square; + } +} diff --git a/tests/stats.cpp b/tests/stats.cpp new file mode 100644 index 0000000..a5783e2 --- /dev/null +++ b/tests/stats.cpp @@ -0,0 +1,27 @@ +#include +#include "algos/stats.hpp" +#include "algos/rand.hpp" +#include +#include + +TEST_CASE("basic stats tests", "[stats]") { + Stats stat1; + stat1.sample(1.0); + + for(int i = 0; i < 20; i++) { + double x = Random::normal(20.0,5.0); + stat1.sample(x); + REQUIRE(!std::isnan(stat1.stddev())); + REQUIRE(stat1.mean() < stat1.mean() + stat1.stddev() * 4.0); + } + + stat1.dump(); + + stat1.reset(); + REQUIRE(stat1.n == 0.0); + + auto timer = stat1.time_start(); + for(int i = 0; i < 20; i++) { + stat1.sample_time(timer); + } +} diff --git a/tests/systems.cpp b/tests/systems.cpp new file mode 100644 index 0000000..18c6384 --- /dev/null +++ b/tests/systems.cpp @@ -0,0 +1,36 @@ +#include +#include +#include "game/systems.hpp" +#include +#include + + +TEST_CASE("figure out best rotation direction", "[systems-rotate]") { + Matrix map = matrix::make(3, 3); + + Point player_at{1, 1}; + map[player_at.y][player_at.x] = 2; + + + for(matrix::box target{map, player_at.x, player_at.y, 1}; target.next();) { + for(matrix::box aiming_at{map, player_at.x, player_at.y, 1}; aiming_at.next();) { + map[aiming_at.y][aiming_at.x] = 10; + + float target_dx = float(player_at.x) - float(target.x); + float target_dy = float(player_at.y) - float(target.y); + float aiming_dx = float(player_at.x) - float(aiming_at.x); + float aiming_dy = float(player_at.y) - float(aiming_at.y); + + float target_angle = atan2(-target_dy, target_dx) * (180.0 / std::numbers::pi); + float aiming_angle = atan2(-aiming_dy, aiming_dx) * (180.0 / std::numbers::pi); + + float diff = target_angle - aiming_angle; + double normalized = fmod(diff + 360.0, 360.0); + + REQUIRE(normalized >= 0); + REQUIRE(normalized <= 360); + + map[aiming_at.y][aiming_at.x] = 0; + } + } +} diff --git a/tests/textures.cpp b/tests/textures.cpp new file mode 100644 index 0000000..eb5d490 --- /dev/null +++ b/tests/textures.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include "graphics/textures.hpp" +#include "constants.hpp" +#include "game/components.hpp" + +using namespace fmt; + +TEST_CASE("test texture management", "[textures]") { + components::init(); + textures::init(); + + auto spider = textures::get_sprite("rat_with_sword"); + REQUIRE(spider.sprite != nullptr); + REQUIRE(spider.texture != nullptr); + REQUIRE(spider.frame_size.x == TEXTURE_WIDTH); + REQUIRE(spider.frame_size.y == TEXTURE_HEIGHT); + + auto image = textures::load_image("assets/sprites/rat_with_sword.png"); + + size_t floor_tile = textures::get_id("floor_tile"); + size_t gray_stone = textures::get_id("door_plain"); + + auto floor_ptr = textures::get_surface(floor_tile); + REQUIRE(floor_ptr != nullptr); + + auto gray_stone_ptr = textures::get_surface(gray_stone); + REQUIRE(gray_stone_ptr != nullptr); + + auto& light = textures::get_ambient_light(); + REQUIRE(light.size() > 0); + REQUIRE(light[floor_tile] == 0); + REQUIRE(light[gray_stone] > 0); + + auto& tiles = textures::get_map_tile_set(); + REQUIRE(tiles.size() > 0); + REQUIRE(tiles[floor_tile] > 0); + REQUIRE(tiles[gray_stone] > 0); + + auto ceiling = textures::get_ceiling(floor_tile); + REQUIRE(ceiling != nullptr); +} diff --git a/tools/animator.cpp b/tools/animator.cpp new file mode 100644 index 0000000..4f47b73 --- /dev/null +++ b/tools/animator.cpp @@ -0,0 +1,347 @@ +#define FSM_DEBUG 1 +#include "game/sound.hpp" +#include "ai/ai.hpp" +#include +#include "graphics/shaders.hpp" +#include "gui/backend.hpp" +#include "constants.hpp" +#include "graphics/animation.hpp" +#include "tools/animator.hpp" +#include +#include +#include + +using namespace std::chrono_literals; + +bool YES_SYNC=true; + +namespace animator { + + void FSM::init(const std::string &sprite_name, const std::string &anim_name, const std::string &background) { + $timer.start(); + $sprite_name = sprite_name; + $anim_name = anim_name; + $background = background; + + // this loads the animation + reload(); + + sf::Vector2u new_size{(unsigned int)$anim.sheet.frame_width, (unsigned int)$anim.sheet.frame_height}; + $window = sf::RenderWindow(sf::VideoMode(new_size), "Animation Crafting Tool"); + $window.setPosition({0,0}); + + $ui.init($sprite_name, $background, new_size.x, new_size.y); + + // need to keep these around + $pos = $ui.sprite->getPosition(); + $scale = $ui.sprite->getScale(); + + if(YES_SYNC) { + $window.setVerticalSyncEnabled(VSYNC); + if(FRAME_LIMIT) $window.setFramerateLimit(FRAME_LIMIT); + } + + $ui.update_status($anim); + } + + void FSM::event(Event ev, std::any data) { + switch($state) { + FSM_STATE(State, START, ev); + FSM_STATE(State, ANIMATE, ev); + FSM_STATE(State, END, ev); + } + } + + void FSM::START(Event ev) { + state(State::ANIMATE); + } + + void FSM::ANIMATE(Event ev) { + switch(ev) { + case Event::PLAY_STOP: + if($anim.playing) { + $anim.stop(); + } else { + $anim.play(); + } + break; + case Event::PREV_FORM: + change_form(-1); + $ui.update_status($anim); + break; + case Event::NEXT_FORM: + change_form(1); + $ui.update_status($anim); + break; + case Event::RELOAD: + reload(); + $ui.update_status($anim); + state(State::START); + break; + case Event::TEST_SHADER: + $ui.effect = $ui.effect == nullptr ? shaders::get("flame") : nullptr; + break; + default: + state(State::START); + } + } + + void FSM::END(Event ev) { + } + + void FSM::change_form(int direction) { + if($anim.forms.size() == 0) { + $ui.show_error("NO FORMS!"); + return; + } + + $cur_form_i = std::clamp($cur_form_i + direction, 0, int($anim.forms.size()) - 1); + dbc::check($cur_form_i >= 0, "CATASTROPHE! cur_form_i went below 0. How?"); + + // this is the dumbest shit ever + auto key_view = std::views::keys($anim.forms); + std::vector keys(key_view.begin(), key_view.end()); + + dbc::check(size_t($cur_form_i) < keys.size(), "form index outside of form keys vector"); + + $cur_form = keys[$cur_form_i]; + fmt::println("cur_form_i {}; cur_form: {}", $cur_form_i, $cur_form); + + // set_form will stop the animation + $anim.set_form($cur_form); + $anim.play(); + } + + void FSM::update() { + if($anim.playing) { + $anim.update(); + $anim.apply(*$ui.sprite); + if($ui.effect != nullptr) $anim.apply_effect($ui.effect); + $anim.motion(*$ui.sprite, $pos, $scale); + } + } + + void FSM::check_changed() { + if($timer.getElapsedTime().toDuration() > 500ms) { + try { + auto mod_time = std::filesystem::last_write_time("assets/animation.json"); + + if($last_mod_time < mod_time) { + event(Event::RELOAD); + } + + $timer.restart(); + } catch(const std::filesystem::filesystem_error& err) { + fmt::println("failed to open {}: {}", err.path1().string(), err.what()); + $timer.restart(); + } + } + } + + void FSM::reload() { + animation::Animation new_anim; + + try { + new_anim = animation::load("assets/animation.json", $anim_name); + } catch(...) { + $ui.show_error("Failed to load JSON"); + return; + } + + if(!new_anim.has_form($cur_form)) { + $ui.show_error(fmt::format("No form {}", $cur_form)); + $cur_form = "idle"; + $cur_form_i = 0; + } + + new_anim.set_form($cur_form); + + try { + $last_mod_time = std::filesystem::last_write_time("assets/animation.json"); + } catch(...) { + $ui.show_error("Filesystem error"); + } + + if($anim.form_name == new_anim.form_name) { + $ui.clear_error(); + } + + $anim = new_anim; + $anim.play(); + } + + void FSM::handle_keyboard_mouse() { + while(const auto ev = $window.pollEvent()) { + using enum game::Event; + using KEY = sf::Keyboard::Scan; + auto gui_ev = $router.process_event(ev); + auto mouse_pos = $window.mapPixelToCoords($router.position); + + switch(gui_ev) { + case KEY_PRESS: + if($router.scancode == KEY::Space) { + event(Event::PLAY_STOP); + } else if($router.scancode == KEY::Up) { + event(Event::PREV_FORM); + } else if($router.scancode == KEY::Down) { + event(Event::NEXT_FORM); + } else if($router.scancode == KEY::M) { + $mute = !$mute; + sound::mute($mute); + } else if($router.scancode == KEY::S) { + event(Event::TEST_SHADER); + } + break; + case MOUSE_CLICK: + $ui.mouse(mouse_pos.x, mouse_pos.y, guecs::NO_MODS); + break; + case MOUSE_MOVE: + $ui.mouse(mouse_pos.x, mouse_pos.y, {1 << guecs::ModBit::hover}); + break; + case QUIT: + state(State::END); + default: + break; // ignored + } + } + } + + void FSM::render() { + $window.clear(); + $ui.render($window, false); + $window.display(); + } + + bool FSM::active() { + return !in_state(State::END); + } + + void UI::init(const std::string& sprite_name, const std::string& background, int width, int height) { + $ui.position(0,0, width, height); + $ui.layout("[=viewer]"); + + if(background != "") { + $ui.set($ui.MAIN, {$ui.$parser, guecs::THEME.TRANSPARENT}); + auto& bg = $ui.get($ui.MAIN); + bg.set_sprite(background, true); + } + + auto viewer = $ui.entity("viewer"); + // BUG: this is some jank bullshit but it works + $ui.set(viewer, {sprite_name, 0, false}); + $ui.init(); + sprite = $ui.get(viewer).sprite; + dbc::check(sprite != nullptr, "failed to initialize $ui.sprite"); + + $ui.remove(viewer); + + $overlay.position(0, 0, width/4, height/4); + $overlay.layout( + "[form]" + "[sequence]" + "[transform]" + "[error]"); + + $overlay.init(); + + $initialized_this_sucks_ass = true; + } + + void UI::render(sf::RenderWindow& window, bool debug) { + $ui.render(window); + window.draw(*sprite, effect.get()); + $overlay.render(window); + + if(debug) { + $ui.debug_layout(window); + $overlay.debug_layout(window); + } + } + + bool UI::mouse(float x, float y, guecs::Modifiers mods) { + return $ui.mouse(x, y, mods); + } + + void UI::update_status(animation::Animation& anim) { + $overlay.show_text("form", guecs::to_wstring(anim.form_name)); + $overlay.show_text("sequence", guecs::to_wstring(anim.sequence_name)); + $overlay.show_text("transform", guecs::to_wstring(anim.transform_name)); + } + + void UI::show_error(const std::string& message) { + if($initialized_this_sucks_ass) { + $overlay.show_text("error", guecs::to_wstring(message)); + } else { + dbc::log(message); + } + } + + void UI::clear_error() { + if($initialized_this_sucks_ass) { + $overlay.show_text("error", L""); + } + } + + std::shared_ptr UI::get_sprite() { + auto viewer = $ui.entity("viewer"); + return $ui.get(viewer).sprite; + } +} + +int error_usage() { + fmt::println("USAGE: animator -h -b -s -a "); + return 1; +} + +int main(int argc, char* argv[]) { + ai::init("ai"); + components::init(); + gui::Backend backend; + guecs::init(&backend); + shaders::init(); + + std::string sprite_name; + std::string background; + std::string anim_name; + int opt = 0; + + while((opt = getopt(argc, argv, "hb:s:a:")) != -1) { + switch(opt) { + case 'b': + background = optarg; + break; + case 's': + sprite_name = optarg; + break; + case 'a': + anim_name = optarg; + break; + case 'h': // fallthrough + error_usage(); + break; + default: + return error_usage(); + break; + } + } + + if(sprite_name == "") { + return error_usage(); + } else if(anim_name == "") { + anim_name = sprite_name; // default to the same + } + + sound::mute(true); + + animator::FSM main; + main.init(sprite_name, anim_name, background); + + while(main.active()) { + main.update(); + main.render(); + main.check_changed(); + main.handle_keyboard_mouse(); + } + + return 0; +} diff --git a/tools/animator.hpp b/tools/animator.hpp new file mode 100644 index 0000000..c6c06c3 --- /dev/null +++ b/tools/animator.hpp @@ -0,0 +1,75 @@ +#pragma once +#include +#include "gui/event_router.hpp" +#include "gui/guecstra.hpp" +#include "events.hpp" +#include + +namespace animator { + + enum class State { + START=__LINE__, + ANIMATE=__LINE__, + END=__LINE__, + }; + + enum class Event { + TICK=__LINE__, + PLAY_STOP=__LINE__, + NEXT_FORM=__LINE__, + PREV_FORM=__LINE__, + TEST_SHADER=__LINE__, + RELOAD=__LINE__, + }; + + struct FSM; + + struct UI { + guecs::UI $ui; + guecs::UI $overlay; + std::shared_ptr sprite = nullptr; + std::shared_ptr effect = nullptr; + bool $initialized_this_sucks_ass = false; + + void button(const std::string& name, std::function cb); + void init(const std::string& sprite_name, const std::string& background, int width, int height); + void render(sf::RenderWindow& window, bool debug=false); + bool mouse(float x, float y, guecs::Modifiers mods); + void update_status(animation::Animation& anim); + std::shared_ptr get_sprite(); + void show_error(const std::string& message); + void clear_error(); + }; + + struct FSM : public DeadSimpleFSM { + UI $ui; + gui::routing::Router $router; + sf::RenderWindow $window; + sf::Vector2f $pos{0,0}; + sf::Vector2f $scale{0,0}; + animation::Animation $anim; + std::string $sprite_name=""; + std::string $anim_name=""; + std::string $background=""; + std::filesystem::file_time_type $last_mod_time; + sf::Clock $timer; + std::string $cur_form = "idle"; + int $cur_form_i = 0; + bool $mute = true; + + void init(const std::string &sprite_name, const std::string& background, const std::string &anim_name); + void event(Event ev, std::any data={}); + void START(Event ev); + void ANIMATE(Event ev); + void END(Event ev); + + void handle_keyboard_mouse(); + void render(); + bool active(); + void update(); + void reload(); + void check_changed(); + void change_form(int direction); + }; + +} diff --git a/tools/fragviewer.cpp b/tools/fragviewer.cpp new file mode 100644 index 0000000..95330b9 --- /dev/null +++ b/tools/fragviewer.cpp @@ -0,0 +1,123 @@ +#include "graphics/textures.hpp" +#include +#include +#include +#include +#include "dbc.hpp" +#include +#include + +bool SHADER_RELOAD = true; + +void Handle_events(sf::RenderWindow &window) { + // is this a main event loop + while (const auto event = window.pollEvent()) { + if(event->is()) { + window.close(); + } else if(const auto* key = event->getIf()) { + using KEY = sf::Keyboard::Scan; + if(key->scancode == KEY::Escape || key->scancode == KEY::Space) { + SHADER_RELOAD = true; + } + } + } +} + +int main(int argc, char *argv[]) { + int opt = 0; + bool load_sprite = false; + std::string sprite_name; + std::string frag_name; + sf::Clock clock; + sf::Shader shader; + sf::RectangleShape rect; + textures::SpriteTexture sprite_texture; + sf::Vector2f u_resolution{720.0, 720.0}; + sf::Vector2u screen_resolution{720, 720}; + + textures::init(); + + dbc::check(sf::Shader::isAvailable(), "You apparently are a time traveler from the 80s who doesn't have shaders."); + + while((opt = getopt(argc, argv, "hs:f:x:y:")) != -1) { + switch(opt) { + case 's': + sprite_name = optarg; + load_sprite = true; + break; + case 'f': + frag_name = optarg; + break; + case 'x': + screen_resolution.x = std::atoi(optarg); + u_resolution.x = screen_resolution.x; + break; + case 'y': + screen_resolution.y = std::atoi(optarg); + u_resolution.y = screen_resolution.y; + break; + case 'h': + fmt::println( + "fragviewer is a simple tool to play fragment shaders.\n" + "USAGE: fragviewer [-x size_x -y size_y] [-s sprite_name] -f shader.frag"); + return 0; + default: + fmt::println("USAGE: fragviewer [-x size_x -y size_y] [-s sprite_name] -f shader.frag"); + return -1; + } + } + + fmt::println("sprite is", sprite_name); + + dbc::check(frag_name != "", "You must set the -f shader.frag option."); + + sf::RenderWindow window(sf::VideoMode(screen_resolution), "SFML Frag Shader Viewer"); + window.setFramerateLimit(60); + window.setVerticalSyncEnabled(true); + + if(load_sprite) { + sprite_texture = textures::get_sprite(sprite_name); + sprite_texture.sprite->setPosition({0,0}); + auto bounds = sprite_texture.sprite->getLocalBounds(); + sf::Vector2f scale{u_resolution.x / bounds.size.x, + u_resolution.y / bounds.size.y}; + sprite_texture.sprite->setScale(scale); + shader.setUniform("source", sf::Shader::CurrentTexture); + } else { + rect.setPosition({0,0}); + rect.setSize(u_resolution); + } + + + while(window.isOpen()) { + Handle_events(window); + + if(SHADER_RELOAD) { + bool good_shader = shader.loadFromFile(frag_name, sf::Shader::Type::Fragment); + + if(!good_shader) { + fmt::print("!!!!!! failed to load shader {}\n", frag_name); + } + + shader.setUniform("u_resolution", u_resolution); + SHADER_RELOAD = false; + } + + sf::Time u_time = clock.getElapsedTime(); + sf::Vector2i mouse_at = sf::Mouse::getPosition(window); + sf::Vector2f u_mouse{float(mouse_at.x), float(mouse_at.y)}; + + shader.setUniform("u_mouse", u_mouse); + shader.setUniform("u_time", u_time.asSeconds()); + + window.clear(); + + if(load_sprite) { + window.draw(*sprite_texture.sprite, &shader); + } else { + window.draw(rect, &shader); + } + + window.display(); + } +} diff --git a/tools/icongen.cpp b/tools/icongen.cpp new file mode 100644 index 0000000..2719fb3 --- /dev/null +++ b/tools/icongen.cpp @@ -0,0 +1,266 @@ +#include +#include "dbc.hpp" +#include +#include +#include "constants.hpp" +#include "game/config.hpp" +#include +#include "algos/shiterator.hpp" +#include +#include +#include "graphics/textures.hpp" +#include "graphics/palette.hpp" + +namespace fs = std::filesystem; +constexpr const int TILE_COUNT=10; +constexpr const sf::Color DEFAULT_COLOR{255, 255, 255, 255}; +using namespace nlohmann; + +using namespace shiterator; + +using MapRow = BaseRow; +using MapGrid = Base; + +using BoolRow = BaseRow; +using BoolGrid = Base; + +struct MapConfig { + MapGrid map = make(TILE_COUNT, TILE_COUNT); + BoolGrid centered = make(TILE_COUNT, TILE_COUNT); + std::unordered_map colors; + std::unordered_map backgrounds; + std::unordered_map names; + each_row_t it{map}; +}; + +struct MapTileBuilder { + unsigned int $font_size = 20; + sf::Glyph $glyph; + sf::Font $font{FONT_FILE_NAME}; + std::shared_ptr $render = nullptr; + sf::Vector2i $size; + sf::Vector2i $image_size; + sf::RenderTexture $temp_render; + + MapTileBuilder(size_t x, size_t y) : + $size(x, y), + $image_size($size.x * TILE_COUNT, $size.y * TILE_COUNT), + $temp_render({(unsigned int)$size.x, (unsigned int)$size.y}) + { + $font.setSmooth(false); + } + + void best_size(wchar_t for_char, bool centered) { + float factor = centered ? 0.8f : 1.0f; + sf::Vector2i adjusted_size = {int($size.x * factor), int($size.y * factor)}; + $font_size = 20; // reset the size + // fit the glyph in our box height + auto temp = $font.getGlyph(for_char, $font_size, false); + auto temp_size = $font_size; + + while(temp.textureRect.size.y <= adjusted_size.y + && temp.textureRect.size.x <= adjusted_size.x) + { + $glyph = temp; + $font_size = temp_size; + + temp_size++; + temp = $font.getGlyph(for_char, temp_size, false); + } + } + + void save_image(std::string icon_path) { + dbc::check($render != nullptr, "You have to call run() first."); + fs::path out_path{icon_path}; + + if(fs::exists(out_path)) { + fs::remove(out_path); + } + + sf::Image out_img = $render->getTexture().copyToImage(); + + bool worked = out_img.saveToFile(out_path); + dbc::check(worked, "Failed to write screenshot.png"); + } + + void run_real_textures(MapConfig &config) { + textures::init(); + sf::Vector2u crop{$size.x * (unsigned int)config.it.width, ($size.y) * ((unsigned int)config.it.y + 1)}; + $render = std::make_shared(crop); + $render->clear({0,0,0,0}); + + $render->setSmooth(false); + sf::Vector2f cell_pos{0.0f,0.0f}; + + for(each_row_t it{config.map}; it.next();) { + wchar_t display_char = config.map[it.y][it.x]; + // stop when there's no more cells set + if(display_char == 0) break; + + cell_pos.x = it.x * $size.x; + cell_pos.y = it.y * $size.y; + + auto& name = config.names.at(display_char); + auto id = textures::get_id(name); + auto& img = textures::get_surface_img(id); + auto img_size = img.getSize(); + + sf::Texture surface{img}; + + sf::Vector2f scale{float($size.x) / float(img_size.x), + float($size.y) / float(img_size.y)}; + + sf::Sprite sprite{surface}; + sprite.setScale(scale); + sprite.setPosition(cell_pos); + $render->draw(sprite); + $render->display(); + } + } + + void run(MapConfig& config) { + sf::Vector2u crop{$size.x * (unsigned int)config.it.width, $size.y * ((unsigned int)config.it.y+1)}; + $render = std::make_shared(crop); + $render->clear({0,0,0,0}); + + $render->setSmooth(false); + sf::Vector2f cell_pos{0.0f,0.0f}; + sf::RectangleShape background({(float)$size.x, (float)$size.y}); + + for(each_row_t it{config.map}; it.next();) { + // a 0 slot means we're done + if(config.map[it.y][it.x] == 0) break; + + cell_pos.x = it.x * $size.x; + cell_pos.y = it.y * $size.y; + bool is_centered = config.centered[it.y][it.x]; + + wchar_t display_char = config.map[it.y][it.x]; + std::wstring content{display_char}; + auto bg = config.backgrounds.at(display_char); + auto fg = config.colors.at(display_char); + + best_size(display_char, is_centered); + + sf::Text icon{$font, content, $font_size}; + icon.setFillColor({255, 255, 255, 255}); + $temp_render.draw(icon); + $temp_render.clear({0,0,0,0}); + + auto& font_texture = $font.getTexture($font_size); + sf::Sprite sprite{font_texture, $glyph.textureRect}; + auto t_size = $glyph.textureRect.size; + + dbc::check($size.x - t_size.x >= 0, "font too big on x"); + dbc::check($size.y - t_size.y >= 0, "font too big on y"); + + // draw the background first + background.setFillColor(bg); + + if(is_centered) { + sf::Vector2f center{ + float(($size.x - t_size.x) / 2), + float(($size.y - t_size.y) / 2)}; + + sprite.setScale({1.0f, 1.0f}); + sprite.setPosition({cell_pos.x + center.x, cell_pos.y + center.y}); + } else { + sf::Vector2f scale{float($size.x) / float(t_size.x), float($size.y) / float(t_size.y)}; + sprite.setScale(scale); + sprite.setPosition(cell_pos); + background.setPosition(cell_pos); + } + + sprite.setColor(fg); + + $render->draw(background); + $render->draw(sprite); + $render->display(); + } + } + + void save_config(MapConfig& config, const std::string &path) { + (void)path; + json result = json::array(); + + for(each_row_t it{config.map}; it.next();) { + if(config.map[it.y][it.x] == 0) break; + + json val; + + val["x"] = $size.x * it.x; + val["y"] = $size.y * it.y; + val["display"] = (int)config.map[it.y][it.x]; + val["centered"] = config.centered[it.y][it.x]; + + result.push_back(val); + } + + std::ofstream o(path, std::ios::out | std::ios::binary); + o << std::setw(4) << result << std::endl; + } +}; + +void load_config(MapConfig& config, bool is_centered, std::string path, std::function finder) +{ + auto tiles = settings::get(path); + + for(auto [key, val] : tiles.json().items()) { + config.it.next(); + auto data = finder(val); + wchar_t display = data["display"]; + config.map[config.it.y][config.it.x] = display; + config.centered[config.it.y][config.it.x] = is_centered; + config.names.insert_or_assign(display, key); + + dbc::check(!config.colors.contains(display), + fmt::format("duplicate icon for display={} key={}", + (int)display, (std::string)key)); + + dbc::check(data.contains("foreground"), + fmt::format("{} has no foreground", std::string(key))); + + auto fg = palette::get(data["foreground"]); + config.colors.insert_or_assign(display, fg); + + dbc::check(data.contains("background"), + fmt::format("{} has no background", std::string(key))); + + auto bg = palette::get(data["background"]); + config.backgrounds.insert_or_assign(display, bg); + } +} + +json& component_display(json& val) { + auto& components = val["components"]; + + for(auto& comp : components) { + if(comp["_type"] == "Tile") { + return comp; + } + } + + dbc::log("BAD CHAR"); + return val; +} + +int main() { + palette::init(); + MapConfig config; + + load_config(config, false, "tiles", [](json& val) -> json& { + return val; + }); + + load_config(config, true, "items", component_display); + load_config(config, true, "devices", component_display); + load_config(config, true, "enemies", component_display); + + fmt::println("-----------------------------------------"); + MapTileBuilder builder(ICONGEN_MAP_TILE_DIM, ICONGEN_MAP_TILE_DIM); + builder.run(config); + + builder.save_image("./assets/map_tiles.png"); + builder.save_config(config, "./assets/map_tiles.json"); + return 0; +} diff --git a/wraps/catch2.wrap b/wraps/catch2.wrap new file mode 100644 index 0000000..f9bf436 --- /dev/null +++ b/wraps/catch2.wrap @@ -0,0 +1,11 @@ +[wrap-file] +directory = Catch2-3.7.1 +source_url = https://github.com/catchorg/Catch2/archive/v3.7.1.tar.gz +source_filename = Catch2-3.7.1.tar.gz +source_hash = c991b247a1a0d7bb9c39aa35faf0fe9e19764213f28ffba3109388e62ee0269c +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/catch2_3.7.1-1/Catch2-3.7.1.tar.gz +wrapdb_version = 3.7.1-1 + +[provide] +catch2 = catch2_dep +catch2-with-main = catch2_with_main_dep diff --git a/wraps/flac.wrap b/wraps/flac.wrap new file mode 100644 index 0000000..ee36479 --- /dev/null +++ b/wraps/flac.wrap @@ -0,0 +1,13 @@ +[wrap-file] +directory = flac-1.4.3 +source_url = https://github.com/xiph/flac/releases/download/1.4.3/flac-1.4.3.tar.xz +source_filename = flac-1.4.3.tar.xz +source_hash = 6c58e69cd22348f441b861092b825e591d0b822e106de6eb0ee4d05d27205b70 +patch_filename = flac_1.4.3-2_patch.zip +patch_url = https://wrapdb.mesonbuild.com/v2/flac_1.4.3-2/get_patch +patch_hash = 3eace1bd0769d3e0d4ff099960160766a5185d391c8f583293b087a1f96c2a9c +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/flac_1.4.3-2/flac-1.4.3.tar.xz +wrapdb_version = 1.4.3-2 + +[provide] +flac = flac_dep diff --git a/wraps/fmt.wrap b/wraps/fmt.wrap new file mode 100644 index 0000000..fd50847 --- /dev/null +++ b/wraps/fmt.wrap @@ -0,0 +1,13 @@ +[wrap-file] +directory = fmt-11.0.2 +source_url = https://github.com/fmtlib/fmt/archive/11.0.2.tar.gz +source_filename = fmt-11.0.2.tar.gz +source_hash = 6cb1e6d37bdcb756dbbe59be438790db409cdb4868c66e888d5df9f13f7c027f +patch_filename = fmt_11.0.2-1_patch.zip +patch_url = https://wrapdb.mesonbuild.com/v2/fmt_11.0.2-1/get_patch +patch_hash = 90c9e3b8e8f29713d40ca949f6f93ad115d78d7fb921064112bc6179e6427c5e +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/fmt_11.0.2-1/fmt-11.0.2.tar.gz +wrapdb_version = 11.0.2-1 + +[provide] +fmt = fmt_dep diff --git a/wraps/freetype2.wrap b/wraps/freetype2.wrap new file mode 100644 index 0000000..acad6f4 --- /dev/null +++ b/wraps/freetype2.wrap @@ -0,0 +1,11 @@ +[wrap-file] +directory = freetype-2.13.3 +source_url = https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/freetype2_2.13.3-1/freetype-2.13.3.tar.xz +source_filename = freetype-2.13.3.tar.xz +source_hash = 0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289 +wrapdb_version = 2.13.3-1 + +[provide] +freetype2 = freetype_dep +freetype = freetype_dep diff --git a/wraps/lel-guecs.wrap b/wraps/lel-guecs.wrap new file mode 100644 index 0000000..456ef65 --- /dev/null +++ b/wraps/lel-guecs.wrap @@ -0,0 +1,9 @@ +[wrap-git] +directory=lel-guecs-0.7.0 +url=https://git.zedshaw.games/games/lel-guecs.git +revision=HEAD +depth=1 +method=meson + +[provide] +lel_guecs = lel_guecs_dep diff --git a/wraps/libpng.wrap b/wraps/libpng.wrap new file mode 100644 index 0000000..06044a9 --- /dev/null +++ b/wraps/libpng.wrap @@ -0,0 +1,13 @@ +[wrap-file] +directory = libpng-1.6.44 +source_url = https://github.com/glennrp/libpng/archive/v1.6.44.tar.gz +source_filename = libpng-1.6.44.tar.gz +source_hash = 0ef5b633d0c65f780c4fced27ff832998e71478c13b45dfb6e94f23a82f64f7c +patch_filename = libpng_1.6.44-1_patch.zip +patch_url = https://wrapdb.mesonbuild.com/v2/libpng_1.6.44-1/get_patch +patch_hash = 394b07614c45fbd1beac8b660386216a490fe12f841a1a445799b676c9c892fb +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/libpng_1.6.44-1/libpng-1.6.44.tar.gz +wrapdb_version = 1.6.44-1 + +[provide] +libpng = libpng_dep diff --git a/wraps/nlohmann_json.wrap b/wraps/nlohmann_json.wrap new file mode 100644 index 0000000..8c46676 --- /dev/null +++ b/wraps/nlohmann_json.wrap @@ -0,0 +1,11 @@ +[wrap-file] +directory = nlohmann_json-3.11.3 +lead_directory_missing = true +source_url = https://github.com/nlohmann/json/releases/download/v3.11.3/include.zip +source_filename = nlohmann_json-3.11.3.zip +source_hash = a22461d13119ac5c78f205d3df1db13403e58ce1bb1794edc9313677313f4a9d +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/nlohmann_json_3.11.3-1/nlohmann_json-3.11.3.zip +wrapdb_version = 3.11.3-1 + +[provide] +nlohmann_json = nlohmann_json_dep diff --git a/wraps/ogg.wrap b/wraps/ogg.wrap new file mode 100644 index 0000000..e7f23eb --- /dev/null +++ b/wraps/ogg.wrap @@ -0,0 +1,13 @@ +[wrap-file] +directory = libogg-1.3.5 +source_url = https://downloads.xiph.org/releases/ogg/libogg-1.3.5.tar.xz +source_filename = libogg-1.3.5.tar.xz +source_hash = c4d91be36fc8e54deae7575241e03f4211eb102afb3fc0775fbbc1b740016705 +patch_filename = ogg_1.3.5-6_patch.zip +patch_url = https://wrapdb.mesonbuild.com/v2/ogg_1.3.5-6/get_patch +patch_hash = 8be6dcd5f93bbf9c0b9c8ec1fa29810226a60f846383074ca05b313a248e78b2 +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/ogg_1.3.5-6/libogg-1.3.5.tar.xz +wrapdb_version = 1.3.5-6 + +[provide] +ogg = libogg_dep diff --git a/wraps/sfml.wrap b/wraps/sfml.wrap new file mode 100644 index 0000000..577ed1e --- /dev/null +++ b/wraps/sfml.wrap @@ -0,0 +1,14 @@ +[wrap-git] +directory=SFML-3.0.0 +url=https://github.com/SFML/SFML.git +revision=3.0.0 +depth=1 +method=cmake + +[provide] +sfml_audio = sfml_audio_dep +sfml_graphics = sfml_graphics_dep +sfml_main = sfml_main_dep +sfml_network = sfml_network_dep +sfml_system = sfml_system_dep +sfml_window = sfml_window_dep diff --git a/wraps/vorbis.wrap b/wraps/vorbis.wrap new file mode 100644 index 0000000..7425c11 --- /dev/null +++ b/wraps/vorbis.wrap @@ -0,0 +1,14 @@ +[wrap-file] +directory = libvorbis-1.3.7 +source_url = https://downloads.xiph.org/releases/vorbis/libvorbis-1.3.7.tar.xz +source_filename = libvorbis-1.3.7.tar.xz +source_hash = b33cc4934322bcbf6efcbacf49e3ca01aadbea4114ec9589d1b1e9d20f72954b +patch_filename = vorbis_1.3.7-4_patch.zip +patch_url = https://wrapdb.mesonbuild.com/v2/vorbis_1.3.7-4/get_patch +patch_hash = 979e22b24b16c927040700dfd8319cd6ba29bf52a14dbc66b1cb4ea60504f14a +wrapdb_version = 1.3.7-4 + +[provide] +vorbis = vorbis_dep +vorbisfile = vorbisfile_dep +vorbisenc = vorbisenc_dep