diff --git a/.gitignore b/.gitignore index 56e48cf..43be4f3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,14 @@ dist/ dist-newstyle/ out/ +tmp/ +extra/ .idea/ -.vscode/ .DS_Store *.exe *.dll + +stack.yaml.lock +.vscode/settings.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..3cf0327 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,74 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run (+ build)", + "type": "shell", + "command": "stack run", + "problemMatcher": [], + "dependsOn": [ "Build" ], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "Build", + "type": "shell", + "command": "stack build", + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Test", + "type": "shell", + "command": "stack test", + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "GHCI", + "type": "shell", + "command": "stack ghci ${input:file_to_load}", + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "Create verslag.pdf", + "type": "shell", + "command": "pandoc", + "args": [ + "-s", + "-o", "verslag.pdf", + "-f", "markdown+smart+header_attributes+yaml_metadata_block+auto_identifiers+implicit_figures", + "--pdf-engine", "lualatex", + "--template", "eisvogel", + "--dpi=300", + "header.yaml", + "README.md" + ], + "problemMatcher": [], + "group": { + "kind": "none", + "isDefault": false + } + } + ], + "inputs": [ + { + "id": "file_to_load", + "description": "CLI arguments specifying file to load into Stack GHCI", + "default": ".", + "type": "promptString" + }, + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 5f92037..39a363e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,377 @@ + + # RPG-Engine -Schrijf een game-engine voor een rollenspel \ No newline at end of file +RPG-Engine is a game engine for playing and creating your own RPG games. + +If you are interested in the development side of things, [development notes can be found here](#Development-notes). + +This README serves as both documentation and project report, so excuse the details that might not be important for the average reader. + +## Playing the game + +These are the keybinds while *in* game. All other keybinds in menus etc. should be straightforward. + +| Action | Primary | Secondary | +| -------------- | ------------- | ------------ | +| Move up | `Arrow Up` | `w` | +| Move left | `Arrow Left` | `a` | +| Move down | `Arrow Down` | `s` | +| Move right | `Arrow Right` | `d` | +| Interaction | `Space` | `f`, `Enter` | +| Show inventory | `i` | `Tab` | +| Restart level | `r` | | +| Quit game | `Esc` | | + +### Example playthrough + +![Select level 5](./extra/walkthrough/selection.png){ width=600 } +![Move to the first exit](./extra/walkthrough/5-1-01.png){ width=600 } +![Move to the first key](./extra/walkthrough/5-2-02.png){ width=600 } +![Pick up key](./extra/walkthrough/5-2-03.png){ width=600 } +![Move to door](./extra/walkthrough/5-2-04.png){ width=600 } +![Open door with key](./extra/walkthrough/5-2-05.png){ width=600 } +![Move to exit](./extra/walkthrough/5-2-06.png){ width=600 } +![Move to devil](./extra/walkthrough/5-3-01.png){ width=600 } +![Try to attack with dagger](./extra/walkthrough/5-3-02.png){ width=600 } +![Go pick up sword](./extra/walkthrough/5-3-03.png){ width=600 } +![Attack devil using sword](./extra/walkthrough/5-3-05.png){ width=600 } +![Pick up key](./extra/walkthrough/5-3-06.png){ width=600 } +![Open door](./extra/walkthrough/5-3-08.png){ width=600 } +![Move to exit](./extra/walkthrough/5-3-09.png){ width=600 } +![You win](./extra/walkthrough/you-win.png){ width=600 } + +## Development notes + +### Engine architecture + +`RPGEngine` is the main module. It contains the `playRPGEngine` function which bootstraps the whole game. It is also + the game loop. From here, `RPGEngine` talks to its submodules. + +These submodules are `Config`, `Data`, `Input`, `Parse` & `Render`. They are all responsible for their own part. However, + each of these submodules has their own submodules to divide the work. They are conveniently named after the state of + the game that they work with, e.g. the main menu has a module & when the game is playing is a different module. A special + one is `Core`, which is kind of like a library for every piece. It contains functions that are regularly used by the other modules. + +- `Config`: Configuration values, should ultimately be moved into parsing from a file. +- `Data`: Data containers and accessors of information. +- `Input`: Anything that handles input or changes the state of the game. +- `Parse`: Parsing +- `Render`: Rendering of the game and everything below that. + +#### Monads/Monad stack + +Monads: + +- Extensive use of `Maybe` for integers or infinity and `do` in parser implementation. +- `IO` to handle external information +- ... + +Monad transformer: ?? + +I am afraid I did not write any monad transformers in this project. I think I could (and should) have focused more on + writing monads and monad transformers. In hindsight, I can see where I could and should have used them. I can think of + plenty of ways to make the current implementation simpler. This is unfortunate. However, I want to believe that my next + time writing a more complex Haskell program, I will remember using monad transformers. Sadly, I forgot this time. + +An example of where I would use a monad transformer - in hindsight: + +1. Interactions in game: something along the lines of ... + +```haskell +newtype StateT m a = StateT { runStateT :: m a } + +instance Monad m => Monad (StateT m) where + return = lift . return + x >>= f = StateT $ do + v <- runStateT x + case v of + Playing level -> runStateT ( f level ) + Paused continue -> runStateT ( continue >>= f ) + -- etc + +class MonadTransformer r where + lift :: Monad m => m a -> (r m) a +instance MonadTransformer StateT where + lift = StateT +``` + +2. Interaction with the outside world should also be done with Monad(transformers) instead of using `unsafePerformIO`. + +### Tests + +Overall, only parsing is tested using Hspec. However, parsing is tested *thoroughly* and I am quite sure that there aren't + a lot of edge cases that I did not catch. This makes for a relaxing environment where you can quickly check if a change + you made breaks anything. + +`Spec` is the main module. It does not contain any tests, but functions as the 'discover' module to find the other tests + in its folder. + +`Parser.StructureSpec` tests functionality of `RPGEngine.Parse.TextToStructure`, `Parser.GameSpec` tests functionality + of `RPGEngine.Parse.StructureToGame`. + +Known issues: + +- [ ] Rendering is still not centered, I am sorry for those with small screens. +- [ ] Config files cannot end with an empty line. I could not get that to work and I decided that it was more important + to implement other functionality first. Unfortunately, I was not able to get back to it yet. +- [ ] The parser is unable to parse layouts with trailing whitespace. + +## Conclusion + +Parsing was way harder than I initially expected. I believe over half my time on this project was spent trying to write the + parser. I am still not absolutely sure that it will work with *everything*, but it gets the job done at the moment. I don't + know if parsing into a structure before transforming the structure into a game was a good move. It might have saved me some + time if I did it straight to `Game`. I want to say that I have a parser-to-structure module now, but even so, there are some + links between `TextToStructure` and `Game` that make it almost useless to any other project (without changing anything). + +Player-object interaction was easier than previous projects. I believe this is both because I am getting used to it by now + and because I spent a lot of time beforehand structuring everything. I also like to think that structuring the project is + what I did right. There is a clear hierarchy and you can find what you are looking for fairly easy, without having to search + for a function in file contents or having to scavenge multiple different files before finding what you want. However, I + absolutely wasted a lot of time restructuring the project multiple times, mostly because I was running into dependency cycles. + +Overall, I believe the project was a success. I am proud of the end result. Though, please note my comments on monad transformers. + +### Assets & dependencies + +The following assets were used (and modified if specified): + +- Kyrise's Free 16x16 RPG Icon Pack[[1]](#1) + +- 2D Pixel Dungeon Asset Pack by Pixel_Poem[[2]](#2) + + Every needed asset was taken and put into its own `.png`, instead of in the overview. + +RPG-Engine makes use of the following libraries: + +- [directory](https://hackage.haskell.org/package/directory) for listing levels in a directory +- [gloss](https://hackage.haskell.org/package/gloss) for game rendering +- [gloss-juicy](https://hackage.haskell.org/package/gloss-juicy) for rendering images +- [hspec](https://hackage.haskell.org/package/hspec) for testing +- [hspec-discover](https://hackage.haskell.org/package/hspec-discover) for allowing to split test files in multiple files +- [parsec](https://hackage.haskell.org/package/parsec) for parsing configuration files + +
\pagebreak
+ +## References + +[1] [Kyrise's Free 16x16 RPG Icon Pack](https://kyrise.itch.io/kyrises-free-16x16-rpg-icon-pack) © 2018 + by [Kyrise](https://kyrise.itch.io/) is licensed under [CC BY 4.0](http://creativecommons.org/licenses/by/4.0/?ref=chooser-v1) + +[2] [2D Pixel Dungeon Asset Pack](https://pixel-poem.itch.io/dungeon-assetpuck) by [Pixel_Poem](https://pixel-poem.itch.io/) + is not licensed + +
\pagebreak
+ +## Appendix A: Future development ideas + +The following ideas could (or should) be implemented in the future of this project. + +- [ ] **Entity system:** With en ES, you can implement moving entities and repeated input. It also resembles the typical + game loop more closely which can make it easier to implement other ideas in the future. +- [ ] **Game music:** Ambient game music and sound effects can improve the gaming experience I think. +- [ ] **Expand configuration file:** Implement the same methods for parsing stage description files to a configuration file, + containing keybinds, dimension sizes, even window titles, making this a truly customizable engine. +- [ ] **Camera follows player:** The camera should follow the player, making it always center. This allows for larger levels + increases the immersion of the game. + +Changes in the backend: + +- [ ] **Make inventory a state** At the moment, there is a boolean for inventory rendering. This should be turned into a state, + so it makes more sense to call it from other places as well. +- [ ] **Direction of entities** Change the rendering based on the direction of an entity. +- [ ] **Inventory with more details** The inventory should show more details of items, e.g. name, value, remaining use + times and description. + +
\pagebreak
+ +## Appendix B: Writing your own worlds + +A world description file, conventionally named `.txt` is a file with a JSON-like format. It is used to describe + everything inside a single world of your game, including anything related to the player, the levels your game contains + and what happens in that level. It is essentially the raw representation of the initial state of a single game. + +A world description file consists of several elements. + +| Element | Short description | +| --------------- | --------------------------------------------------------------------------------------------------------- | +| `Block` | optionally surrounded by `{ ... }`, consists of several `Entry`'s, optionally separated by commas `,` | +| `Entry` | is a `Key` - `Value` pair, optionally separated by a colon `:` | +| `Key` | is a unique, predefined `String` describing `Value` | +| `Value` | is either a `Block` or a `BlockList` or a traditional value, such as `String` or `Int` | +| `BlockList` | is a number of `Block`'s, surrounded by `[ ... ]`, separated by commas, can be empty | + +
+We'll look at the following example to explain these concepts. + +```javascript +player: { + hp: 50, + inventory: [ + { + id: "dagger", + x: 0, + y: 0, + name: "Dagger", + description: "Basic dagger you found somewhere", + useTimes: infinite, + value: 10, + + actions: {} + } + ] +} + +levels: [ + { + layout: { + | * * * * * * + | * s . . e * + | * * * * * * + }, + items: [], + entities: [] + }, + { + layout: { + | * * * * * * * * + | * s . . . . e * + | * * * * * * * * + }, + items: [ + { + id: "key", + x: 3, + y: 1, + name: "Door key", + description: "Unlocks a secret door", + useTimes: 1, + value: 0, + actions: { + [not(inventoryFull())] retrieveItem(key), + [] leave() + } + } + ], + entities: [ + { + id: "door", + x: 4, + y: 1, + name: "Secret door", + description: "This secret door can only be opened with a key", + direction: left, + actions: { + [inventoryContains(key)] useItem(key), + [] leave() + } + } + ] + } +] +``` +
+ +This world description file consists of a single `Block`. A world description file always does. This top level `Block` + contains two `Value`s `player` and `levels`, not separated by commas. + +`player` describes a `Block` that represents the player of the game. Its `Entry`s are `hp` (a traditional value) and + `inventory` (a `BlockList` of several other `Block`s). They are both separated by commas this time. It is possible for + the inventory to be an empty list `[]`. + +`levels` is a `BlockList` that contains all the information to construct your game. + +### `layout` syntax + +If `Key` has the value `layout`, `Value` is none of the types discussed so far. Instead `Layout` is specifically made + to describe the layout of a level. This object is surrounded by `{ ... }` and consists of multiple lines, starting with + a vertical line `|` and several characters of the following: + +- `x` is an empty tile a.k.a. void. +- `.` is a tile walkable by the player. +- `*` is a tile not walkable by the player. +- `s` is the starting position of the player. +- `e` is the exit. + +All characters are interspersed with spaces. + +### `actions` syntax + +If `Key` has the value `actions`, the following changes are important for its `Value`, which in this case is a `Block` + with zero or more `Entry`s like so: + +- `Key` has type `ConditionList`. + + A `ConditionList` consists of several `Condition`s, surrounded by `[ ... ]`, separated by commas. A `ConditionList` + can be empty. If so, the conditional is always fulfilled. + + A `Condition` is one of the following: + + - `inventoryFull()`: the players inventory is full. + - `inventoryContains(objectId)`: the players inventory contains an object with id `objectId`. + - `not(condition)`: logical negation of `condition`. + +- `Value` is an `Action`. + + An `Action` is one of the following: + + - `leave()` + - `retrieveItem(objectId)` + - `useItem(objectId)` + - `decreaseHp(entityId, objectId)` + - `increasePlayerHp(objectId)` + +### Back to the example + +If we look at the example, all the objects are + +``` +>Block< + Entry = Key ('player') + >Block< + Entry = Key ('hp') + Value (50) + Entry = Key ('inventory') + >BlockList< + length = 1 + Block + Entry = Key ('id') + Value ("dagger") + ... + Entry = Key ('actions') + empty Block + Entry = Key ('levels') + >BlockList< + length = 2 + >Block< + Entry = Key ('layout') + Layout + + Entry = Key ('items') + empty BlockList + Entry = Key ('entities') + empty BlockList + >Block< + Entry = Key ('layout') + Layout + + Entry = Key ('items') + >BlockList< + length = 1 + >Block< + Entry = Key ('id') + Value ("key") + ... + Entry = Key ('actions') + >Block< + Entry = >ConditionList< + Action ('retrieveItem(key)') + length = 1 + Condition ('not(inventoryFull())')) + Entry = empty ConditionList + Action ('leave()') + Entry = Key ('entities') + >BlockList< + length = 1 + >Block< + Entry = Key ('id') + Value ("door") + ... + Entry = Key ('actions') + >Block< + Entry = >ConditionList< + Action ('useItem(key)') + length = 1 + Condition ('inventoryContains(key)') + Entry = empty ConditionList + Action ('leave()') +``` \ No newline at end of file diff --git a/assets/entities/devil.png b/assets/entities/devil.png new file mode 100644 index 0000000..1ab3ef5 Binary files /dev/null and b/assets/entities/devil.png differ diff --git a/assets/entities/door.png b/assets/entities/door.png new file mode 100644 index 0000000..f876321 Binary files /dev/null and b/assets/entities/door.png differ diff --git a/assets/entities/player.png b/assets/entities/player.png new file mode 100644 index 0000000..512b550 Binary files /dev/null and b/assets/entities/player.png differ diff --git a/assets/environment/entrance.png b/assets/environment/entrance.png new file mode 100644 index 0000000..edb422f Binary files /dev/null and b/assets/environment/entrance.png differ diff --git a/assets/environment/exit.png b/assets/environment/exit.png new file mode 100644 index 0000000..2d2a66e Binary files /dev/null and b/assets/environment/exit.png differ diff --git a/assets/environment/overlay.png b/assets/environment/overlay.png new file mode 100644 index 0000000..b5d500d Binary files /dev/null and b/assets/environment/overlay.png differ diff --git a/assets/environment/tile.png b/assets/environment/tile.png new file mode 100644 index 0000000..91f4b5d Binary files /dev/null and b/assets/environment/tile.png differ diff --git a/assets/environment/void.png b/assets/environment/void.png new file mode 100644 index 0000000..9eb2e3f Binary files /dev/null and b/assets/environment/void.png differ diff --git a/assets/environment/wall.png b/assets/environment/wall.png new file mode 100644 index 0000000..cfe91c2 Binary files /dev/null and b/assets/environment/wall.png differ diff --git a/assets/gui/health.png b/assets/gui/health.png new file mode 100644 index 0000000..a2adeda Binary files /dev/null and b/assets/gui/health.png differ diff --git a/assets/gui/main.png b/assets/gui/main.png new file mode 100644 index 0000000..1d2ae6e Binary files /dev/null and b/assets/gui/main.png differ diff --git a/assets/items/dagger.png b/assets/items/dagger.png new file mode 100644 index 0000000..d1da3c3 Binary files /dev/null and b/assets/items/dagger.png differ diff --git a/assets/items/key.png b/assets/items/key.png new file mode 100644 index 0000000..6674296 Binary files /dev/null and b/assets/items/key.png differ diff --git a/assets/items/potion.png b/assets/items/potion.png new file mode 100644 index 0000000..f2bf7bb Binary files /dev/null and b/assets/items/potion.png differ diff --git a/assets/items/sword.png b/assets/items/sword.png new file mode 100644 index 0000000..ba64389 Binary files /dev/null and b/assets/items/sword.png differ diff --git a/assets/unknown.png b/assets/unknown.png new file mode 100644 index 0000000..005de40 Binary files /dev/null and b/assets/unknown.png differ diff --git a/extra/walkthrough/5-1-01.png b/extra/walkthrough/5-1-01.png new file mode 100644 index 0000000..2b40621 Binary files /dev/null and b/extra/walkthrough/5-1-01.png differ diff --git a/extra/walkthrough/5-2-01.png b/extra/walkthrough/5-2-01.png new file mode 100644 index 0000000..2356231 Binary files /dev/null and b/extra/walkthrough/5-2-01.png differ diff --git a/extra/walkthrough/5-2-02.png b/extra/walkthrough/5-2-02.png new file mode 100644 index 0000000..001f1f2 Binary files /dev/null and b/extra/walkthrough/5-2-02.png differ diff --git a/extra/walkthrough/5-2-03.png b/extra/walkthrough/5-2-03.png new file mode 100644 index 0000000..63bbcf2 Binary files /dev/null and b/extra/walkthrough/5-2-03.png differ diff --git a/extra/walkthrough/5-2-04.png b/extra/walkthrough/5-2-04.png new file mode 100644 index 0000000..10dc2fc Binary files /dev/null and b/extra/walkthrough/5-2-04.png differ diff --git a/extra/walkthrough/5-2-05.png b/extra/walkthrough/5-2-05.png new file mode 100644 index 0000000..15be37f Binary files /dev/null and b/extra/walkthrough/5-2-05.png differ diff --git a/extra/walkthrough/5-2-06.png b/extra/walkthrough/5-2-06.png new file mode 100644 index 0000000..b1cfadc Binary files /dev/null and b/extra/walkthrough/5-2-06.png differ diff --git a/extra/walkthrough/5-3-01.png b/extra/walkthrough/5-3-01.png new file mode 100644 index 0000000..5b44ec9 Binary files /dev/null and b/extra/walkthrough/5-3-01.png differ diff --git a/extra/walkthrough/5-3-02.png b/extra/walkthrough/5-3-02.png new file mode 100644 index 0000000..e590fd1 Binary files /dev/null and b/extra/walkthrough/5-3-02.png differ diff --git a/extra/walkthrough/5-3-03.png b/extra/walkthrough/5-3-03.png new file mode 100644 index 0000000..c84d804 Binary files /dev/null and b/extra/walkthrough/5-3-03.png differ diff --git a/extra/walkthrough/5-3-04.png b/extra/walkthrough/5-3-04.png new file mode 100644 index 0000000..9562083 Binary files /dev/null and b/extra/walkthrough/5-3-04.png differ diff --git a/extra/walkthrough/5-3-05.png b/extra/walkthrough/5-3-05.png new file mode 100644 index 0000000..d53340b Binary files /dev/null and b/extra/walkthrough/5-3-05.png differ diff --git a/extra/walkthrough/5-3-06.png b/extra/walkthrough/5-3-06.png new file mode 100644 index 0000000..3c9e12f Binary files /dev/null and b/extra/walkthrough/5-3-06.png differ diff --git a/extra/walkthrough/5-3-07.png b/extra/walkthrough/5-3-07.png new file mode 100644 index 0000000..ab6235c Binary files /dev/null and b/extra/walkthrough/5-3-07.png differ diff --git a/extra/walkthrough/5-3-08.png b/extra/walkthrough/5-3-08.png new file mode 100644 index 0000000..fd22e1a Binary files /dev/null and b/extra/walkthrough/5-3-08.png differ diff --git a/extra/walkthrough/5-3-09.png b/extra/walkthrough/5-3-09.png new file mode 100644 index 0000000..4e4f729 Binary files /dev/null and b/extra/walkthrough/5-3-09.png differ diff --git a/extra/walkthrough/selection.png b/extra/walkthrough/selection.png new file mode 100644 index 0000000..5c4e9cc Binary files /dev/null and b/extra/walkthrough/selection.png differ diff --git a/extra/walkthrough/you-win.png b/extra/walkthrough/you-win.png new file mode 100644 index 0000000..eb4cc85 Binary files /dev/null and b/extra/walkthrough/you-win.png differ diff --git a/header.yaml b/header.yaml new file mode 100644 index 0000000..21a20a4 --- /dev/null +++ b/header.yaml @@ -0,0 +1,15 @@ +--- +title: "RPG Engine" +author: "Tibo De Peuter" +date: "23 december 2022" +subtitle: "Write a game engine for an RPG game" +# geometry: "left=2.54cm,right=2.54cm,top=1.91cm,bottom=1.91cm" +geometry: "left=2.54cm,right=2.54cm,top=2.54cm,bottom=2.54cm" +titlepage: true +titlepage-rule-height: 4 +toc: true +listings-disable-line-numbers: true +listings-no-page-break: false +subparagraph: true +lang: en-GB +--- diff --git a/levels/level1.txt b/levels/level1.txt index 42ba56a..02bc322 100644 --- a/levels/level1.txt +++ b/levels/level1.txt @@ -15,4 +15,4 @@ levels: [ entities: [] } -] +] \ No newline at end of file diff --git a/levels/level2.txt b/levels/level2.txt index bb589d6..641cc56 100644 --- a/levels/level2.txt +++ b/levels/level2.txt @@ -48,4 +48,4 @@ levels: [ } ] } -] +] \ No newline at end of file diff --git a/levels/level3.txt b/levels/level3.txt index f7e1e5d..7a48a9c 100644 --- a/levels/level3.txt +++ b/levels/level3.txt @@ -29,8 +29,8 @@ levels: [ items: [ { id: "sword", - x: 2, - y: 3, + x: 3, + y: 4, name: "Zwaard", description: "Meer schade tegen monsters", useTimes: infinite, @@ -43,8 +43,8 @@ levels: [ }, { id: "potion", - x: 3, - y: 1, + x: 4, + y: 2, name: "Levensbrouwsel", description: "Geeft een aantal levenspunten terug", useTimes: 1, @@ -60,8 +60,8 @@ levels: [ entities: [ { id: "devil", - x: 4, - y: 3, + x: 5, + y: 4, name: "Duivel", description: "Een monster uit de hel", hp: 50, @@ -69,11 +69,11 @@ levels: [ actions: { [inventoryContains(potion)] increasePlayerHp(potion), - [inventoryContains(sword)] decreaseHp(m1, sword), - [] decreaseHp(m1, dagger), + [inventoryContains(sword)] decreaseHp(devil, sword), + [] decreaseHp(devil, dagger), [] leave() } } ] } -] +] \ No newline at end of file diff --git a/levels/level4.txt b/levels/level4.txt new file mode 100644 index 0000000..1417276 --- /dev/null +++ b/levels/level4.txt @@ -0,0 +1,134 @@ +player: { + hp: 50, + inventory: [ + { + id: "dagger", + x: 0, + y: 0, + name: "Dolk", + description: "Basis schade tegen monsters", + useTimes: infinite, + value: 10, + + actions: {} + } + ] +} + +levels: [ + { + layout: { + | * * * * * * + | * s . . e * + | * * * * * * + }, + + items: [], + + entities: [] + }, + { + layout: { + | * * * + | * e * + | * . * + | * . * + | * . * + | * . * + | * s * + | * * * + }, + + items: [ + { + id: "key", + x: 1, + y: 2, + name: "Sleutel", + description: "Deze sleutel kan een deur openen", + useTimes: 1, + value: 0, + actions: { + [not(inventoryFull())] retrieveItem(key), + [] leave() + } + } + ], + + entities: [ + { + id: "door", + x: 1, + y: 4, + name: "Deur", + description: "Deze deur kan geopend worden met een sleutel", + direction: up, + + actions: { + [inventoryContains(key)] useItem(key), + [] leave() + } + } + ] + }, + { + layout: { + | * * * * * * * * + | * . . . . . . * + | * s . . . . . * + | * . . . . . e * + | * . . . . . . * + | * * * * * * * * + }, + + items: [ + { + id: "sword", + x: 3, + y: 4, + name: "Zwaard", + description: "Meer schade tegen monsters", + useTimes: infinite, + value: 25, + + actions: { + [not(inventoryFull())] retrieveItem(sword), + [] leave() + } + }, + { + id: "potion", + x: 4, + y: 2, + name: "Levensbrouwsel", + description: "Geeft een aantal levenspunten terug", + useTimes: 1, + value: 50, + + actions: { + [not(inventoryFull())] retrieveItem(potion), + [] leave() + } + } + ], + + entities: [ + { + id: "devil", + x: 5, + y: 4, + name: "Duivel", + description: "Een monster uit de hel", + hp: 50, + value: 5, + + actions: { + [inventoryContains(potion)] increasePlayerHp(potion), + [inventoryContains(sword)] decreaseHp(devil, sword), + [] decreaseHp(devil, dagger), + [] leave() + } + } + ] + } +] \ No newline at end of file diff --git a/levels/level5.txt b/levels/level5.txt new file mode 100644 index 0000000..0263dac --- /dev/null +++ b/levels/level5.txt @@ -0,0 +1,144 @@ +player: { + hp: 100, + inventory: [ + { + id: "dagger", + x: 0, + y: 0, + name: "Swiss army knife", + description: "Your trustworthy army knife will never let you down", + useTimes: infinite, + value: 5, + actions: {} + }, + { + id: "potion", + x: 0, + y: 0, + name: "Small healing potion", + description: "Will recover you from small injuries", + useTimes: 5, + value: 5, + actions: {} + } + ] +} + +levels: [ + { + layout: { + | * * * * * * * + | * s . . . e * + | * * * * * * * + }, + items: [], + entities: [] + }, + { + layout: { + | x x * * * x x x x + | x x * . * x x x x + | * * * . * * * * * + | * s . . . . . e * + | * * * * * * * * * + }, + items: [ + { + id: "key", + x: 3, + y: 3, + name: "Secret key", + description: "What if this key opens a secret door?", + useTimes: 1, + value: 0, + actions: { + [not(inventoryFull())] retrieveItem(key), + [] leave() + } + } + ], + entities: [ + { + id: "door", + x: 4, + y: 1, + name: "Secret door", + description: "This door can only be opened with a secret key", + direction: left, + actions: { + [inventoryContains(key)] useItem(key), + [] leave() + } + } + ] + }, + { + layout: { + | * * * * * * * * * * * + | * . . . . . . . . . * + | * * * * * * * * * . * + | * e . . . . . . . s * + | * * * * * * * * * . * + | x x x x x x x x * . * + | * * * * * * * * * . * + | * . . . . . . . . . * + | * * * * * * * * * * * + }, + items: [ + { + id: "key", + x: 1, + y: 1, + name: "Key to sturdy door", + description: "You have proven worthy", + useTimes: 1, + value: 0, + actions: { + [not(inventoryFull())] retrieveItem(key), + [] leave() + } + }, + { + id: "sword", + x: 1, + y: 7, + name: "Mighty sword", + description: "Slayer of evil", + useTimes: 3, + value: 100, + actions: { + [not(inventoryFull())] retrieveItem(sword), + [] leave() + } + } + ], + entities: [ + { + id: "door", + x: 8, + y: 5, + name: "Sturdy door", + description: "I wonder what's behind it?", + direction: right, + actions: { + [inventoryContains(key)] useItem(key), + [] leave() + } + }, + { + id: "devil", + x: 6, + y: 1, + name: "Evil powers", + description: "Certainly from hell", + hp: 55, + value: 10, + actions: { + [inventoryContains(dagger)] decreaseHp(devil, dagger), + [inventoryContains(sword)] decreaseHp(devil, sword), + [] leave() + } + } + ] + } +] \ No newline at end of file diff --git a/lib/RPGEngine.hs b/lib/RPGEngine.hs new file mode 100644 index 0000000..e7cbf72 --- /dev/null +++ b/lib/RPGEngine.hs @@ -0,0 +1,93 @@ +-- Allows to play a game using RPGEngine. +-- Includes all logic and rendering. + +module RPGEngine +( playRPGEngine +) where + +import RPGEngine.Config ( bgColor, winDimensions, winOffsets ) +import RPGEngine.Render ( initWindow, render ) +import RPGEngine.Input ( handleAllInput ) +import RPGEngine.Input.Playing ( checkPlaying, spawnPlayer ) +import RPGEngine.Data (Game (..), State (..), Layout, Level (..), Physical (..), Entity(..), Direction(..), Player(..)) +import RPGEngine.Data.Default (defaultLevel, defaultPlayer) + +import Graphics.Gloss ( play ) + +---------------------------------------------------------------------- + +-- This is the game loop. +-- It can receive input and update itself. It is rendered by a renderer. +playRPGEngine :: String -> Int -> IO() +playRPGEngine title fps = do + play window bgColor fps initGame render handleAllInput step + where window = initWindow title winDimensions winOffsets + step _ = checkPlaying -- TODO Do something with step? Check health etc. + +-- TODO revert this +-- Initialize the game +initGame :: Game +initGame = Game { state = Menu } +-- initGame = Game{ state = initState } +-- where initState = Playing{ +-- levels = [defaultLevel, otherLevel], +-- count = 0, +-- level = defaultLevel, +-- player = spawnPlayer defaultLevel defaultPlayer, +-- restart = initState +-- } + +-- TODO remove this +otherLayout :: Layout +otherLayout = [ + [Blocked, Blocked, Blocked], + [Blocked, Entrance, Blocked], + [Blocked, Walkable, Blocked], + [Blocked, Walkable, Blocked], + [Blocked, Walkable, Blocked], + [Blocked, Exit, Blocked], + [Blocked, Blocked, Blocked] + ] + +-- TODO remove this +otherLevel :: Level +otherLevel = Level { + layout = otherLayout, + index = [ + (0, 0, Blocked), + (1, 0, Blocked), + (2, 0, Blocked), + (0, 1, Blocked), + (1, 1, Entrance), + (2, 1, Blocked), + (0, 2, Blocked), + (1, 2, Walkable), + (2, 2, Blocked), + (0, 3, Blocked), + (1, 3, Walkable), + (2, 3, Blocked), + (0, 4, Blocked), + (1, 4, Walkable), + (2, 4, Blocked), + (0, 5, Blocked), + (1, 5, Exit), + (2, 5, Blocked), + (0, 6, Blocked), + (1, 6, Blocked), + (2, 6, Blocked) + ], + items = [], + entities = [ + Entity{ + entityId = "door", + entityX = 1, + entityY = 3, + entityName = "Epic door", + entityDescription = "epic description", + entityActions = [], + entityValue = Nothing, + entityHp = Nothing, + direction = North + } + ] +} \ No newline at end of file diff --git a/lib/RPGEngine/Config.hs b/lib/RPGEngine/Config.hs new file mode 100644 index 0000000..49d0cc7 --- /dev/null +++ b/lib/RPGEngine/Config.hs @@ -0,0 +1,54 @@ +-- This module should ultimately be replaced by a config file parser +module RPGEngine.Config +-- All entries are exported +where + +import Graphics.Gloss + +----------------------- Window configuration ------------------------- + +-- Dimensions for main window +winDimensions :: (Int, Int) +winDimensions = (1280, 720) + +-- Offsets for main window +winOffsets :: (Int, Int) +winOffsets = (0, 0) + +-- Game background color +bgColor :: Color +bgColor = makeColor (37 / 256) (19 / 256) (26 / 256) 1 + +-- Text color +textColor :: Color +textColor = white + +-- Color of selection +selectionColor :: Color +selectionColor = red + +-- Default scale +zoom :: Float +zoom = 5 + +-- UI scale, number between 0 (small) and 1 (big) +uizoom :: Float +uizoom = 0.5 + +-- Resolution of the texture +resolution :: Float +resolution = 16 + +-- Location of the assets folder containing all images +assetsFolder :: FilePath +assetsFolder = "assets/" + +-- Location of the level folder containing all levels +levelFolder :: FilePath +levelFolder = "levels/" + +------------------------- Game configuration ------------------------- + +-- How many items can a player keep in their inventory? +inventorySize :: Int +inventorySize = 5 \ No newline at end of file diff --git a/lib/RPGEngine/Data.hs b/lib/RPGEngine/Data.hs new file mode 100644 index 0000000..46c65d2 --- /dev/null +++ b/lib/RPGEngine/Data.hs @@ -0,0 +1,134 @@ +-- Contains all the data containers of the game. +-- Submodules contain accessors for these data containers. +module RPGEngine.Data +-- All data types are exported +where + +import RPGEngine.Input.Core +import RPGEngine.Render.Core ( Renderer ) + +-------------------------------- Game -------------------------------- + +-- A game is the base data container. +data Game = Game { + state :: State +} deriving (Eq, Show) + +------------------------------- State -------------------------------- + + -- Main menu +data State = Menu + -- Select the level you want to play + | LevelSelection { levelList :: [FilePath], + selector :: ListSelector } + -- Playing a level + | Playing { levels :: [Level], + count :: Int, + level :: Level, + player :: Player, + restart :: State } + -- Selecting an action + | ActionSelection { actionList :: [Action], + selector :: ListSelector, + -- The player of this state will be used to interact + continue :: State } + -- Paused while playing a level + | Paused { continue :: State } + -- Won a level + | Win + -- Lost a level + | Lose { restart :: State } + | Error Message + deriving (Eq, Show) + +type Message = String + +------------------------------- Level -------------------------------- + +data Level = Level { + layout :: Layout, + -- All Physical pieces but with their coordinates + index :: [(X, Y, Physical)], + items :: [Item], + entities :: [Entity] +} deriving (Eq, Show) + +type X = Int +type Y = Int + +type Layout = [Strip] +type Strip = [Physical] + +-- A Physical part of the world. A single tile of the world. A block +-- with stuff on it. +data Physical = Void + | Walkable + | Blocked + | Entrance + | Exit + deriving (Eq, Show) + +-------------------------------- Item -------------------------------- + +data Item = Item { + itemId :: ItemId, + itemX :: X, + itemY :: Y, + itemName :: String, + itemDescription :: String, + itemActions :: [([Condition], Action)], + itemValue :: Maybe Int, + useTimes :: Maybe Int +} deriving (Eq, Show) + +type ItemId = String + +------------------------------- Entity ------------------------------- + +data Entity = Entity { + entityId :: EntityId, + entityX :: X, + entityY :: Y, + entityName :: String, + entityDescription :: String, + entityActions :: [([Condition], Action)], + entityValue :: Maybe Int, + entityHp :: HP, + direction :: Direction +} deriving (Eq, Show) + +type EntityId = String +type HP = Maybe Int + +data Direction = North + | East + | South + | West + | Stay -- No direction + deriving (Eq, Show) + +data Player = Player { + playerHp :: HP, + inventory :: [Item], + position :: (X, Y), + showHp :: Bool, + showInventory :: Bool +} deriving (Eq, Show) + +------------------------------ Condition ----------------------------- + +data Condition = InventoryFull + | InventoryContains ItemId + | Not Condition + | AlwaysFalse + deriving (Eq, Show) + +------------------------------- Action ------------------------------- + +data Action = Leave + | RetrieveItem ItemId + | UseItem ItemId + | DecreaseHp EntityId ItemId + | IncreasePlayerHp ItemId + | DoNothing + deriving (Eq, Show) \ No newline at end of file diff --git a/lib/RPGEngine/Data/Default.hs b/lib/RPGEngine/Data/Default.hs new file mode 100644 index 0000000..c2e2814 --- /dev/null +++ b/lib/RPGEngine/Data/Default.hs @@ -0,0 +1,97 @@ +module RPGEngine.Data.Default +-- Everything is exported +where +import RPGEngine.Data (Entity (..), Game (..), Item (..), Layout, Player (..), Level (..), State (..), Physical (..), Direction (..)) +import RPGEngine.Input.Core (ListSelector(..)) + +------------------------------ Defaults ------------------------------ + +defaultEntity :: Entity +defaultEntity = Entity { + entityId = "", + entityX = 0, + entityY = 0, + entityName = "Default", + entityDescription = "", + entityActions = [], + entityValue = Prelude.Nothing, + entityHp = Prelude.Nothing, + direction = Stay +} + +defaultItem :: Item +defaultItem = Item { + itemId = "", + itemX = 0, + itemY = 0, + itemName = "Default", + itemDescription = "", + itemActions = [], + itemValue = Prelude.Nothing, + useTimes = Prelude.Nothing +} + +defaultLayout :: Layout +defaultLayout = [ + [Blocked, Blocked, Blocked, Blocked, Blocked], + [Blocked, Entrance, Walkable, Exit, Blocked], + [Blocked, Blocked, Blocked, Blocked, Blocked] + ] + +defaultLevel :: Level +defaultLevel = Level { + layout = defaultLayout, + index = [ + (0, 0, Blocked), + (0, 1, Blocked), + (0, 2, Blocked), + (1, 0, Blocked), + (1, 1, Entrance), + (1, 2, Blocked), + (2, 0, Blocked), + (2, 1, Walkable), + (2, 2, Blocked), + (3, 0, Blocked), + (3, 1, Exit), + (3, 2, Blocked), + (4, 0, Blocked), + (4, 1, Blocked), + (4, 2, Blocked) + ], + items = [], + entities = [] +} + +defaultPlayer :: Player +defaultPlayer = Player { + -- playerHp = Prelude.Nothing, -- Compares to infinity + playerHp = Just 50, + inventory = [ Item{ + itemId = "key", + itemX = 0, + itemY = 0, + itemName = "Epic key", + itemDescription = "MyKey", + itemActions = [], + itemValue = Nothing, + useTimes = Nothing + }, Item{ + itemId = "dagger", + itemX = 0, + itemY = 0, + itemName = "My dagger", + itemDescription = "dagger", + itemActions = [], + itemValue = Nothing, + useTimes = Nothing + }], + position = (0, 0), + showInventory = False, + showHp = True +} + +defaultSelector :: ListSelector +defaultSelector = ListSelector { + selection = 0, + selected = False +} \ No newline at end of file diff --git a/lib/RPGEngine/Data/Game.hs b/lib/RPGEngine/Data/Game.hs new file mode 100644 index 0000000..da239a5 --- /dev/null +++ b/lib/RPGEngine/Data/Game.hs @@ -0,0 +1,37 @@ +module RPGEngine.Data.Game +( isLegalMove +, isPlayerAtExit +, isPlayerDead +) where + +import RPGEngine.Data + ( Player(..), + Direction, + Physical(Exit, Walkable, Entrance), + State(..), + Game(..) ) +import RPGEngine.Data.Level (findAt, directionOffsets) + +------------------------------ Exported ------------------------------ + +-- Check if a move is legal by checking what is located at the new position. +isLegalMove :: Direction -> Game -> Bool +isLegalMove dir g@Game{ state = Playing{ level = lvl, player = p@Player{ position = (x, y) }}} = legality + where legality = physical `elem` [Walkable, Entrance, Exit] + physical = findAt newPos lvl + newPos = (x + xD, y + yD) + (xD, yD) = directionOffsets dir +isLegalMove _ _ = False + +-- Check if a player is standing on an exit +isPlayerAtExit :: Game -> Bool +isPlayerAtExit g@Game{ state = Playing{ player = player, level = level }} = atExit + where playerPos = position player + atPos = findAt playerPos level + atExit = atPos == Exit +isPlayerAtExit _ = False + +-- Check if the players health is <= 0, which means the player is dead. +isPlayerDead :: Game -> Bool +isPlayerDead g@Game{ state = Playing{ player = Player{ playerHp = (Just hp)}}} = hp <= 0 +isPlayerDead _ = False diff --git a/lib/RPGEngine/Data/Level.hs b/lib/RPGEngine/Data/Level.hs new file mode 100644 index 0000000..875514d --- /dev/null +++ b/lib/RPGEngine/Data/Level.hs @@ -0,0 +1,100 @@ +module RPGEngine.Data.Level +-- Everything is exported +where + +import GHC.IO (unsafePerformIO) +import System.Directory (getDirectoryContents) +import RPGEngine.Input.Core (ListSelector(..)) +import RPGEngine.Data (Action(..), Level (..), Physical (..), Direction (..), Entity (..), Game (..), Item (..), Player (..), State (..), X, Y, Layout, Condition (InventoryFull, InventoryContains, Not, AlwaysFalse), ItemId) +import RPGEngine.Config (levelFolder, inventorySize) +import Data.Foldable (find) + +------------------------------ Exported ------------------------------ + +-- Find first position of a Physical +-- Graceful exit by giving Nothing if there is nothing found. +findFirstOf :: Level -> Physical -> Maybe (X, Y) +findFirstOf l@Level{ index = index } physical = try + where matches = filter (\(x, y, v) -> v == physical) index + try | not (null matches) = Just $ (\(x, y, _) -> (x, y)) $ head matches + | otherwise = Nothing + +-- What is located at a given position in the level? +findAt :: (X, Y) -> Level -> Physical +findAt pos lvl@Level{ index = index } = try + where matches = map (\(_, _, v) -> v) $ filter (\(x, y, v) -> (x, y) == pos) index + try | not (null matches) = head matches + | otherwise = Void + +hasAt :: (X, Y) -> Level -> Maybe (Either Item Entity) +hasAt pos level = match firstItem firstEntity + where match :: Maybe Item -> Maybe Entity -> Maybe (Either Item Entity) + match (Just a) _ = Just $ Left a + match _ (Just a) = Just $ Right a + match _ _ = Nothing + firstEntity = find ((== pos) . getECoord) $ entities level + getECoord e = (entityX e, entityY e) + firstItem = find ((== pos) . getICoord) $ items level + getICoord i = (itemX i, itemY i) + +getWithId :: String -> Level -> Maybe (Either Item Entity) +getWithId id level = match firstItem firstEntity + where match :: Maybe Item -> Maybe Entity -> Maybe (Either Item Entity) + match (Just a) _ = Just $ Left a + match _ (Just a) = Just $ Right a + match _ _ = Nothing + firstEntity = find ((== id) . entityId) $ entities level + firstItem = find ((== id) . itemId) $ items level + +directionOffsets :: Direction -> (X, Y) +directionOffsets North = ( 0, 1) +directionOffsets East = ( 1, 0) +directionOffsets South = ( 0, -1) +directionOffsets West = (-1, 0) +directionOffsets Stay = ( 0, 0) + +getLevelList :: [FilePath] +getLevelList = drop 2 $ unsafePerformIO $ getDirectoryContents levelFolder + +-- Get the actions of either an entity or an item +getActions :: Either Item Entity -> [([Condition], Action)] +getActions (Left item) = itemActions item +getActions (Right entity) = entityActions entity + +getActionText :: Action -> String +getActionText Leave = "Leave" +getActionText (RetrieveItem _) = "Pick up" +getActionText (UseItem _) = "Use item" +getActionText (IncreasePlayerHp _) = "Take a healing potion" +getActionText (DecreaseHp _ used) = "Attack using " ++ used +getActionText _ = "ERROR" + +-- Filter based on the conditions, keep only the actions of which the +-- conditions are met. +-- Should receive a Playing state +filterActions :: State -> [([Condition], Action)] -> [Action] +filterActions _ [] = [] +filterActions s (entry:others) = met entry $ filterActions s others + where met (conditions, action) l | all (meetsCondition s) conditions = action:l + | otherwise = l + +-- Check if a condition is met or not. +meetsCondition :: State -> Condition -> Bool +meetsCondition s InventoryFull = isInventoryFull $ player s +meetsCondition s (InventoryContains id) = inventoryContains id $ player s +meetsCondition s (Not condition) = not $ meetsCondition s condition +meetsCondition _ AlwaysFalse = False + +-- Check if the inventory of the player is full. +isInventoryFull :: Player -> Bool +isInventoryFull p = inventorySize <= length (inventory p) + +-- Check if the inventory of the player contains an item. +inventoryContains :: ItemId -> Player -> Bool +inventoryContains id p = any ((== id) . itemId) $ inventory p + +-- Retrieve an item from inventory +itemFromInventory :: ItemId -> [Item] -> (Maybe Item, [Item]) +itemFromInventory iid list = (match, filteredList) + where match = find ((== iid) . itemId) list + filteredList = filter ((/= iid) . itemId) list \ No newline at end of file diff --git a/lib/RPGEngine/Input.hs b/lib/RPGEngine/Input.hs new file mode 100644 index 0000000..8fc594c --- /dev/null +++ b/lib/RPGEngine/Input.hs @@ -0,0 +1,29 @@ +-- Implementations for each state can be found in their respective +-- submodules. +module RPGEngine.Input +( handleAllInput +) where + +import RPGEngine.Input.Core ( InputHandler, composeInputHandlers, handleAnyKey ) + +import RPGEngine.Data ( Game(..), State(..) ) +import RPGEngine.Input.Menu ( handleInputMenu ) +import RPGEngine.Input.LevelSelection (handleInputLevelSelection) +import RPGEngine.Input.Playing ( handleInputPlaying ) +import RPGEngine.Input.Paused ( handleInputPaused ) +import RPGEngine.Input.Win ( handleInputWin ) +import RPGEngine.Input.Lose ( handleInputLose ) +import RPGEngine.Input.ActionSelection (handleInputActionSelection) + +------------------------------ Exported ------------------------------ + +-- Handle all input of all states of the game. +handleAllInput :: InputHandler Game +handleAllInput ev g@Game{ state = Menu } = handleInputMenu ev g +handleAllInput ev g@Game{ state = LevelSelection{} } = handleInputLevelSelection ev g +handleAllInput ev g@Game{ state = Playing{} } = handleInputPlaying ev g +handleAllInput ev g@Game{ state = Paused{} } = handleInputPaused ev g +handleAllInput ev g@Game{ state = Win } = handleInputWin ev g +handleAllInput ev g@Game{ state = Lose{} } = handleInputLose ev g +handleAllInput ev g@Game{ state = ActionSelection{}} = handleInputActionSelection ev g +handleAllInput ev g@Game{ state = Error _ } = handleAnyKey (\game -> game{ state = Menu}) ev g \ No newline at end of file diff --git a/lib/RPGEngine/Input/ActionSelection.hs b/lib/RPGEngine/Input/ActionSelection.hs new file mode 100644 index 0000000..321a796 --- /dev/null +++ b/lib/RPGEngine/Input/ActionSelection.hs @@ -0,0 +1,142 @@ +module RPGEngine.Input.ActionSelection +( handleInputActionSelection +) where + +import RPGEngine.Input.Core (InputHandler, handleKey, composeInputHandlers, ListSelector (selection)) + +import RPGEngine.Data (Game (..), State (..), Direction (..), Action (..), ItemId, EntityId, Level (..), Player (inventory, playerHp, Player), Item (..), HP, Entity (..)) +import Graphics.Gloss.Interface.IO.Game (Key(SpecialKey), SpecialKey (KeyUp, KeyDown)) +import Graphics.Gloss.Interface.IO.Interact + ( SpecialKey(..), KeyState(..) ) +import RPGEngine.Data.Level (getWithId, itemFromInventory) +import Data.Foldable (find) + +------------------------------ Exported ------------------------------ + +handleInputActionSelection :: InputHandler Game +handleInputActionSelection = composeInputHandlers [ + handleKey (SpecialKey KeySpace) Down selectAction, + handleKey (SpecialKey KeyEnter) Down selectAction, + + handleKey (SpecialKey KeyUp) Down $ moveSelector North, + handleKey (SpecialKey KeyDown) Down $ moveSelector South + ] + +---------------------------------------------------------------------- + +selectAction :: Game -> Game +selectAction game@Game{ state = ActionSelection list selector continue } = newGame + where newGame = game{ state = execute selectedAction continue } + selectedAction = list !! index + index = selection selector +selectAction g = g + +-- TODO Lift this code from LevelSelection +-- Move the selector either up or down +moveSelector :: Direction -> Game -> Game +moveSelector dir game@Game{ state = state@(ActionSelection list selector _) } = newGame + where newGame = game{ state = newState } + newState = state{ selector = newSelector } + newSelector | constraint = selector{ selection = newSelection } + | otherwise = selector + constraint = 0 <= newSelection && newSelection < length list + newSelection = selection selector + diff + diff | dir == North = -1 + | dir == South = 1 + | otherwise = 0 +moveSelector _ g = g{ state = Error "Something went wrong while moving the selector up or down"} + +------------------------------ Actions ------------------------------- + +execute :: Action -> State -> State +execute (RetrieveItem id ) s = pickUpItem id s +execute (UseItem id ) s = useItem id s +execute (DecreaseHp eid iid) s = decreaseHp eid iid s +execute (IncreasePlayerHp iid) s = healedPlayer + where healedPlayer = s{ player = increasePlayerHp iid (player s)} +execute _ s = s + +-- Pick up the item with itemId and put it in the players inventory +-- Should receive a Playing state +pickUpItem :: ItemId -> State -> State +pickUpItem id s@Playing{ level = level, player = player } = newState + where (Just (Left pickedUpItem)) = getWithId id level + newState = s{ level = newLevel, player = newPlayer } + newLevel = level{ items = filteredItems } + filteredItems = filter (/= pickedUpItem) $ items level + newPlayer = player{ inventory = newInventory } + newInventory = pickedUpItem:inventory player +pickUpItem _ _ = Error "Something went wrong while picking up an item" + +-- Use an item +-- Should receive a Playing state +useItem :: ItemId -> State -> State +useItem iid s@Playing{ level = level, player = player} = newState + where newState = s{ level = newLevel, player = newPlayer } + -- Remove item from inventory if necessary + (Just usingItem) = find ((== iid) . itemId) $ inventory player + usedItem = decreaseDurability usingItem + newInventory = filter (/= usingItem) $ inventory player + newPlayer = player{ inventory = putItemBack usedItem newInventory } + putItemBack Nothing inv = inv + putItemBack (Just item) inv = item:inv + -- Remove entity if necessary + allEntities = entities level + entitiesWithUseItem = filter (any ((== UseItem iid) . snd) . entityActions) allEntities + attackedEntity = head entitiesWithUseItem + newLevel = level{ entities = filter (/= attackedEntity) $ entities level} +useItem _ _ = Error "Something went wrong while using an item" + +-- Attack an entity using an item +-- Should receive a Playing state +decreaseHp :: EntityId -> ItemId -> State -> State +decreaseHp eid iid s@Playing{ level = level, player = player } = newState + where newState = s{ level = newLevel, player = newPlayer } + -- Change player + (Just usingItem) = find ((== iid) . itemId) $ inventory player + usedItem = decreaseDurability usingItem + newInventory = filter (/= usingItem) $ inventory player + newPlayer = player{ inventory = putItemBack usedItem newInventory, playerHp = newHp } + putItemBack Nothing inv = inv + putItemBack (Just item) inv = item:inv + newHp = changeHealth (playerHp player) damageGetAmount -- Damage dealt by entity + damageDealAmount = itemValue usingItem + -- Change entity + (Just (Right attackedEntity)) = getWithId eid level + newLevel = level{ entities = putEntityBack dealtWithEntity newEntities } + newEntities = filter ((/= eid) . entityId) $ entities level + dealtWithEntity = decreaseHealth attackedEntity damageDealAmount + putEntityBack Nothing list = list + putEntityBack (Just ent) list = ent:list + damageGetAmount = inverse (entityValue attackedEntity) + inverse (Just val) = Just (-val) + inverse Nothing = Nothing +decreaseHp _ _ _ = Error "something went wrong while attacking" + +-- Heal a bit +-- Should receive a Player +increasePlayerHp :: ItemId -> Player -> Player +increasePlayerHp id p@Player{ playerHp = hp, inventory = inventory} = newPlayer + where newPlayer = p{ playerHp = newHp, inventory = newInventory newItem } + (Just usedItem) = find ((== id) . itemId) inventory + newItem = decreaseDurability usedItem + newInventory (Just item) = item:filteredInventory + newInventory _ = filteredInventory + filteredInventory =filter (/= usedItem) inventory + newHp = changeHealth hp (itemValue usedItem) + +decreaseDurability :: Item -> Maybe Item +decreaseDurability item@Item{ useTimes = Nothing } = Just item -- Infinite uses, never breaks +decreaseDurability item@Item{ useTimes = Just val } | 0 < val - 1 = Just item{ useTimes = Just (val - 1) } + | otherwise = Nothing -- Broken + +decreaseHealth :: Entity -> Maybe Int -> Maybe Entity +decreaseHealth entity@Entity{ entityHp = Nothing } _ = Just entity +decreaseHealth entity@Entity{ entityHp = Just val } (Just i) | 0 < val - i = Just entity{ entityHp = Just (val - i) } + | otherwise = Nothing +decreaseHealth entity _ = Just entity + +-- Change given health by a given amount +changeHealth :: HP -> HP -> HP +changeHealth (Just health) (Just difference) = Just (health + difference) +changeHealth health _ = health \ No newline at end of file diff --git a/lib/RPGEngine/Input/Core.hs b/lib/RPGEngine/Input/Core.hs new file mode 100644 index 0000000..467e149 --- /dev/null +++ b/lib/RPGEngine/Input/Core.hs @@ -0,0 +1,63 @@ +module RPGEngine.Input.Core +( InputHandler +, ListSelector(..) + +, composeInputHandlers +, handle +, handleKey +, handleAnyKey + +, SpecialKey(..) +) where + +import Graphics.Gloss.Interface.Pure.Game + ( Event(EventKey), Key(..), KeyState(Down), SpecialKey ) + +----------------------------- Constants ------------------------------ + +type InputHandler a = Event -> (a -> a) + +data ListSelector = ListSelector { + selection :: Int, + selected :: Bool +} deriving (Eq, Show) + +------------------------------ Exported ------------------------------ + +-- Compose multiple InputHandlers into one InputHandler that handles +-- all of them. +composeInputHandlers :: [InputHandler a] -> InputHandler a +composeInputHandlers [] ev a = a +composeInputHandlers (ih:ihs) ev a = composeInputHandlers ihs ev (ih ev a) + +-- Handle any event +handle :: Event -> (a -> a) -> InputHandler a +handle (EventKey key state _ _) = handleKey key state +-- handle (EventMotion _) = undefined -- TODO +-- handle (EventResize _) = undefined -- TODO +handle _ = const (const id) + +-- Handle a event by pressing a key +handleKey :: Key -> KeyState -> (a -> a) -> InputHandler a +handleKey (SpecialKey sk) s = handleSpecialKey sk s +handleKey (Char c ) s = handleCharKey c s +handleKey (MouseButton _ ) _ = const (const id) + +-- Handle any key, equivalent to "Press any key to start" +handleAnyKey :: (a -> a) -> InputHandler a +handleAnyKey f (EventKey _ Down _ _) = f +handleAnyKey _ _ = id + +--------------------------- Help functions --------------------------- + +handleCharKey :: Char -> KeyState -> (a -> a) -> InputHandler a +handleCharKey c1 s1 f (EventKey (Char c2) s2 _ _) + | c1 == c2 && s1 == s2 = f + | otherwise = id +handleCharKey _ _ _ _ = id + +handleSpecialKey :: SpecialKey -> KeyState -> (a -> a) -> InputHandler a +handleSpecialKey sk1 s1 f (EventKey (SpecialKey sk2) s2 _ _) + | sk1 == sk2 && s1 == s2 = f + | otherwise = id +handleSpecialKey _ _ _ _ = id \ No newline at end of file diff --git a/lib/RPGEngine/Input/LevelSelection.hs b/lib/RPGEngine/Input/LevelSelection.hs new file mode 100644 index 0000000..84420c0 --- /dev/null +++ b/lib/RPGEngine/Input/LevelSelection.hs @@ -0,0 +1,45 @@ +module RPGEngine.Input.LevelSelection +( handleInputLevelSelection +) where + +import RPGEngine.Input.Core (InputHandler, composeInputHandlers, handleKey, ListSelector (..)) + +import RPGEngine.Data (Game (..), State (..), Direction (..)) +import Graphics.Gloss.Interface.IO.Game (Key(..)) +import Graphics.Gloss.Interface.IO.Interact (SpecialKey(..), KeyState(..)) +import RPGEngine.Config (levelFolder) +import RPGEngine.Parse (parse) + +------------------------------ Exported ------------------------------ + +handleInputLevelSelection :: InputHandler Game +handleInputLevelSelection = composeInputHandlers [ + handleKey (SpecialKey KeySpace) Down selectLevel, + + handleKey (SpecialKey KeyUp) Down $ moveSelector North, + handleKey (SpecialKey KeyDown) Down $ moveSelector South + ] + +---------------------------------------------------------------------- + +-- Select a level and load it in +selectLevel :: Game -> Game +selectLevel game@Game{ state = LevelSelection list selector } = newGame + where newGame = parse $ levelFolder ++ (list !! index) + index = selection selector +selectLevel g = g{ state = Error "Something went wrong while selecting a level"} + +-- TODO Lift this code from ActionSelection +-- Move the selector either up or down +moveSelector :: Direction -> Game -> Game +moveSelector dir game@Game{ state = state@(LevelSelection list selector) } = newGame + where newGame = game{ state = newState } + newState = state{ selector = newSelector } + newSelector | constraint = selector{ selection = newSelection } + | otherwise = selector + constraint = 0 <= newSelection && newSelection < length list + newSelection = selection selector + diff + diff | dir == North = -1 + | dir == South = 1 + | otherwise = 0 +moveSelector _ g = g{ state = Error "Something went wrong while moving the selector up or down"} \ No newline at end of file diff --git a/lib/RPGEngine/Input/Lose.hs b/lib/RPGEngine/Input/Lose.hs new file mode 100644 index 0000000..a7ff57e --- /dev/null +++ b/lib/RPGEngine/Input/Lose.hs @@ -0,0 +1,17 @@ +module RPGEngine.Input.Lose +( handleInputLose +) where + +import RPGEngine.Input.Core (InputHandler, handleAnyKey) + +import RPGEngine.Data (Game(..), State(..)) + +------------------------------ Exported ------------------------------ + +handleInputLose :: InputHandler Game +handleInputLose = handleAnyKey retry + +---------------------------------------------------------------------- + +retry :: Game -> Game +retry g@Game{ state = Lose{ restart = restart }} = g{ state = restart } \ No newline at end of file diff --git a/lib/RPGEngine/Input/Menu.hs b/lib/RPGEngine/Input/Menu.hs new file mode 100644 index 0000000..9dd27d8 --- /dev/null +++ b/lib/RPGEngine/Input/Menu.hs @@ -0,0 +1,23 @@ +module RPGEngine.Input.Menu +( handleInputMenu +) where + +import RPGEngine.Input.Core (InputHandler, composeInputHandlers, handleAnyKey) +import RPGEngine.Data (Game (state), State (..)) +import RPGEngine.Data.Default (defaultSelector) +import RPGEngine.Data.Level (getLevelList) + +------------------------------ Exported ------------------------------ + +handleInputMenu :: InputHandler Game +handleInputMenu = composeInputHandlers [ + handleAnyKey (\game -> game{ state = startLevelSelection }) + ] + +---------------------------------------------------------------------- + +startLevelSelection :: State +startLevelSelection = LevelSelection { + levelList = getLevelList, + selector = defaultSelector +} \ No newline at end of file diff --git a/lib/RPGEngine/Input/Paused.hs b/lib/RPGEngine/Input/Paused.hs new file mode 100644 index 0000000..7ef6c63 --- /dev/null +++ b/lib/RPGEngine/Input/Paused.hs @@ -0,0 +1,18 @@ +module RPGEngine.Input.Paused +( handleInputPaused +) where + +import RPGEngine.Input.Core (InputHandler, handleAnyKey) +import RPGEngine.Data (Game (..), State (continue, Paused)) + +------------------------------ Exported ------------------------------ + +handleInputPaused :: InputHandler Game +handleInputPaused = handleAnyKey continueGame + +---------------------------------------------------------------------- + +continueGame :: Game -> Game +continueGame g@Game{ state = Paused{ continue = state }} = newGame + where newGame = g{ state = state } +continueGame g = g \ No newline at end of file diff --git a/lib/RPGEngine/Input/Playing.hs b/lib/RPGEngine/Input/Playing.hs new file mode 100644 index 0000000..6f1c9fa --- /dev/null +++ b/lib/RPGEngine/Input/Playing.hs @@ -0,0 +1,149 @@ +module RPGEngine.Input.Playing +( handleInputPlaying +, checkPlaying +, spawnPlayer +, putCoords +) where + +import RPGEngine.Input.Core (InputHandler, handle, handleKey, composeInputHandlers, ListSelector (..)) + +import RPGEngine.Data (Game (..), Layout(..), Level(..), Physical(..), Player(..), State(..), X, Y, Direction (..), Entity (..), Item (..)) +import RPGEngine.Data.Game (isLegalMove, isPlayerDead, isPlayerAtExit) +import RPGEngine.Data.Level (directionOffsets, findFirstOf, hasAt, filterActions, getActions) + +import Data.Maybe (fromJust, isNothing) +import Graphics.Gloss.Interface.IO.Game (Key(..)) +import Graphics.Gloss.Interface.IO.Interact (SpecialKey(..), KeyState(..), Event(..), KeyState(..)) +import Prelude hiding (interact) + +------------------------------ Exported ------------------------------ + +handleInputPlaying :: InputHandler Game +handleInputPlaying = composeInputHandlers [ + -- Pause the game + handleKey (Char 'p') Down pauseGame, + + -- Player movement + handleKey (SpecialKey KeyUp) Down $ movePlayer North, + handleKey (SpecialKey KeyRight) Down $ movePlayer East, + handleKey (SpecialKey KeyDown) Down $ movePlayer South, + handleKey (SpecialKey KeyLeft) Down $ movePlayer West, + + handleKey (Char 'w') Down $ movePlayer North, + handleKey (Char 'd') Down $ movePlayer East, + handleKey (Char 's') Down $ movePlayer South, + handleKey (Char 'a') Down $ movePlayer West, + + -- Interaction with entities and items + handleKey (SpecialKey KeySpace) Down checkForInteraction, + handleKey (SpecialKey KeyEnter) Down checkForInteraction, + handleKey (Char 'f') Down checkForInteraction, + + handleKey (Char 'i') Down $ toggleInventoryShown True, + handleKey (Char 'i') Up $ toggleInventoryShown False, + handleKey (SpecialKey KeyTab) Down $ toggleInventoryShown True, + handleKey (SpecialKey KeyTab) Up $ toggleInventoryShown False, + + handleKey (Char 'r') Down restartGame + ] + +---------------------------------------------------------------------- + +-- Set the initial position of the player in a given level. +spawnPlayer :: Level -> Player -> Player +spawnPlayer l@Level{ layout = lay } p@Player{ position = prevPos } = p{ position = newPos } + where try = findFirstOf l Entrance + newPos | isNothing try = prevPos + | otherwise = fromJust try + +checkPlaying :: Game -> Game +checkPlaying g@Game{ state = s@Playing{ restart = restart }} = newGame + where newGame | isPlayerDead g = loseGame + | isPlayerAtExit g = g{ state = goToNextLevel s } + | otherwise = g + loseGame = g{ state = Lose{ restart = restart }} +checkPlaying g = g + +pauseGame :: Game -> Game +pauseGame g@Game{ state = playing@Playing{} } = pausedGame + where pausedGame = g{ state = Paused playing } +pauseGame g = g + +restartGame :: Game -> Game +restartGame g@Game{ state = playing@Playing{ restart = restarted } } = g{ state = restarted } +restartGame g = g{ state = Error "something went wrong while restarting the level"} + +-- Go to next level if there is a next level, otherwise, initialize win state. +goToNextLevel :: State -> State +goToNextLevel s@Playing{ levels = levels, level = current, count = count, player = player } = nextState + where nextState | (count + 1) < length levels = nextLevelState + | otherwise = Win + nextLevelState = s{ level = nextLevel, count = count + 1, player = movedPlayer, restart = nextLevelState } + nextLevel = levels !! (count + 1) + movedPlayer = spawnPlayer nextLevel player +goToNextLevel s = s + +-- Move a player in a direction if possible. +movePlayer :: Direction -> Game -> Game +movePlayer dir g@Game{ state = s@Playing{ player = p@Player{ position = (x, y) }}} = tryForceInteraction newGame g + where newGame = g{ state = newState } + newState = s{ player = newPlayer } + newPlayer = p{ position = newCoord } + newCoord | isLegalMove dir g = (x + xD, y + yD) + | otherwise = (x, y) + (xD, yD) = directionOffsets dir +movePlayer _ g = g{ state = Error "something went wrong while moving the player" } + +-- TODO Clean this function +-- Try to force an interaction. If there is an entity, you have to +-- interact with it. If it is an item, the user should trigger this +-- themselves. If forced, the player should not move to the new position. +tryForceInteraction :: Game -> Game -> Game +tryForceInteraction g@Game{ state = Playing { level = level, player = player }} fallBack@Game{ state = Playing{ player = firstPlayer }} = newGame triedInteraction + where newGame g@Game{ state = s@ActionSelection{ continue = c@Playing{ player = player}}} = g{ state = s{ continue = c{ player = playerWithRestorePos }}} + newGame g = g + playerWithRestorePos = (newPlayer triedInteraction){ position = position firstPlayer } + newPlayer Game{ state = ActionSelection{ continue = Playing{ player = player }}} = player + triedInteraction | hasEntity (hasAt pos level) = interact g + | otherwise = g + pos = position player + hasEntity (Just (Right entity)) = True + hasEntity _ = False +tryForceInteraction g _ = g{ state = Error "something went wrong while trying to force interaction"} + +-- If there is an interaction at the current position, go to +-- actionSelection state. Otherwise just continue the game. +checkForInteraction :: Game -> Game +checkForInteraction g@Game{ state = Playing{ level = level, player = player }} = newGame + where newGame | canInteract = interact g + | otherwise = g + canInteract = not $ null $ hasAt pos level + pos = position player +checkForInteraction g = g{ state = Error "something went wrong while checking for entities to interact with" } + +interact :: Game -> Game +interact g@Game{ state = s@Playing{ level = level, player = player } } = g{ state = newState } + where newState = ActionSelection actionList selector continue + actionList = filterActions s $ getActions $ fromJust $ hasAt pos level + selector = ListSelector 0 False + pos = position player + continue = s +interact g = g{ state = Error "something went wrong while interacting with object"} + +toggleInventoryShown :: Bool -> Game -> Game +toggleInventoryShown shown g@Game{ state = s@Playing{ player = p }}= newGame + where newGame = g{ state = newState } + newState = s{ player = newPlayer } + newPlayer = p{ showInventory = shown } +toggleInventoryShown _ g = g{ state = Error "something went wrong while working inventory" } + +-- Map all Physicals onto coordinates +putCoords :: Level -> [(X, Y, Physical)] +putCoords l@Level{ layout = lay } = concatMap (\(a, bs) -> map (\(b, c) -> (b, a, c)) bs) numberedList + where numberedStrips = reverse $ zip [0::Int .. ] $ reverse lay + numberedList = map (\(x, strip) -> (x, zip [0::Int ..] strip)) numberedStrips + +-- putCoords l = concatMap numberColumns intermediate +-- where numberColumns (rowIndex, numberedRow) = map (\(colIndex, cell) -> (colIndex, rowIndex, cell)) numberedRow +-- intermediate = zip [0 .. ] numberedRows +-- numberedRows = zip [0::X .. ] $ layout l \ No newline at end of file diff --git a/lib/RPGEngine/Input/Win.hs b/lib/RPGEngine/Input/Win.hs new file mode 100644 index 0000000..3eeaf5d --- /dev/null +++ b/lib/RPGEngine/Input/Win.hs @@ -0,0 +1,16 @@ +module RPGEngine.Input.Win +( handleInputWin +) where + +import RPGEngine.Input.Core (InputHandler, handleAnyKey) +import RPGEngine.Data (Game (..), State (Menu)) + +------------------------------ Exported ------------------------------ + +handleInputWin :: InputHandler Game +handleInputWin = handleAnyKey goToMenu + +---------------------------------------------------------------------- + +goToMenu :: Game -> Game +goToMenu g = g{ state = Menu } \ No newline at end of file diff --git a/lib/RPGEngine/Parse.hs b/lib/RPGEngine/Parse.hs new file mode 100644 index 0000000..c63afd3 --- /dev/null +++ b/lib/RPGEngine/Parse.hs @@ -0,0 +1,16 @@ +module RPGEngine.Parse +( parse +) where + +import RPGEngine.Data ( Game ) +import RPGEngine.Parse.StructureToGame ( structureToGame ) +import GHC.IO (unsafePerformIO) +import Text.Parsec.String (parseFromFile) +import RPGEngine.Parse.TextToStructure ( gameFile ) + +------------------------------ Exported ------------------------------ + +parse :: FilePath -> Game +parse filename = structureToGame struct + where (Right struct) = unsafePerformIO io + io = parseFromFile gameFile filename \ No newline at end of file diff --git a/lib/RPGEngine/Parse/Core.hs b/lib/RPGEngine/Parse/Core.hs new file mode 100644 index 0000000..ff1be67 --- /dev/null +++ b/lib/RPGEngine/Parse/Core.hs @@ -0,0 +1,36 @@ +module RPGEngine.Parse.Core +( parseWith +, parseWithRest +, ignoreWS +) where + +import Text.Parsec + ( ParseError, + anyChar, + endOfLine, + spaces, + string, + anyToken, + choice, + eof, + manyTill, + parse ) +import Text.Parsec.String ( Parser ) + +------------------------------ Exported ------------------------------ + +-- A wrapper, which takes a parser and some input and returns a +-- parsed output. +parseWith :: Parser a -> String -> Either ParseError a +parseWith parser = parse parser "" + +-- Also return anything that has not yet been parsed +parseWithRest :: Parser a -> String -> Either ParseError (a, String) +-- fmap (,) over Parser monad and apply to rest +parseWithRest parser = parse ((,) <$> parser <*> rest) "" + where rest = manyTill anyToken eof + +-- Ignore all kinds of whitespace +ignoreWS :: Parser a -> Parser a +ignoreWS parser = choice [skipComment, spaces] >> parser + where skipComment = do{ string "#"; manyTill anyChar endOfLine; return ()} \ No newline at end of file diff --git a/lib/RPGEngine/Parse/StructureToGame.hs b/lib/RPGEngine/Parse/StructureToGame.hs new file mode 100644 index 0000000..09e9e83 --- /dev/null +++ b/lib/RPGEngine/Parse/StructureToGame.hs @@ -0,0 +1,121 @@ +module RPGEngine.Parse.StructureToGame +-- Everything is exported for testing +where + +import RPGEngine.Data + ( Action, + Condition, + Player(playerHp, inventory), + Entity(entityId, entityX, entityY, entityName, entityDescription, + entityActions, entityValue, entityHp, direction), + Item(itemId, itemX, itemY, itemName, itemDescription, itemValue, + itemActions, useTimes), + Level(layout, items, entities, index), + Game (..), State (..) ) +import RPGEngine.Parse.TextToStructure + ( Value(Infinite, Action, Layout, String, Direction, Integer), + Key(Tag, ConditionList), + Structure(..) ) +import RPGEngine.Data.Default (defaultPlayer, defaultLevel, defaultItem, defaultEntity) +import RPGEngine.Input.Playing (putCoords, spawnPlayer) + +------------------------------ Exported ------------------------------ + +structureToGame :: [Structure] -> Game +structureToGame [Entry (Tag "player") playerBlock, Entry (Tag "levels") levelsBlock] = game + where game = Game newState + newState = Playing newLevels 0 currentLevel newPlayer newState + newLevels = structureToLevels levelsBlock + currentLevel = head newLevels + newPlayer = spawnPlayer currentLevel $ structureToPlayer playerBlock +structureToGame _ = Game Menu + +------------------------------- Player ------------------------------- + +structureToPlayer :: Structure -> Player +structureToPlayer (Block block) = structureToPlayer' block defaultPlayer +structureToPlayer _ = defaultPlayer + +structureToPlayer' :: [Structure] -> Player -> Player +structureToPlayer' [] p = p +structureToPlayer' ((Entry(Tag "hp") val ):es) p = (structureToPlayer' es p){ playerHp = structureToMaybeInt val } +structureToPlayer' ((Entry(Tag "inventory") (Block inv)):es) p = (structureToPlayer' es p){ inventory = structureToItems inv } +structureToPlayer' _ _ = defaultPlayer + +structureToActions :: Structure -> [([Condition], Action)] +structureToActions (Block []) = [] +structureToActions (Block block) = structureToActions' block [] +structureToActions _ = [] + +structureToActions' :: [Structure] -> [([Condition], Action)] -> [([Condition], Action)] +structureToActions' [] list = list +structureToActions' ((Entry(ConditionList cs) (Regular (Action a))):as) list = structureToActions' as ((cs, a):list) +structureToActions' _ list = list + +------------------------------- Levels ------------------------------- + +structureToLevels :: Structure -> [Level] +structureToLevels (Block struct) = structureToLevel <$> struct +structureToLevels _ = [defaultLevel] + +structureToLevel :: Structure -> Level +structureToLevel (Block entries) = indexIsSet + where indexIsSet = level{ index = putCoords level } + level = structureToLevel' entries defaultLevel +structureToLevel _ = defaultLevel + +structureToLevel' :: [Structure] -> Level -> Level +structureToLevel' ((Entry(Tag "layout") (Regular (Layout layout))):ls) l = (structureToLevel' ls l){ RPGEngine.Data.layout = layout } +structureToLevel' ((Entry(Tag "items") (Block items) ):ls) l = (structureToLevel' ls l){ items = structureToItems items } +structureToLevel' ((Entry(Tag "entities") (Block entities) ):ls) l = (structureToLevel' ls l){ entities = structureToEntities entities } +structureToLevel' _ _ = defaultLevel + +------------------------------- Items -------------------------------- + +structureToItems :: [Structure] -> [Item] +structureToItems items = structureToItem <$> items + +structureToItem :: Structure -> Item +structureToItem (Block block) = structureToItem' block defaultItem +structureToItem _ = defaultItem + +structureToItem' :: [Structure] -> Item -> Item +structureToItem' [] i = i +structureToItem' ((Entry(Tag "id") (Regular(String id ))):is) i = (structureToItem' is i){ itemId = id } +structureToItem' ((Entry(Tag "x") (Regular(Integer x ))):is) i = (structureToItem' is i){ itemX = x } +structureToItem' ((Entry(Tag "y") (Regular(Integer y ))):is) i = (structureToItem' is i){ itemY = y } +structureToItem' ((Entry(Tag "name") (Regular(String name))):is) i = (structureToItem' is i){ itemName = name } +structureToItem' ((Entry(Tag "description") (Regular(String desc))):is) i = (structureToItem' is i){ itemDescription = desc } +structureToItem' ((Entry(Tag "value") val ):is) i = (structureToItem' is i){ itemValue = structureToMaybeInt val } +structureToItem' ((Entry(Tag "actions") actions ):is) i = (structureToItem' is i){ itemActions = structureToActions actions } +structureToItem' ((Entry (Tag "useTimes") useTimes ):is) i = (structureToItem' is i){ useTimes = structureToMaybeInt useTimes } +structureToItem' _ _ = defaultItem + +------------------------------ Entities ------------------------------ + +structureToEntities :: [Structure] -> [Entity] +structureToEntities entities = structureToEntity <$> entities + +structureToEntity :: Structure -> Entity +structureToEntity (Block block) = structureToEntity' block defaultEntity +structureToEntity _ = defaultEntity + +structureToEntity' :: [Structure] -> Entity -> Entity +structureToEntity' [] e = e +structureToEntity' ((Entry(Tag "id") (Regular(String id )) ):es) e = (structureToEntity' es e){ entityId = id } +structureToEntity' ((Entry(Tag "x") (Regular(Integer x )) ):es) e = (structureToEntity' es e){ entityX = x } +structureToEntity' ((Entry(Tag "y") (Regular(Integer y )) ):es) e = (structureToEntity' es e){ entityY = y } +structureToEntity' ((Entry(Tag "name") (Regular(String name)) ):es) e = (structureToEntity' es e){ entityName = name } +structureToEntity' ((Entry(Tag "description") (Regular(String desc)) ):es) e = (structureToEntity' es e){ entityDescription = desc } +structureToEntity' ((Entry(Tag "actions") actions ):es) e = (structureToEntity' es e){ entityActions = structureToActions actions } +structureToEntity' ((Entry(Tag "value") val ):es) e = (structureToEntity' es e){ entityValue = structureToMaybeInt val } +structureToEntity' ((Entry(Tag "hp") val ):es) e = (structureToEntity' es e){ entityHp = structureToMaybeInt val } +structureToEntity' ((Entry(Tag "direction") (Regular(Direction dir))):es) e = (structureToEntity' es e){ RPGEngine.Data.direction = dir } +structureToEntity' _ _ = defaultEntity + +---------------------------------------------------------------------- + +structureToMaybeInt :: Structure -> Maybe Int +structureToMaybeInt (Regular (Integer val)) = Just val +structureToMaybeInt (Regular Infinite) = Prelude.Nothing +structureToMaybeInt _ = Prelude.Nothing -- TODO \ No newline at end of file diff --git a/lib/RPGEngine/Parse/TextToStructure.hs b/lib/RPGEngine/Parse/TextToStructure.hs new file mode 100644 index 0000000..d3c7ba0 --- /dev/null +++ b/lib/RPGEngine/Parse/TextToStructure.hs @@ -0,0 +1,206 @@ +module RPGEngine.Parse.TextToStructure +-- Everything is exported for testing +where + +import RPGEngine.Parse.Core ( ignoreWS ) + +import RPGEngine.Data (Action (..), Condition (..), Direction (..), Layout, Strip, Physical (..)) + +import Text.Parsec + ( alphaNum, + char, + digit, + noneOf, + oneOf, + between, + choice, + many1, + notFollowedBy, + sepBy, + many, + try, spaces, endOfLine ) +import qualified Text.Parsec as P ( string ) +import Text.Parsec.String ( Parser ) +import Text.Parsec.Combinator (lookAhead) + +gameFile :: Parser [Structure] +gameFile = try $ do many1 $ ignoreWS structure + +-------------------------- StructureElement -------------------------- + +-- See documentation for more details, only a short description is +-- provided here. +data Structure = Block [Structure] + | Entry Key Structure -- Key + Value + | Regular Value -- Regular value, Integer or String or Infinite + deriving (Eq, Show) + +---------------------------------------------------------------------- + +structure :: Parser Structure +structure = try $ choice [block, entry, regular] + +-- A list of entries +block :: Parser Structure +block = try $ do + open <- ignoreWS $ oneOf openingBrackets + middle <- ignoreWS $ choice [entry, block] `sepBy` char ',' + let closingBracket = getMatchingClosingBracket open + ignoreWS $ char closingBracket + return $ Block middle + +entry :: Parser Structure +entry = try $ do + key <- ignoreWS key + -- TODO Fix this + oneOf ": " -- Can be left out + value <- ignoreWS structure + return $ Entry key value + +regular :: Parser Structure +regular = try $ Regular <$> value + +--------------------------------- Key -------------------------------- + +data Key = Tag String + | ConditionList [Condition] + deriving (Eq, Show) + +data ConditionArgument = ArgString String + | Condition Condition + deriving (Eq, Show) + +---------------------------------------------------------------------- + +key :: Parser Key +key = try $ choice [conditionList, tag] + +tag :: Parser Key +tag = try $ Tag <$> many1 alphaNum + +conditionList :: Parser Key +conditionList = try $ do + open <- ignoreWS $ oneOf openingBrackets + list <- ignoreWS condition `sepBy` char ',' + let closingBracket = getMatchingClosingBracket open + ignoreWS $ char closingBracket + return $ ConditionList $ extract list + where extract ((Condition cond):list) = cond:extract list + extract _ = [] + +condition :: Parser ConditionArgument +condition = try $ do + text <- ignoreWS $ many1 $ noneOf illegalCharacters + open <- ignoreWS $ oneOf openingBrackets + cond <- ignoreWS $ choice [condition, argString] + let closingBracket = getMatchingClosingBracket open + ignoreWS $ char closingBracket + return $ Condition $ make text cond + where make "inventoryFull" _ = InventoryFull + make "inventoryContains" (ArgString arg) = InventoryContains arg + make "not" (Condition cond) = Not cond + make _ _ = AlwaysFalse + argString = try $ ArgString <$> many (noneOf illegalCharacters) + +-------------------------------- Value ------------------------------- + +data Value = String String + | Integer Int + | Infinite + | Action Action + | Direction Direction + | Layout Layout + deriving (Eq, Show) + +---------------------------------------------------------------------- + +value :: Parser Value +value = choice [layout, string, integer, infinite, direction, action] + +string :: Parser Value +string = try $ String <$> between (char '\"') (char '\"') reading + where reading = ignoreWS $ many1 $ noneOf illegalCharacters + +integer :: Parser Value +integer = try $ do + value <- ignoreWS $ many1 digit + return $ Integer (read value :: Int) + +infinite :: Parser Value +infinite = try $ do + ignoreWS $ P.string "infinite" + notFollowedBy alphaNum + return Infinite + +action :: Parser Value +action = try $ do + script <- ignoreWS $ many1 $ noneOf "(" + arg <- ignoreWS $ between (char '(') (char ')') $ many $ noneOf ")" + let answer | script == "leave" = Leave + | script == "retrieveItem" = RetrieveItem arg + | script == "useItem" = UseItem arg + | script == "decreaseHp" = DecreaseHp first (filter (/= ' ') second) -- TODO Work this hack away + | script == "increasePlayerHp" = IncreasePlayerHp arg + | otherwise = DoNothing + (first, ',':second) = break (== ',') arg + return $ Action answer + +direction :: Parser Value +direction = try $ do + value <- choice [ + ignoreWS $ P.string "up", + ignoreWS $ P.string "down", + ignoreWS $ P.string "left", + ignoreWS $ P.string "right" + ] + -- lookAhead $ char ',' + return $ Direction $ make value + where make "up" = North + make "right" = East + make "down" = South + make "left" = West + make _ = Stay + +layout :: Parser Value +layout = try $ do + open <- ignoreWS $ oneOf openingBrackets + let closing = getMatchingClosingBracket open + value <- many1 strip <* ignoreWS (char closing) + return $ Layout value + +strip :: Parser Strip +strip = try $ do ignoreWS (char '|') *> ignoreWS (physical `sepBy` char ' ') + +physical :: Parser Physical +physical = try $ do + value <- choice [ + char 'x', + char '.', + char '*', + char 's', + char 'e' + ] + return $ make value + where make '.' = Walkable + make '*' = Blocked + make 's' = Entrance + make 'e' = Exit + make _ = Void + +------------------------------ Brackets ------------------------------ + +openingBrackets :: [Char] +openingBrackets = "<({[" + +closingBrackets :: [Char] +closingBrackets = ">)}]" + +illegalCharacters :: [Char] +illegalCharacters = ",:\"" ++ openingBrackets ++ closingBrackets + +---------------------------------------------------------------------- + +getMatchingClosingBracket :: Char -> Char +getMatchingClosingBracket opening = closingBrackets !! index + where combo = zip openingBrackets [0 ..] + index = head $ [y | (x, y) <- combo, x == opening] \ No newline at end of file diff --git a/lib/RPGEngine/Render.hs b/lib/RPGEngine/Render.hs new file mode 100644 index 0000000..2c2e158 --- /dev/null +++ b/lib/RPGEngine/Render.hs @@ -0,0 +1,39 @@ +-- Implementation for each state can be found in their respective +-- submodules. +module RPGEngine.Render +( initWindow +, render +) where + +import RPGEngine.Render.Core ( Renderer(..) ) + +import RPGEngine.Data (Game(..), State (..)) +import RPGEngine.Render.Menu( renderMenu ) +import RPGEngine.Render.LevelSelection ( renderLevelSelection ) +import RPGEngine.Render.Playing ( renderPlaying ) +import RPGEngine.Render.Paused ( renderPaused ) +import RPGEngine.Render.Win ( renderWin ) +import RPGEngine.Render.Lose ( renderLose ) + +import Graphics.Gloss ( Display, text, color ) +import Graphics.Gloss.Data.Picture (Picture, blank) +import Graphics.Gloss.Data.Display (Display(..)) +import RPGEngine.Render.ActionSelection (renderActionSelection) +import RPGEngine.Config (textColor) + +---------------------------------------------------------------------- + +-- Initialize a window to play in +initWindow :: String -> (Int, Int) -> (Int, Int) -> Display +initWindow = InWindow + +-- Render all different states +render :: Game -> Picture +render Game{ state = s@Menu } = renderMenu s +render Game{ state = s@LevelSelection{} } = renderLevelSelection s +render Game{ state = s@Playing{} } = renderPlaying s +render Game{ state = s@Paused{} } = renderPaused s +render Game{ state = s@Win } = renderWin s +render Game{ state = s@Lose{} } = renderLose s +render Game{ state = s@ActionSelection{}} = renderActionSelection s +render Game{ state = Error message } = color textColor $ text message \ No newline at end of file diff --git a/lib/RPGEngine/Render/ActionSelection.hs b/lib/RPGEngine/Render/ActionSelection.hs new file mode 100644 index 0000000..164719e --- /dev/null +++ b/lib/RPGEngine/Render/ActionSelection.hs @@ -0,0 +1,26 @@ +module RPGEngine.Render.ActionSelection +( renderActionSelection +) where + +import RPGEngine.Data (State (..), Action (..)) +import Graphics.Gloss + ( Picture, text, pictures, translate, scale, color ) +import Graphics.Gloss.Data.Picture (blank) +import RPGEngine.Data.Level (getActionText) +import RPGEngine.Config (uizoom, selectionColor, textColor) +import RPGEngine.Input.Core (ListSelector(selection)) +import RPGEngine.Render.Playing (renderPlaying) +import RPGEngine.Render.Core (overlay) + +------------------------------ Exported ------------------------------ + +renderActionSelection :: State -> Picture +renderActionSelection (ActionSelection list selector continue) = everything + where numberedTexts = zip [0::Int ..] $ map getActionText list + sel = selection selector + everything = pictures $ [renderPlaying continue, overlay] ++ map render numberedTexts + render (i, t) | i == sel = color selectionColor $ make (i, t) + | otherwise = color textColor $ make (i, t) + make (i, t) = scale uizoom uizoom $ translate 0 (offset i) $ text t + offset i = negate (250 * uizoom * fromIntegral i) +renderActionSelection _ = blank \ No newline at end of file diff --git a/lib/RPGEngine/Render/Core.hs b/lib/RPGEngine/Render/Core.hs new file mode 100644 index 0000000..b04c9fe --- /dev/null +++ b/lib/RPGEngine/Render/Core.hs @@ -0,0 +1,93 @@ +module RPGEngine.Render.Core +( Renderer + +, getRender +, setRenderPos +, overlay +) where + +import RPGEngine.Config + +import Data.Maybe +import Graphics.Gloss +import GHC.IO +import Graphics.Gloss.Juicy + +----------------------------- Constants ------------------------------ + +type Renderer a = a -> Picture + +unknownImage :: FilePath +unknownImage = "unknown.png" + +allEntities :: [(String, FilePath)] +allEntities = [ + ("player", "player.png"), + ("devil", "devil.png" ), + ("door", "door.png") + ] + +allEnvironment :: [(String, FilePath)] +allEnvironment = [ + ("void", "void.png"), + ("overlay", "overlay.png"), + ("tile", "tile.png"), + ("wall", "wall.png"), + ("entrance", "entrance.png"), + ("exit", "exit.png") + ] + +allItems :: [(String, FilePath)] +allItems = [ + ("dagger", "dagger.png"), + ("key", "key.png" ), + ("potion", "potion.png"), + ("sword", "sword.png" ) + ] + +allGui :: [(String, FilePath)] +allGui = [ + ("main", "main.png"), + ("health", "health.png") + ] + +-- Map of all renders +library :: [(String, Picture)] +library = unknown:entities ++ environment ++ gui ++ items + where unknown = ("unknown", renderPNG (assetsFolder ++ unknownImage)) + entities = map (\(f, s) -> (f, renderPNG (assetsFolder ++ "entities/" ++ s))) allEntities + environment = map (\(f, s) -> (f, renderPNG (assetsFolder ++ "environment/" ++ s))) allEnvironment + gui = map (\(f, s) -> (f, renderPNG (assetsFolder ++ "gui/" ++ s))) allGui + items = map (\(f, s) -> (f, renderPNG (assetsFolder ++ "items/" ++ s))) allItems + +------------------------------ Exported ------------------------------ + +-- Retrieve an image from the library. If the library does not contain +-- the requested image, a default is returned. +getRender :: String -> Picture +getRender id = get filtered + where filtered = filter ((== id) . fst) library + get [] = snd $ head library + get ((_, res):_) = res + +-- Move a picture by game coordinates +setRenderPos :: Int -> Int -> Picture -> Picture +setRenderPos x y = translate floatX floatY + where floatX = fromIntegral x * zoom * resolution + floatY = fromIntegral y * zoom * resolution + +overlay :: Picture +overlay = setRenderPos offX offY $ pictures voids + where voids = [setRenderPos x y void | x <- [0 .. width], y <- [0 .. height]] + void = getRender "overlay" + intZoom = round zoom :: Int + height = round $ 4320 / resolution / zoom + width = round $ 7680 / resolution / zoom + offX = negate (width `div` 2) + offY = negate (height `div` 2) + +---------------------------------------------------------------------- + +-- Turn a path to a .png file into a Picture. +renderPNG :: FilePath -> Picture +renderPNG path = scale zoom zoom $ fromJust $ unsafePerformIO $ loadJuicyPNG path \ No newline at end of file diff --git a/lib/RPGEngine/Render/LevelSelection.hs b/lib/RPGEngine/Render/LevelSelection.hs new file mode 100644 index 0000000..e952b17 --- /dev/null +++ b/lib/RPGEngine/Render/LevelSelection.hs @@ -0,0 +1,32 @@ +module RPGEngine.Render.LevelSelection +( renderLevelSelection +) where + +import RPGEngine.Render.Core (Renderer) + +import RPGEngine.Config (resolution, zoom, uizoom, textColor, selectionColor ) +import RPGEngine.Data (State (..)) + +import Graphics.Gloss ( pictures, color, text, translate, blank ) +import Graphics.Gloss.Data.Color (red) +import Graphics.Gloss.Data.Picture (scale) +import RPGEngine.Input.Core (ListSelector(..)) + +------------------------------ Exported ------------------------------ + +renderLevelSelection :: Renderer State +renderLevelSelection state = result + where result = renderLevelList state + +---------------------------------------------------------------------- + +renderLevelList :: Renderer State +renderLevelList (LevelSelection list selector) = everything + where everything = pictures $ map render entries + sel = selection selector + entries = zip [0::Int .. ] list + render (i, path) | i == sel = color selectionColor $ make (i, path) + | otherwise = color textColor $ make (i, path) + make (i, path) = scale uizoom uizoom $ translate 0 (offset i) $ text path + offset i = negate (250 * uizoom * fromIntegral i) +renderLevelList _ = blank \ No newline at end of file diff --git a/lib/RPGEngine/Render/Lose.hs b/lib/RPGEngine/Render/Lose.hs new file mode 100644 index 0000000..cd1cfad --- /dev/null +++ b/lib/RPGEngine/Render/Lose.hs @@ -0,0 +1,21 @@ +module RPGEngine.Render.Lose +( renderLose +) where +import RPGEngine.Render.Core (Renderer) + +import RPGEngine.Config (uizoom, textColor) +import RPGEngine.Data (State) + +import Graphics.Gloss (text, scale, color, translate) + +------------------------------ Constants ----------------------------- + +message :: String +message = "You lose! Press any key to retry." + +------------------------------ Exported ------------------------------ + +renderLose :: Renderer State +renderLose _ = scaled $ center $ color textColor $ text message + where scaled = scale uizoom uizoom + center = translate (-1200) 0 \ No newline at end of file diff --git a/lib/RPGEngine/Render/Menu.hs b/lib/RPGEngine/Render/Menu.hs new file mode 100644 index 0000000..e9251e4 --- /dev/null +++ b/lib/RPGEngine/Render/Menu.hs @@ -0,0 +1,24 @@ +module RPGEngine.Render.Menu +( renderMenu +) where + +import RPGEngine.Render.Core (Renderer, getRender) + +import RPGEngine.Config ( uizoom, textColor ) +import RPGEngine.Data (State) + +import Graphics.Gloss (text, scale, color, translate, pictures) + +------------------------------ Constants ----------------------------- + +message :: String +message = "[Press any key to start]" + +------------------------------ Exported ------------------------------ + +renderMenu :: Renderer State +renderMenu _ = pictures [main, pressAny] + where pressAny = scaled $ center $ color textColor $ text message + scaled = scale uizoom uizoom + center = translate (-800) (-320) + main = getRender "main" \ No newline at end of file diff --git a/lib/RPGEngine/Render/Paused.hs b/lib/RPGEngine/Render/Paused.hs new file mode 100644 index 0000000..6fa3d95 --- /dev/null +++ b/lib/RPGEngine/Render/Paused.hs @@ -0,0 +1,20 @@ +module RPGEngine.Render.Paused +( renderPaused +) where + +import RPGEngine.Render.Core (Renderer, overlay) + +import RPGEngine.Data (State(..)) +import RPGEngine.Render.Playing (renderPlaying) + +import Graphics.Gloss (pictures, white, color, Color(..), text, scale) + +------------------------------ Exported ------------------------------ + +renderPaused :: Renderer State +renderPaused state = pictures [playing, pause] + where playing = renderPlaying $ continue state + pause = pictures [ + overlay, + color white $ scale 0.5 0.5 $ text "[Press any key to continue]" + ] \ No newline at end of file diff --git a/lib/RPGEngine/Render/Playing.hs b/lib/RPGEngine/Render/Playing.hs new file mode 100644 index 0000000..9a661bd --- /dev/null +++ b/lib/RPGEngine/Render/Playing.hs @@ -0,0 +1,111 @@ +module RPGEngine.Render.Playing +( renderPlaying +) where + +import RPGEngine.Render.Core (Renderer, getRender, setRenderPos, overlay) + +import RPGEngine.Config (resolution, zoom, uizoom) +import RPGEngine.Data (State(..), Player (..), Game (..), Level (..), Layout, Physical (..), Item (..), Entity (..), HP) + +import Data.Maybe ( fromJust ) +import Graphics.Gloss ( pictures, Picture, translate, white ) +import Graphics.Gloss.Data.Picture ( blank, text, color, scale ) + +------------------------------ Exported ------------------------------ + +renderPlaying :: Renderer State +renderPlaying Playing { level = lvl, player = player } = pictures [ + renderLevel lvl, + renderPlayer player, + renderInventory player + ] +renderPlaying _ = blank + +------------------------------- Player ------------------------------- + +renderPlayer :: Renderer Player +renderPlayer Player{ position = (x, y), playerHp = playerHp } = move picture + where move = setRenderPos x y + picture = withHealthBar playerHp $ getRender "player" + +-- Center the player in the middle of the screen. +-- Not in use at the moment, might be useful later. +focusPlayer :: Game -> Picture -> Picture +focusPlayer Game{ state = Playing{ player = Player{ position = (x, y) }}} = move + where move = translate centerX centerY + centerX = resolution * zoom * fromIntegral (negate x) + centerY = resolution * zoom * fromIntegral (negate y) +focusPlayer _ = id + +------------------------------- Level -------------------------------- + +renderLevel :: Renderer Level +renderLevel Level{ layout = l, items = i, entities = e } = level + where level = pictures [void, layout, items, entities] + void = createVoid + layout = renderLayout l + items = renderItems i + entities = renderEntities e + +renderLayout :: Layout -> Picture +renderLayout strips = pictures [setRenderPos 0 (count - y) (renderStrip (strips !! y)) | y <- [0 .. count]] + where count = length strips - 1 + +renderStrip :: [Physical] -> Picture +renderStrip list = pictures physicals + where physicals = [setRenderPos x 0 (image (list !! x)) | x <- [0 .. count]] + image Void = getRender "void" + image Walkable = getRender "tile" + image Blocked = getRender "wall" + image Entrance = pictures [getRender "tile", getRender "entrance"] + image Exit = pictures [getRender "tile", getRender "exit"] + count = length list - 1 + +createVoid :: Picture +createVoid = setRenderPos offX offY $ pictures voids + where voids = [setRenderPos x y void | x <- [0 .. width], y <- [0 .. height]] + void = getRender "void" + intZoom = round zoom :: Int + height = round $ 4320 / resolution / zoom + width = round $ 7680 / resolution / zoom + offX = negate (width `div` 2) + offY = negate (height `div` 2) + +-------------------------- Items & Entities -------------------------- + +renderItems :: [Item] -> Picture +renderItems list = pictures $ map renderItem list + +renderItem :: Item -> Picture +renderItem Item{ itemId = id, itemX = x, itemY = y} = setRenderPos x y image + where image = getRender id + +renderEntities :: [Entity] -> Picture +renderEntities list = pictures $ map renderEntity list + +renderEntity :: Entity -> Picture +renderEntity Entity{ entityId = id, entityX = x, entityY = y, entityHp = hp} = setRenderPos x y image + where image = withHealthBar hp $ getRender id + +renderInventory :: Player -> Picture +renderInventory Player{ showInventory = False } = blank +renderInventory Player{ inventory = list } = pictures [overlay, title, items] + where title = translate 0 (offset (-1)) $ scale uizoom uizoom $ color white $ text "Inventory" + items = pictures $ zipWith (curry move) [0::Int ..] (map (getRender . itemId) list) + move (i, pic) = translate 0 (offset i) pic + offset i = negate (zoom * resolution * fromIntegral i) + +withHealthBar :: HP -> Picture -> Picture +withHealthBar Nothing renderedEntity = renderedEntity +withHealthBar (Just hp) renderedEntity = pictures [renderedEntity, positionedBar] + where positionedBar = scale smaller smaller $ translate left up renderedBar + renderedBar = pictures [heart, counter] + heart = scale by by $ getRender "health" + counter = translate right down $ scale scaler scaler $ color white $ text $ show hp + left = negate $ uizoom * resolution * scaler + right = uizoom * resolution * 0.05 + up = uizoom * resolution + down = negate $ resolution * uizoom * 0.15 + smaller = resolution * uizoom + by = uizoom * 0.1 + scaler = by * 0.5 \ No newline at end of file diff --git a/lib/RPGEngine/Render/Win.hs b/lib/RPGEngine/Render/Win.hs new file mode 100644 index 0000000..abaa095 --- /dev/null +++ b/lib/RPGEngine/Render/Win.hs @@ -0,0 +1,22 @@ +module RPGEngine.Render.Win +( renderWin +) where + +import RPGEngine.Render.Core (Renderer) + +import RPGEngine.Config (uizoom, textColor) +import RPGEngine.Data (State) + +import Graphics.Gloss (text, scale, color, translate) + +------------------------------ Constants ----------------------------- + +message :: String +message = "You win! Press any key to return to the menu." + +------------------------------ Exported ------------------------------ + +renderWin :: Renderer State +renderWin _ = scaled $ center $ color textColor $ text message + where scaled = scale uizoom uizoom + center = translate (-1500) 0 \ No newline at end of file diff --git a/lib/VoorbeeldModule.hs b/lib/VoorbeeldModule.hs deleted file mode 100644 index 63dfdf3..0000000 --- a/lib/VoorbeeldModule.hs +++ /dev/null @@ -1,10 +0,0 @@ -module VoorbeeldModule - ( hoi -- oplijsting van de publieke functies - als je deze lijst en de haakjes weglaat, wordt alles publiek - , hallo - ) where - -hoi :: String -hoi = "Hoi" - -hallo :: String -hallo = "Hallo" diff --git a/rpg-engine.cabal b/rpg-engine.cabal index 7d6f4ad..bfd1afa 100644 --- a/rpg-engine.cabal +++ b/rpg-engine.cabal @@ -1,13 +1,50 @@ name: rpg-engine version: 1.0.0 -author: Author name here +author: Tibo De Peuter cabal-version: 1.12 build-type: Simple library hs-source-dirs: lib - build-depends: base >= 4.7 && <5 - exposed-modules: VoorbeeldModule + build-depends: + base >= 4.7 && <5, + directory >= 1.3.6.0, + gloss >= 1.11 && < 1.14, gloss-juicy >= 0.2.3, + parsec >= 3.1.15.1 + exposed-modules: + RPGEngine + + RPGEngine.Config + + RPGEngine.Data + RPGEngine.Data.Default + RPGEngine.Data.Game + RPGEngine.Data.Level + + RPGEngine.Input + RPGEngine.Input.Core + RPGEngine.Input.ActionSelection + RPGEngine.Input.Menu + RPGEngine.Input.LevelSelection + RPGEngine.Input.Playing + RPGEngine.Input.Paused + RPGEngine.Input.Win + RPGEngine.Input.Lose + + RPGEngine.Parse + RPGEngine.Parse.Core + RPGEngine.Parse.TextToStructure + RPGEngine.Parse.StructureToGame + + RPGEngine.Render + RPGEngine.Render.Core + RPGEngine.Render.ActionSelection + RPGEngine.Render.Menu + RPGEngine.Render.LevelSelection + RPGEngine.Render.Playing + RPGEngine.Render.Paused + RPGEngine.Render.Win + RPGEngine.Render.Lose executable rpg-engine main-is: Main.hs @@ -17,7 +54,14 @@ executable rpg-engine test-suite rpg-engine-test type: exitcode-stdio-1.0 - main-is: VoorbeeldTest.hs + main-is: Spec.hs hs-source-dirs: test default-language: Haskell2010 - build-depends: base >=4.7 && <5, hspec <= 2.10.6, rpg-engine + build-depends: + base >=4.7 && <5, + rpg-engine, + hspec <= 2.10.6, hspec-discover, + parsec >= 3.1.15.1 + other-modules: + Parser.GameSpec + Parser.StructureSpec \ No newline at end of file diff --git a/src/Main.hs b/src/Main.hs index 55d35ac..0e997a8 100644 --- a/src/Main.hs +++ b/src/Main.hs @@ -1,4 +1,16 @@ -import VoorbeeldModule (hoi) +import RPGEngine ( playRPGEngine ) + +----------------------------- Constants ------------------------------ + +-- Title of the game +title :: String +title = "RPG Engine" + +-- Framerate of the game +fps :: Int +fps = 60 + +---------------------------------------------------------------------- main :: IO () -main = putStrLn hoi +main = playRPGEngine title fps diff --git a/stack.yaml b/stack.yaml index 2c311ed..2f59104 100644 --- a/stack.yaml +++ b/stack.yaml @@ -35,12 +35,14 @@ packages: # These entries can reference officially published versions as well as # forks / in-progress versions pinned to a git hash. For example: # -# extra-deps: +extra-deps: # - acme-missiles-0.3 # - git: https://github.com/commercialhaskell/stack.git # commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a # # extra-deps: [] +- gloss-juicy-0.2.3@sha256:0c3bca95237cbf91f8b3b1936a0661f1e0457acd80502276d54d6c5210f88b25,1618 +- parsec-3.1.15.1@sha256:8c7a36aaadff12a38817fc3c4ff6c87e3352cffd1a58df640de7ed7a97ad8fa3,4601 # Override default flag values for local packages and extra-deps # flags: {} @@ -65,3 +67,5 @@ packages: # # Allow a newer minor version of GHC than the snapshot specifies # compiler-check: newer-minor + +custom-preprocessor-extensions: [] \ No newline at end of file diff --git a/test/Parser/GameSpec.hs b/test/Parser/GameSpec.hs new file mode 100644 index 0000000..13ad9e6 --- /dev/null +++ b/test/Parser/GameSpec.hs @@ -0,0 +1,187 @@ +module Parser.GameSpec where + +import Test.Hspec + +import RPGEngine.Data +import RPGEngine.Parse.Core +import RPGEngine.Parse.TextToStructure +import RPGEngine.Parse.StructureToGame + +spec :: Spec +spec = do + describe "Game" $ do + it "Simple game" $ do + pendingWith "There is a weird bug that caused this to go in an infinite loop. Fix later." + let input = "player: {\n hp: 50,\n inventory: []\n}\n\nlevels: [\n {\n layout: {\n | * * * * * *\n | * s . . e *\n | * * * * * *\n },\n \n items: [],\n\n entities: []\n\n\n }\n]" + correct = Game { + state = Playing { + levels = [], + count = 0, + level = Level { + RPGEngine.Data.layout = [], + index = [], + items = [], + entities = [] + }, + player = Player { + playerHp = Just 50, + inventory = [], + position = (0, 0), + showHp = True, + showInventory = False + }, + restart = Menu + } + } + (Right struct) = parseWith gameFile input + structureToGame struct `shouldBe` correct + it "More complex game" $ do + pendingWith "Still need to write this" + it "Game with multiple levels" $ do + pendingWith "Still need to write this" + + describe "Player" $ do + it "cannot die" $ do + let input = "player: { hp: infinite, inventory: [] }" + correct = Player { + playerHp = Prelude.Nothing, + inventory = [], + position = (0, 0), + showHp = True, + showInventory = False + } + Right (Entry (Tag "player") struct) = parseWith structure input + structureToPlayer struct `shouldBe` correct + + it "without inventory" $ do + let input = "player: { hp: 50, inventory: [] }" + correct = Player { + playerHp = Just 50, + inventory = [], + position = (0, 0), + showHp = True, + showInventory = False + } + Right (Entry (Tag "player") struct) = parseWith structure input + structureToPlayer struct `shouldBe` correct + + it "with inventory" $ do + let input = "player: { hp: 50, inventory: [ { id: \"dagger\", x: 0, y: 0, name: \"Dolk\", description: \"Basis schade tegen monsters\", useTimes: infinite, value: 10, actions: {} } ] }" + correct = Player { + playerHp = Just 50, + inventory = [ + Item { + itemId = "dagger", + itemX = 0, + itemY = 0, + itemName = "Dolk", + itemDescription = "Basis schade tegen monsters", + itemActions = [], + itemValue = Just 10, + useTimes = Prelude.Nothing + } + ], + position = (0, 0), + showHp = True, + showInventory = False + } + Right (Entry (Tag "player") struct) = parseWith structure input + structureToPlayer struct `shouldBe` correct + + describe "Items" $ do + it "simple" $ do + let input = "{ id: \"dagger\", x: 0, y: 0, name: \"Dagger\", description: \"Basic dagger you found somewhere\", useTimes: infinite, value: 10, actions: {} }" + correct = Item { + itemId = "dagger", + itemX = 0, + itemY = 0, + itemName = "Dagger", + itemDescription = "Basic dagger you found somewhere", + itemValue = Just 10, + itemActions = [], + useTimes = Prelude.Nothing + } + Right struct = parseWith structure input + structureToItem struct `shouldBe` correct + + it "with actions" $ do + let input = "{ id: \"key\", x: 3, y: 1, name: \"Doorkey\", description: \"Unlocks a secret door\", useTimes: 1, value: 0, actions: { [not(inventoryFull())] retrieveItem(key), [] leave() } }" + correct = Item { + itemId = "key", + itemX = 3, + itemY = 1, + itemName = "Doorkey", + itemDescription = "Unlocks a secret door", + itemActions = [ + ([], Leave), + ([Not InventoryFull], RetrieveItem "key") + ], + itemValue = Just 0, + useTimes = Just 1 + } + Right struct = parseWith structure input + structureToItem struct `shouldBe` correct + + describe "Actions" $ do + it "no conditions" $ do + let input = "{[] leave()}" + correct = [([], Leave)] + Right struct = parseWith structure input + structureToActions struct `shouldBe` correct + + it "single condition" $ do + let input = "{ [inventoryFull()] useItem(itemId)}" + correct = [([InventoryFull], UseItem "itemId")] + Right struct = parseWith structure input + structureToActions struct `shouldBe` correct + + it "multiple conditions" $ do + let input = "{ [not(inventoryFull()), inventoryContains(itemId)] increasePlayerHp(itemId)}" + correct = [([Not InventoryFull, InventoryContains "itemId"], IncreasePlayerHp "itemId")] + Right struct = parseWith structure input + structureToActions struct `shouldBe` correct + + it "DecreaseHp(entityid, itemid)" $ do + let input = "{ [] decreaseHp(devil, sword) }" + correct = [([], DecreaseHp "devil" "sword")] + Right struct = parseWith structure input + structureToActions struct `shouldBe` correct + + describe "Entities" $ do + it "Simple entity" $ do + pendingWith "still need to write this" + + describe "Level" $ do + it "Simple layout" $ do + let input = "{ layout: { | * * * * * *\n| * s . . e *\n| * * * * * *\n }, items: [], entities: [] }" + correct = Level { + RPGEngine.Data.layout = [ + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked], + [Blocked, Entrance, Walkable, Walkable, Exit, Blocked], + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked] + ], + index = [ + (0, 0, Blocked), + (1, 0, Blocked), + (2, 0, Blocked), + (3, 0, Blocked), + (4, 0, Blocked), + (5, 0, Blocked), + (0, 1, Blocked), + (1, 1, Entrance), + (2, 1, Walkable), + (3, 1, Walkable), + (4, 1, Exit), + (5, 1, Blocked), + (0, 2, Blocked), + (1, 2, Blocked), + (2, 2, Blocked), + (3, 2, Blocked), + (4, 2, Blocked), + (5, 2, Blocked) + ], + items = [], + entities = [] + } + Right struct = parseWith structure input + structureToLevel struct `shouldBe` correct \ No newline at end of file diff --git a/test/Parser/StructureSpec.hs b/test/Parser/StructureSpec.hs new file mode 100644 index 0000000..e4c34f5 --- /dev/null +++ b/test/Parser/StructureSpec.hs @@ -0,0 +1,379 @@ +module Parser.StructureSpec where + +import Test.Hspec + +import RPGEngine.Data +import RPGEngine.Parse.Core +import RPGEngine.Parse.TextToStructure +import Text.Parsec.String (parseFromFile) +import GHC.IO (unsafePerformIO) + +spec :: Spec +spec = do + describe "StructureElement" $ do + it "can parse blocks" $ do + let input = "{}" + correct = Right $ Block [] + parseWith structure input `shouldBe` correct + + let input = "{{}}" + correct = Right $ Block [Block []] + parseWith structure input `shouldBe` correct + + let input = "{{}, {}}" + correct = Right $ Block [Block [], Block []] + parseWith structure input `shouldBe` correct + + let input = "{ id: 1 }" + correct = Right (Block [ + Entry (Tag "id") $ Regular $ Integer 1 + ], "") + parseWithRest structure input `shouldBe` correct + + let input = "{ id: \"key\", x: 3, y: 1}" + correct = Right $ Block [ + Entry (Tag "id") $ Regular $ String "key", + Entry (Tag "x") $ Regular $ Integer 3, + Entry (Tag "y") $ Regular $ Integer 1 + ] + parseWith structure input `shouldBe` correct + + let input = "actions: { [not(inventoryFull())] retrieveItem(key), [] leave()}" + correct = Right (Entry (Tag "actions") $ Block [ + Entry (ConditionList [Not InventoryFull]) $ Regular $ Action $ RetrieveItem "key", + Entry (ConditionList []) $ Regular $ Action Leave + ], "") + parseWithRest structure input `shouldBe` correct + + let input = "entities: [ { id: \"door\", x: 4, name:\"Secret door\", description: \"This secret door can only be opened with a key\", direction: left, y: 1}]" + correct = Right (Entry (Tag "entities") $ Block [ Block [ + Entry (Tag "id") $ Regular $ String "door", + Entry (Tag "x") $ Regular $ Integer 4, + Entry (Tag "name") $ Regular $ String "Secret door", + Entry (Tag "description") $ Regular $ String "This secret door can only be opened with a key", + Entry (Tag "direction") $ Regular $ Direction West, + Entry (Tag "y") $ Regular $ Integer 1 + ]], "") + parseWithRest structure input `shouldBe` correct + + let input = "entities: [ { id: \"door\", x: 4, y: 1, name:\"Secret door\", description: \"This secret door can only be opened with a key\", actions: { [inventoryContains(key)] useItem(key), [] leave() } } ]" + correct = Right (Entry (Tag "entities") $ Block [ Block [ + Entry (Tag "id") $ Regular $ String "door", + Entry (Tag "x") $ Regular $ Integer 4, + Entry (Tag "y") $ Regular $ Integer 1, + Entry (Tag "name") $ Regular $ String "Secret door", + Entry (Tag "description") $ Regular $ String "This secret door can only be opened with a key", + Entry (Tag "actions") $ Block [ + Entry (ConditionList [InventoryContains "key"]) $ Regular $ Action $ UseItem "key", + Entry (ConditionList []) $ Regular $ Action Leave + ] + ]], "") + parseWithRest structure input `shouldBe` correct + + let input = "entities: [ { id: \"door\", x: 4, y: 1, name:\"Secret door\", description: \"This secret door can only be opened with a key\", direction: left, actions: { [inventoryContains(key)] useItem(key), [] leave() } } ]" + correct = Right (Entry (Tag "entities") $ Block [ Block [ + Entry (Tag "id") $ Regular $ String "door", + Entry (Tag "x") $ Regular $ Integer 4, + Entry (Tag "y") $ Regular $ Integer 1, + Entry (Tag "name") $ Regular $ String "Secret door", + Entry (Tag "description") $ Regular $ String "This secret door can only be opened with a key", + Entry (Tag "direction") $ Regular $ Direction West, + Entry (Tag "actions") $ Block [ + Entry (ConditionList [InventoryContains "key"]) $ Regular $ Action $ UseItem "key", + Entry (ConditionList []) $ Regular $ Action Leave + ] + ]], "") + parseWithRest structure input `shouldBe` correct + + it "combines actions and direction" $ do + let input = "entities: [ { direction: left, actions: { [inventoryContains(key)] useItem(key), [] leave() } } ]" + correct = Right (Entry (Tag "entities") $ Block [ Block [ + Entry (Tag "direction") $ Regular $ Direction West, + Entry (Tag "actions") $ Block [ + Entry (ConditionList [InventoryContains "key"]) $ Regular $ Action $ UseItem "key", + Entry (ConditionList []) $ Regular $ Action Leave + ] + ]], "") + parseWithRest structure input `shouldBe` correct + + it "can parse entries" $ do + let input = "id: \"dagger\"" + correct = Right $ Entry (Tag "id") $ Regular $ String "dagger" + parseWith entry input `shouldBe` correct + + let input = "x: 0" + correct = Right $ Entry (Tag "x") $ Regular $ Integer 0 + parseWith entry input `shouldBe` correct + + let input = "useTimes: infinite" + correct = Right $ Entry (Tag "useTimes") $ Regular Infinite + parseWith entry input `shouldBe` correct + + let input = "direction: up" + correct = Right $ Entry (Tag "direction") $ Regular $ Direction North + parseWith entry input `shouldBe` correct + + let input = "actions: { [not(inventoryFull())] retrieveItem(key), [] leave()}" + correct = Right (Entry (Tag "actions") $ Block [ + Entry (ConditionList [Not InventoryFull]) $ Regular $ Action $ RetrieveItem "key", + Entry (ConditionList []) $ Regular $ Action Leave + ], "") + parseWithRest structure input `shouldBe` correct + + it "can parse regulars" $ do + let input = "this is a string" + correct = Right $ Regular $ String input + parseWith regular ("\"" ++ input ++ "\"") `shouldBe` correct + + let correct = Right $ Regular $ Integer 1 + parseWith regular "1" `shouldBe` correct + + let correct = Right $ Regular Infinite + parseWith regular "infinite" `shouldBe` correct + + let wrong = Right $ Regular Infinite + parseWith regular "infinitee" `shouldNotBe` wrong + + let input = "leave()" + correct = Right $ Regular $ Action Leave + parseWith regular input `shouldBe` correct + + let input = "retrieveItem(firstId)" + correct = Right $ Regular $ Action $ RetrieveItem "firstId" + parseWith regular input `shouldBe` correct + + let input = "useItem(secondId)" + correct = Right $ Regular $ Action $ UseItem "secondId" + parseWith regular input `shouldBe` correct + + let input = "decreaseHp(entityId, objectId)" + correct = Right $ Regular $ Action $ DecreaseHp "entityId" "objectId" + parseWith regular input `shouldBe` correct + + let input = "decreaseHp(entityId,objectId)" + correct = Right $ Regular $ Action $ DecreaseHp "entityId" "objectId" + parseWith regular input `shouldBe` correct + + let input = "increasePlayerHp(objectId)" + correct = Right $ Regular $ Action $ IncreasePlayerHp "objectId" + parseWith regular input `shouldBe` correct + + let input = "up" + correct = Right $ Regular $ Direction North + parseWith regular input `shouldBe` correct + + let input = "right" + correct = Right $ Regular $ Direction East + parseWith regular input `shouldBe` correct + + let input = "down" + correct = Right $ Regular $ Direction South + parseWith regular input `shouldBe` correct + + let input = "left" + correct = Right $ Regular $ Direction West + parseWith regular input `shouldBe` correct + + describe "Key" $ do + it "can parse tags" $ do + let input = "simpletag" + correct = Right $ Tag "simpletag" + parseWith tag input `shouldBe` correct + + it "can parse conditionlists" $ do + let input = "[not(inventoryFull())]" + correct = Right (ConditionList [Not InventoryFull], "") + parseWithRest conditionList input `shouldBe` correct + + let input = "[inventoryFull(), inventoryContains(itemId)]" + correct = Right (ConditionList [ + InventoryFull, + InventoryContains "itemId" + ], "") + parseWithRest conditionList input `shouldBe` correct + + let input = "[]" + correct = Right $ ConditionList [] + parseWith conditionList input `shouldBe` correct + + it "can parse conditions" $ do + let input = "inventoryFull()" + correct = Right (Condition InventoryFull, "") + parseWithRest condition input `shouldBe` correct + + let input = "inventoryContains(itemId)" + correct = Right (Condition $ InventoryContains "itemId", "") + parseWithRest condition input `shouldBe` correct + + let input = "not(inventoryFull())" + correct = Right (Condition $ Not InventoryFull, "") + parseWithRest condition input `shouldBe` correct + + let input = "not(inventoryContains(itemId))" + correct = Right (Condition $ Not $ InventoryContains "itemId", "") + parseWithRest condition input `shouldBe` correct + + describe "Value" $ do + it "can parse strings" $ do + let input = "dit is een string" + correct = Right $ String input + parseWith string ("\"" ++ input ++ "\"") `shouldBe` correct + + it "can parse integers" $ do + let correct = Right $ Integer 1 + parseWith integer "1" `shouldBe` correct + + it "can parse infinite" $ do + let correct = Right Infinite + parseWith infinite "infinite" `shouldBe` correct + + let wrong = Right Infinite + parseWith infinite "infinitee" `shouldNotBe` wrong + + it "can parse actions" $ do + let input = "leave()" + correct = Right $ Action Leave + parseWith action input `shouldBe` correct + + let input = "retrieveItem(firstId)" + correct = Right $ Action $ RetrieveItem "firstId" + parseWith action input `shouldBe` correct + + let input = "useItem(secondId)" + correct = Right $ Action $ UseItem "secondId" + parseWith action input `shouldBe` correct + + let input = "decreaseHp(entityId,objectId)" + correct = Right $ Action $ DecreaseHp "entityId" "objectId" + parseWith action input `shouldBe` correct + + let input = "increasePlayerHp(objectId)" + correct = Right $ Action $ IncreasePlayerHp "objectId" + parseWith action input `shouldBe` correct + + it "can parse directions" $ do + let input = "up" + correct = Right $ Direction North + parseWith RPGEngine.Parse.TextToStructure.direction input `shouldBe` correct + + let input = "right" + correct = Right $ Direction East + parseWith RPGEngine.Parse.TextToStructure.direction input `shouldBe` correct + + let input = "down" + correct = Right $ Direction South + parseWith RPGEngine.Parse.TextToStructure.direction input `shouldBe` correct + + let input = "left" + correct = Right $ Direction West + parseWith RPGEngine.Parse.TextToStructure.direction input `shouldBe` correct + + it "can parse layouts" $ do + let input = "{ | * * * * * * * *\n | * s . . . . e *\n | * * * * * * * *\n }" + correct = Right $ Layout [ + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked, Blocked, Blocked], + [Blocked, Entrance, Walkable, Walkable, Walkable, Walkable, Exit, Blocked], + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked, Blocked, Blocked] + ] + parseWith value input `shouldBe` correct + + let input = "layout: { | * * * * * * * *\n | * s . . . . e *\n | * * * * * * * *\n }" + correct = Right $ Entry (Tag "layout") $ Regular $ Layout [ + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked, Blocked, Blocked], + [Blocked, Entrance, Walkable, Walkable, Walkable, Walkable, Exit, Blocked], + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked, Blocked, Blocked] + ] + parseWith structure input `shouldBe` correct + + describe "Brackets" $ do + it "matches closing <" $ do + let input = '<' + correct = '>' + getMatchingClosingBracket input `shouldBe` correct + + it "matches closing (" $ do + let input = '(' + correct = ')' + getMatchingClosingBracket input `shouldBe` correct + + it "matches closing {" $ do + let input = '{' + correct = '}' + getMatchingClosingBracket input `shouldBe` correct + + it "matches closing [" $ do + let input = '[' + correct = ']' + getMatchingClosingBracket input `shouldBe` correct + + describe "Full game file" $ do + it "single level" $ do + let input = "player: {\n hp: 50,\n inventory: []\n}\n\nlevels: [\n {\n layout: {\n | * * * * * *\n | * s . . e *\n | * * * * * *\n },\n \n items: [],\n\n entities: []\n\n\n }\n]" + correct = Right [ + Entry (Tag "player") $ Block [ + Entry (Tag "hp") $ Regular $ Integer 50, + Entry (Tag "inventory") $ Block [] + ], + Entry (Tag "levels") $ Block [ Block [ + Entry (Tag "layout") $ Regular $ Layout [ + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked], + [Blocked, Entrance, Walkable, Walkable, Exit, Blocked], + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked] + ], + Entry (Tag "items") $ Block [], + Entry (Tag "entities") $ Block [] + ]] + ] + parseWith gameFile input `shouldBe` correct + + it "two levels" $ do + let input = "player: {\n hp: 50,\n inventory: []\n}\n\nlevels: [\n {\n layout: {\n | * * * * * *\n | * s . . e *\n | * * * * * *\n },\n \n items: [],\n\n entities: []\n },\n {\n layout: {\n | * * *\n | * e *\n | * . *\n | * . *\n | * . *\n | * . *\n | * s *\n | * * *\n },\n\n items: [],\n\n entities: []\n }\n]" + correct = Right [ + Entry (Tag "player") $ Block [ + Entry (Tag "hp") $ Regular $ Integer 50, + Entry (Tag "inventory") $ Block [] + ], + Entry (Tag "levels") $ Block [ + Block [ + Entry (Tag "layout") $ Regular $ Layout [ + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked], + [Blocked, Entrance, Walkable, Walkable, Exit, Blocked], + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked] + ], + Entry (Tag "items") $ Block [], + Entry (Tag "entities") $ Block [] + ], Block [ + Entry (Tag "layout") $ Regular $ Layout [ + [Blocked,Blocked,Blocked], + [Blocked,Exit,Blocked], + [Blocked,Walkable,Blocked], + [Blocked,Walkable,Blocked], + [Blocked,Walkable,Blocked], + [Blocked,Walkable,Blocked], + [Blocked,Entrance,Blocked], + [Blocked,Blocked,Blocked] + ], + Entry (Tag "items") $ Block [], + Entry (Tag "entities") $ Block [] + ] + ] + ] + parseWith gameFile input `shouldBe` correct + + it "from file" $ do + let correct = Right [ + Entry (Tag "player") $ Block [ + Entry (Tag "hp") $ Regular $ Integer 50, + Entry (Tag "inventory") $ Block [] + ], + Entry (Tag "levels") $ Block [ Block [ + Entry (Tag "layout") $ Regular $ Layout [ + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked], + [Blocked, Entrance, Walkable, Walkable, Exit, Blocked], + [Blocked, Blocked, Blocked, Blocked, Blocked, Blocked] + ], + Entry (Tag "items") $ Block [], + Entry (Tag "entities") $ Block [] + ]] + ] + unsafePerformIO (parseFromFile gameFile "levels/level1.txt") `shouldBe` correct \ No newline at end of file diff --git a/test/Spec.hs b/test/Spec.hs new file mode 100644 index 0000000..bf4362e --- /dev/null +++ b/test/Spec.hs @@ -0,0 +1,18 @@ +{-# OPTIONS_GHC -F -pgmF hspec-discover #-} + +-------------------------- How to use Hspec -------------------------- + +-- If a test has not yet been written: +-- Use `pending` or `pendingWith`. +-- it "Description" $ do +-- pendingWith "Reason" + +-- Temporarily disable running a test: +-- Replace `it` with `xit` +-- xit "Description" $ do ... + +-- Temporarily only run a specific test: +-- Put `focus` in front. +-- it "Description" $ do ... +-- becomes +-- focus $ it "Description" $ do ... \ No newline at end of file diff --git a/test/VoorbeeldTest.hs b/test/VoorbeeldTest.hs deleted file mode 100644 index 2b94edb..0000000 --- a/test/VoorbeeldTest.hs +++ /dev/null @@ -1,11 +0,0 @@ -import Test.Hspec - -import VoorbeeldModule (hoi, hallo) - -main :: IO () -main = hspec $ do - it "Returns correct string for hoi" $ do - hoi `shouldBe` "Hoi" - - it "Returns correct string for hallo" $ do - hallo `shouldBe` "Hallo" diff --git a/verslag.pdf b/verslag.pdf new file mode 100644 index 0000000..03999f6 Binary files /dev/null and b/verslag.pdf differ