commit 870a4c5ad8030e6d500203cb20dc43e27061f18b Author: Gordon Pedersen Date: Wed Mar 8 14:06:29 2023 +1100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cedd289 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +settings-debug.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..66efa37 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Scale + +![repeating red dragon scale pixel art](https://user-images.githubusercontent.com/928367/204090457-0d096cbe-21cc-4753-9c63-f7786d165cfa.png) + +**Simple DragonRuby Game Toolkit Game Starter Template** + +Status: usable but not yet stable! This is pre-v1 software. + +Quickly start a new DragonRuby Game Toolkit game with useful conventions and helpful extensions. + +Looking for a simpler version? Check out [the `simple` release](https://github.com/DragonRidersUnite/scale/releases/tag/simple) that's just `app/main.rb` with some constants and helper methods. + +[Check out the CHANGELOG for the summary of recent changes!](https://github.com/DragonRidersUnite/scale/wiki/CHANGELOG) + +## Bugs / Features + +Last tested against DragonRuby Game Toolkit v4.3. + +- Functional approach to the code, namespaced in modules +- Use the DragonRuby GTK methods and data structures you know and love +- Driven by `args.state` +- Menus and pause screen +- Sensible default controls +- Defined location for where to put scenes +- Settings that persist to disk +- Displays framerate in the upper-right hand corner of the game when running in development mode +- `#debug?` helper to easily check if the game is running in development mode; useful for custom commands +- `#mobile?` to easily check when on mobile and M to simulate mobile +- Reload all sprites in development using the `i` key +- Reset the game with `r` key, calls `$gtk.reset` +- Put all debug-only code in `#debug_tick` +- `#init` method that gets run once on game boot +- `#version` to get the version of your game +- Constants for various values and enums: `FPS`, `BLEND_*`, `ALIGN_*` +- Tests for the methods! +- See more in [SCALE_DOCS.md](./SCALE_DOCS.md) + +## Use It + +There are two main ways you can use the Scale template for your games. + +[đŸ“ē Video demo showing how to get started](https://www.youtube.com/watch?v=eek3a3aO-zo) + +### Download the Zip + +The fastest way to get started is to download the template zip file and put it into your unzipped DragonRuby Game Toolkit folder. + +1. Download and unzip the DragonRuby Game Toolkit engine zip +2. Delete the `mygame` directory +3. [Download Scale](https://github.com/DragonRidersUnite/scale/archive/refs/heads/main.zip) +4. Unzip the `scale-main.zip` +5. Move the `scale-main` folder into the DRGTK folder +6. Rename `scale-main` to `mygame` +7. Start DragonRuby, and make an awesome game! + +### Use GitHub's Template System + +If you're going to track your game with Git and use GitHub, the baked-in template system will get you going quickly. + +1. View the project on GitHub: https://github.com/DragonRidersUnite/scale +2. Click "Use this template" +3. Click "Create a new repository" +4. Fill out the details and create the repository +5. Unzip the DragonRuby Game Toolkit engine zip +6. Delete the `mygame` directory +7. Clone your new repository into the DRGTK engine folder with the folder name `mygame`, example: `git clone git@github.com:USERNAME/REPO.git mygame` +7. Start DragonRuby, and make an awesome game! + +### Updating + +Because Scale is a template with all of its source as part of your game, updating the framework's source code in your game isn't an easy thing to do. + +I generally would say: don't worry about it! Take ownership over the code in your game and change what Scale provides you without concern. When it comes time to start your next game, Scale will be updated and improved. + +But if you do find yourself wanting to keep it updated, [watch the GitHub repo](https://github.com/DragonRidersUnite/scale) for releases. You could pull in just the changes you want. Or you could set an upstream in your repo to the template and merge the changes in. + +## Documentation + +Every game that uses Scale comes with the [SCALE_DOCS.md](./SCALE_DOCS.md) file. Read through that document to find a quickstart guide and information about useful methods. + +## Release Approach & Versioning + +Code on the `main` branch is intended to be stable because Scale is a template. When significant changes have been made, a tag and release are created. This allows progress to be tracked and previous versions to be easily downloaded. + +Scale uses a simplified major.minor versioning scheme. Major version bumps means there are breaking changes to the API (the methods and structure). Minor bumps mean non-breaking additions and fixes. + +## Template License + +The template source code falls under the [Unlicense](https://unlicense.org/), meaning it is dedicated to the public domain and you are free to do with it what you want. + +## Contribute + +Conributions are welcome! + +Open an issue or submit PRs if you notice something isn't working. + +If you find yourself adding the same files, methods, constants, etc. to your DRGTK games, submit a PR to add it to Scale. + +--- + +[Clear out what's above in this README out and add your own details for making your game!] + +## Debug Shortcuts + +- 0 — display debug details (ex: framerate) +- i — reload sprites from disk +- r — reset the entire game state +- m — toggle mobile simulation diff --git a/SCALE_DOCS.md b/SCALE_DOCS.md new file mode 100644 index 0000000..92d4dfc --- /dev/null +++ b/SCALE_DOCS.md @@ -0,0 +1,298 @@ +# Scale Docs + +Everything you need to get started with using [Scale](https://github.com/DragonRidersUnite/scale). + +[Check out the CHANGELOG for the summary of recent changes!](https://github.com/DragonRidersUnite/scale/wiki/CHANGELOG) + +## Quickstart + +Once you've got your game from the Scale template dropped into your `mygame` directory in your DragonRuby GTK game, here's what you'll want to do next. + +### Configure + +Start by specifying your name and game title in `metadata/game_metadata.txt`. The following properties are useful: + +- `devid` — your itch.io username, no spaces +- `devtitle` — your name, spaces are okay +- `gameid` — the URL slug of your game on itch.io that you must already create, no spaces +- `gametitle` — the name of your game, spaces are okay! + +Now when you start the DragonRuby engine for your game, you'll see what you've set. + +### Lay of the Land + +Because all of Scale is included with your new game, you can just browse through the source code to get the lay of the land. You'll notice in the `app/` directory a bunch of files! It's okay, don't worry. It's pretty sensibly organized, and there are plenty of comments. + +Explore, be curious, and change things to suit your needs. + +Scale is structured to follow the default DragonRuby GTK way of working with methods and `args.state`. That drives how much of it works. Most of Scale is organized into modules so that there aren't method name conflicts in the global space. + +### Gameplay + +Open up `app/scenes/gameplay.rb`, as that's where you'll actually add the fun parts of your game. `Scene.tick_gameplay` is just like the regular ole `#tick` in DragonRuby GTK. It takes args, and you go from there. It does include some handy stuff though, but just let that be. + +You'll code your game just as you normally would. If you're new to DragonRuby GTK, [check out the book](https://book.dragonriders.community/) to get started. + +### Input + +Scale comes with some helper methods to make checking for input from multiple sources (multiple keyboard keys and gamepad buttons) easy. That code lives in `app/input.rb`, and out of the box, you get: + +- `primary_down?` — check if J, Z, Space, or Gamepad A button was just pressed +- `primary_down_or_held?` — check if J, Z, Space, or Gamepad A button was just pressed or is being held +- `secondary_down?` — check if K, X, Backspace, or Gamepad B button was just pressed +- `secondary_down_or_held?` — check if K, X, Backspace, or Gamepad B button was just pressed or is being held +- `pause_down?` — check if Escape, P, or Gamepad Start button was just pressed, which pauses the game + +You could add more methods for various inputs in your game. Maybe there's a secondary button you use. Or any number of them! These input methods make it really easy to support various input methods without worrying about the keys/buttons that are being pressed. Want to add a new key for a given layout? Just change it. + +Here's an example of how you'd use it if you wanted to have your player swing their sword: + +``` ruby +if primary_down?(args.inputs) + swing_sword(args, args.state.player) +end +``` + +### Text + +You'll very likely need to display text in your game to show health or the player's level or to give instruction. It's a fundamental part of game user interfaces. `app/text.rb` contains a few helpful constructs to make working with text easier. + +First is the `TEXT` Hash constant. It contains key values of the text to display in your game. This is preferable to putting strings everywhere because you can more easily review the text for typos and eventually translate your game much more easily. It takes a little time to get used to putting your text here first, but it'll become second nature quick enough. You'll see text for what already exists in Scale. + +`#text` is a method you call to access the values in `TEXT`. Example: + +``` ruby +text(:start) +``` + +returns `"Start"`. + +Then we've got the `#label` method. It contains sensible defaults for outputting labels quickly with DragonRuby GTK. It's got a friendlier API than the default data structure in DRGTK. There are multiple ways to use it. + +In its simplest form, you can pass in a symbol key for `TEXT` and specify the position to render it: + +``` ruby +args.outputs.labels << label(:start, x: 100, y: 100) +``` + +You can also pass in a value, like a number that will display: + +``` ruby +args.outputs.labels << label(args.state.player.level, x: 100, y: 100) +``` + +Additional parameters are supported too, all the way up to something like this: + +``` ruby +args.outputs.labels << label( + :restart, x: 100, y: 100, align: ALIGN_CENTER, + size: SIZE_SM, color: RED, font: FONT_ITALIC +) +``` + +In `app/text.rb` you'll find the constants for text size and the various fonts. Change them as you'd like! + +### Sprites + +A lot of games need sprites, and when making them, it's so helpful to see them in the context of your game. DragonRuby GTK allows you to reload sprites in your game, but you need to specify which ones to reload. To get around this, in Scale you track your sprites in `app/sprite.rb`'s `SPRITES` hash. It's a little tedious, but by having a dictionary of sprites, they can easily be reloaded with i when developing your game. + +Get the path for your sprite with `Sprite.for(:key)`: + +``` ruby +args.outputs.sprites << { + x: 100, y: 100, w: 32, h: 32, path: Sprite.for(:player), +} +``` + +### Scenes + +The `app/scenes/` directory is where different scenes of your game go. There are scenes for Paused, Main Menu, Settings, and Gameplay. Scale uses `args.state.scene` to know which scene the game is in. + +You can switch scenes by using `Scene.switch(args, :gameplay)` where the parameter is the symbol of the scene you want. It must match the name of your scene method after the `tick_` prefix. So if you wanted a `:credits` scene, you'd define it like this in `app/scenes/credits.rb`: + +``` ruby +module Scene + class << self + def tick_credits(args) + args.outputs.labels << label(:credits, x: args.grid.w / 2, y: args.grid.top - 200, align: ALIGN_CENTER, size: SIZE_LG, font: FONT_BOLD) + end + end +end +``` + +(Don't forget to require it in `app/main.rb` too!) + +Then when you want to show the Credits scene, call `Scene.switch(args, :credits)`. + +### Collision Detection + +Scale provides you with a collision method that executes the passed in block for each element that intersects: + +``` ruby +collide(tiles, bullets) do |tile, bullet| + bullet.dead = true +end +``` + +It takes arrays or single objects as parameters. + +### Menus + +You'll find examples of menus throughout the Scale codebase for common scenes like Settings and the Main Menu. + +Menus support cursor-based navigation or buttons for mouse/touch. + +How they work is pretty simple, you just call `Menu.tick` and pass in an array of menu options. + +``` ruby +options = [ + { + key: :attack, + on_select: -> (args) do + # do the attack + end + }, + { + key: :defend, + on_select: -> (args) do + # defend + end + }, +] +Menu.tick(args, :your_menu_name, options) +``` + +The options array is just a collection of hashes with a key and an `on_select` lambda that gets called with `args` for doing anything in your game that you need to have happen. + +`Menu.tick` also supports an optional `menu_y` for positioning the menu. + +### Colors + +A simple color palette is provided in `app/constants.rb`. Most of the primary colors are present, along with some variants. They return Hashes with `r`, `g`, and `b` keys. If you have a sprite that you want to change red, you'd do this: + +``` ruby +args.outputs.sprites << { + x: 100, y: 100, w: 32, h: 32, path: Sprite.for(:player) +}.merge(RED) +``` + +### Sound Effects & Music + +Scale comes with a few helper methods to make playing music and sound effects easy, as well as settings to enable or disable their playback. + +Play a sound effect: + +``` ruby +play_sfx(args, :enemy_hit) +``` + +the symbol (or string) file key for the file must correspond to the file's name in `sounds/`. + +Here's how to play music: + +``` ruby +play_music(args, :menu) +``` + +the symbol (or string) file key for the file must correspond to the file's name in `sounds/`. + +You can pause and resume music easily with: + +``` ruby +pause_music(args) +resume_music(args) +``` + +Scale assumes only one music track can be playing at a time. + +### Adding New Files + +When you add new code files to `app/`, just be sure to require them in `app/main.rb`. + +### Debug Tick & Development Shortcuts + +When you're making a game, you often want to have easy shortcuts to toggle settings. Maybe you want to make your player invincible so you can easily test changes out. `app/tick.rb` contains a method called `#debug_tick` that's only called when you're game is in debug mode (a.k.a. not the version shipped to players). Anything you put in there will run every tick but only while you make the game. + +You'll see there are already three shortcuts Scale gives you: + +- i — reloads sprites from disk +- r — resets game state +- 0 — displays framerate and other debug details + +Use those as templates to add your own development shortcuts. + +You can check for Debug mode in your game anywhere with the `debug?` method. + +### `#debug_label` Method + +It's common to need to display debug-only information about entities in your game. Maybe you want to see a value that changes over time. This is what `#debug_label` is for. It putputs text that's shown when you toggle on debug details with the 0 key. + +Here's an example of how to use it to track the player's current coordinates: + +``` ruby +# assume we have args.state.player that can move +player = args.state.player +debug_label(args, player.x, player.y - 4, "#{player.x}, #{player.y}") +``` + +### Tests + +The `test/tests.rb` is where you can put tests for your game. It also includes tests for methods provided by Scale. Tests are powered by [DragonTest](https://github.com/DragonRidersUnite/dragon_test), a simple testing library for DragonRuby GTK. You'll see plenty of examples in there, but here's a quick overview: + +Write tests: + +``` ruby +test :text_for_setting_val do |args, assert| + it "returns proper text for boolean vals" do + assert.equal!(text_for_setting_val(true), "ON") + assert.equal!(text_for_setting_val(false), "OFF") + end + + it "passes the value through when not a boolean" do + assert.equal!(text_for_setting_val("other"), "other") + end +end +``` + +You've got these assertions: + +- `assert.true!` - whether or not what's passed in is truthy, ex: `assert.true!(5 + 5 == 10)` +- `assert.false!` - whether or not what's passed in is falsey, ex: `assert.false!(5 + 5 != 10)` +- `assert.equal!` - whether or not what's passed into param 1 is equal to param 2, ex: `assert.equal!(5, 2 + 3)` +- `assert.exception!` - expect the called code to raise an error with optional message, ex: `assert.exception!(KeyError, "Key not found: :not_present") { text(args, :not_present) }` +- `assert.includes!` - whether or not the array includes the value, ex: `assert.includes!([1, 2, 3], 2)` + +Run your tests with: `./run_tests` — test runner script for Unix-like environments with a shell that has proper exit codes on success and fail + +Your tests will also run when you save test files and be output to your running game's logs. Nifty! + +## Debug Shortcuts + +You'll find these in the README, too. Scale comes with some handy keyboard shortcuts that only run in debug mode to make building your game easier. + +- 0 — display debug details (ex: framerate) +- i — reload sprites from disk +- r — reset the entire game state +- m — toggle mobile simulation + +## Mobile Development + +Use the `#mobile?` method to check to add logic specifically for mobile devices. Press m to simulate this on desktop so you can easily check how your game will look on those platforms. + +Example: + +``` ruby +text_key = mobile? ? :instructions_mobile : :instructions +``` + +There are convenient methods and tracking for swipe inputs on touch devices. Scale automatically keeps track of them, and if you use the `up?(args)`, `down?(args)`, `left?(args)`, `right?(args)` methods, they're automatically checked. Otherwise, you can check to see if a swipe occurred with: + +``` ruby +if args.state.swipe.up + # do the thing +end +``` + +## Make Scale Yours! + +This is your game now. Scale is just here to help you out. Change Scale to meet your game's needs. diff --git a/app/constants.rb b/app/constants.rb new file mode 100644 index 0000000..320eadc --- /dev/null +++ b/app/constants.rb @@ -0,0 +1,41 @@ +FPS = 60 + +# for use with label alignment_enum +ALIGN_LEFT = 0 +ALIGN_CENTER = 1 +ALIGN_RIGHT = 2 + +# for use with blendmode_enum +BLEND_NONE = 0 +BLEND_ALPHA = 1 +BLEND_ADDITIVE = 2 +BLEND_MODULO = 3 +BLEND_MULTIPLY = 4 + +# A basic color palette; customize for your needs! +# Use with `#merge!` into your Hash data structures or pass as a parameter into +# methods that support color +TRUE_BLACK = { r: 0, g: 0, b: 0 } +BLACK = { r: 25, g: 25, b: 25 } +GRAY = { r: 157, g: 157, b: 157 } +WHITE = { r: 255, g: 255, b: 255 } + +BLUE = { r: 42, g: 133, b: 216 } +GREEN = { r: 42, g: 216, b: 78 } +ORANGE = { r: 255, g: 173, b: 31 } +PINK = { r: 245, g: 146, b: 198 } +PURPLE = { r: 133, g: 42, b: 216 } +RED = { r: 231, g: 89, b: 82 } +YELLOW = { r: 240, g: 232, b: 89 } + +DARK_BLUE = { r: 22, g: 122, b: 188 } +DARK_GREEN = { r: 5, g: 84, b: 12 } +DARK_PURPLE = { r: 66, g: 12, b: 109 } +DARK_PURPLE = { r: 103, g: 5, b: 98 } +DARK_RED = { r: 214, g: 26, b: 12 } +DARK_YELLOW = { r: 120, g: 97, b: 7 } + +DIR_DOWN = :down +DIR_UP = :up +DIR_LEFT = :left +DIR_RIGHT = :right diff --git a/app/game_setting.rb b/app/game_setting.rb new file mode 100644 index 0000000..b24ea0d --- /dev/null +++ b/app/game_setting.rb @@ -0,0 +1,58 @@ +# different than the Settings scene, this module contains methods for things +# like fullscreen on/off, sfx on/off, etc. +module GameSetting + class << self + # returns a string of a hash of settings in the following format: + # key1=val1,key2=val2 + # `settings` should be a hash of keys and vals to be saved + def settings_for_save(settings) + settings.map do |k, v| + "#{k}:#{v}" + end.join(",") + end + + # we don't want to accidentally ship our debug preferences to our players + def settings_file + "settings#{ debug? ? '-debug' : nil}.txt" + end + + # useful when wanting to save settings after the code in the block is + # executed, ex: `GameSetting.save_after(args) { |args| args.state.setting.big_head_mode = true } + def save_after(args) + yield(args) + save_settings(args) + end + + # loads settings from disk and puts them into `args.state.setting` + def load_settings(args) + settings = args.gtk.read_file(settings_file)&.chomp + + if settings + settings.split(",").map { |s| s.split(":") }.to_h.each do |k, v| + if v == "true" + v = true + elsif v == "false" + v = false + end + args.state.setting[k.to_sym] = v + end + else + args.state.setting.sfx = true + args.state.setting.music = true + args.state.setting.fullscreen = false + end + + if args.state.setting.fullscreen + args.gtk.set_window_fullscreen(args.state.setting.fullscreen) + end + end + + # saves settings from `args.state.setting` to disk + def save_settings(args) + args.gtk.write_file( + settings_file, + settings_for_save(open_entity_to_hash(args.state.setting)) + ) + end + end +end diff --git a/app/input.rb b/app/input.rb new file mode 100644 index 0000000..c4135a9 --- /dev/null +++ b/app/input.rb @@ -0,0 +1,110 @@ +# efficient input helpers that all take `args.inputs` + +PRIMARY_KEYS = [:j, :z, :space] +def primary_down?(inputs) + PRIMARY_KEYS.any? { |k| inputs.keyboard.key_down.send(k) } || + inputs.controller_one.key_down&.a +end +def primary_down_or_held?(inputs) + primary_down?(inputs) || + PRIMARY_KEYS.any? { |k| inputs.keyboard.key_held.send(k) } || + (inputs.controller_one.connected && + inputs.controller_one.key_held.a) +end + +SECONDARY_KEYS = [:k, :x, :backspace] +def secondary_down?(inputs) + SECONDARY_KEYS.any? { |k| inputs.keyboard.key_down.send(k) } || + (inputs.controller_one.connected && + inputs.controller_one.key_down.b) +end +def secondary_down_or_held?(inputs) + secondary_down?(inputs) || + SECONDARY_KEYS.any? { |k| inputs.keyboard.key_held.send(k) } || + (inputs.controller_one.connected && + inputs.controller_one.key_held.b) +end + +PAUSE_KEYS= [:escape, :p] +def pause_down?(inputs) + PAUSE_KEYS.any? { |k| inputs.keyboard.key_down.send(k) } || + inputs.controller_one.key_down&.start +end + +# check for arrow keys, WASD, gamepad, and swipe up +def up?(args) + args.inputs.up || args.state.swipe.up +end + +# check for arrow keys, WASD, gamepad, and swipe down +def down?(args) + args.inputs.down || args.state.swipe.down +end + +# check for arrow keys, WASD, gamepad, and swipe left +def left?(args) + args.inputs.left || args.state.swipe.left +end + +# check for arrow keys, WASD, gamepad, and swipe right +def right?(args) + args.inputs.right || args.state.swipe.right +end + +# called by the main #tick method to keep track of swipes, you likely don't +# need to call this yourself +# +# to check for swipes outside of the directional methods above, use it like +# this: +# +# if args.state.swipe.up +# # do the thing +# end +# +def track_swipe(args) + return unless mobile? + + reset_swipe(args) if args.state.swipe.nil? || args.state.swipe.stop_tick + swipe = args.state.swipe + + if args.inputs.mouse.down + swipe.merge!({ + start_tick: args.state.tick_count, + start_x: args.inputs.mouse.x, + start_y: args.inputs.mouse.y, + }) + end + + if swipe.start_tick && swipe.start_x && swipe.start_y + p1 = [swipe.start_x, swipe.start_y] + p2 = [args.inputs.mouse.x, args.inputs.mouse.y] + dist = args.geometry.distance(p1, p2) + + if dist > 50 # min distance threshold + swipe.merge!({ + stop_x: p2[0], + stop_y: p2[1], + }) + + angle = args.geometry.angle_from(p1, p2) + swipe.angle = angle + swipe.dist = dist + swipe.stop_tick = args.state.tick_count + + if angle > 315 || swipe.angle < 45 + swipe.left = true + elsif angle >= 45 && angle <= 135 + swipe.down = true + elsif angle > 135 && angle < 225 + swipe.right = true + elsif angle >= 225 && angle <= 315 + swipe.up = true + end + end + end +end + +# reset the currently tracked swipe +def reset_swipe(args) + args.state.swipe = { up: false, down: false, right: false, left: false } +end diff --git a/app/main.rb b/app/main.rb new file mode 100644 index 0000000..b513062 --- /dev/null +++ b/app/main.rb @@ -0,0 +1,19 @@ +require "app/input.rb" +require "app/sprite.rb" +require "app/util.rb" + +require "app/constants.rb" +require "app/menu.rb" +require "app/scene.rb" +require "app/game_setting.rb" +require "app/sound.rb" +require "app/text.rb" + +require "app/scenes/gameplay.rb" +require "app/scenes/main_menu.rb" +require "app/scenes/paused.rb" +require "app/scenes/settings.rb" + +# NOTE: add all requires above this + +require "app/tick.rb" diff --git a/app/menu.rb b/app/menu.rb new file mode 100644 index 0000000..46942d8 --- /dev/null +++ b/app/menu.rb @@ -0,0 +1,113 @@ +module Menu + class << self + # Updates and renders a list of options that get passed through. + # + # +options+ data structure: + # [ + # { + # text: "some string", + # on_select: -> (args) { "do some stuff in this lambda" } + # } + # ] + def tick(args, state_key, options, menu_y: 420) + args.state.send(state_key).current_option_i ||= 0 + args.state.send(state_key).hold_delay ||= 0 + menu_state = args.state.send(state_key) + + labels = [] + + spacer = mobile? ? 100 : 60 + options.each.with_index do |option, i| + text = case option.kind + when :toggle + "#{text(option[:key])}: #{text_for_setting_val(option[:setting_val])}" + else + text(option[:key]) + end + + label = label( + text, + x: args.grid.w / 2, + y: menu_y + (options.length - i * spacer), + align: ALIGN_CENTER, + size: SIZE_MD + ) + label.key = option[:key] + label_size = args.gtk.calcstringbox(label.text, label.size_enum) + labels << label + if menu_state.current_option_i == i + if !mobile? || (mobile? && args.inputs.controller_one.connected) + args.outputs.solids << { + x: label.x - (label_size[0] / 1.4) - 24 + (Math.sin(args.state.tick_count / 8) * 4), + y: label.y - 22, + w: 16, + h: 16, + }.merge(WHITE) + end + end + end + + labels.each do |l| + button_border = { w: 340, h: 80, x: l.x - 170, y: l.y - 55 }.merge(WHITE) + if mobile? + args.outputs.borders << button_border + end + + if args.inputs.mouse.up && args.inputs.mouse.inside_rect?(button_border) + o = options.find { |o| o[:key] == l[:key] } + play_sfx(args, :menu) + o[:on_select].call(args) if o + end + end + + args.outputs.labels << labels + + move = nil + if args.inputs.down + move = :down + elsif args.inputs.up + move = :up + else + menu_state.hold_delay = 0 + end + + if move + menu_state.hold_delay -= 1 + + if menu_state.hold_delay <= 0 + play_sfx(args, :menu) + index = menu_state.current_option_i + if move == :up + index -= 1 + else + index += 1 + end + + if index < 0 + index = options.length - 1 + elsif index > options.length - 1 + index = 0 + end + menu_state.current_option_i = index + menu_state.hold_delay = 10 + end + end + + if primary_down?(args.inputs) + play_sfx(args, :select) + options[menu_state.current_option_i][:on_select].call(args) + end + end + + def text_for_setting_val(val) + case val + when true + text(:on) + when false + text(:off) + else + val + end + end + end +end diff --git a/app/repl.rb b/app/repl.rb new file mode 100644 index 0000000..f057ad2 --- /dev/null +++ b/app/repl.rb @@ -0,0 +1,11 @@ +# =============================================================== +# Welcome to repl.rb +# =============================================================== +# You can experiement with code within this file. Code in this +# file is only executed when you save (and only excecuted ONCE). +# =============================================================== + +# Remove the x from xrepl to run the code. Add the x back to ignore to code. +xrepl do + puts "The result of 1 + 2 is: #{1 + 2}" +end diff --git a/app/scene.rb b/app/scene.rb new file mode 100644 index 0000000..53768ef --- /dev/null +++ b/app/scene.rb @@ -0,0 +1,36 @@ +# A scene represents a discreet state of gameplay. Things like the main menu, +# game over screen, and gameplay. +# +# Define a new scene by adding one to `app/scenes/` and defining a +# `Scene.tick_SCENE_NAME` class method. +# +# The main `#tick` of the game handles delegating to the current scene based on +# the `args.state.scene` value, which is a symbol of the current scene, ex: +# `:gameplay` +module Scene + class << self + # Change the current scene, and optionally reset the scene that's begin + # changed to so any data is cleared out + # ex: + # Scene.switch(args, :gameplay) + def switch(args, scene, reset: false, return_to: nil) + args.state.scene_to_return_to = return_to if return_to + + if scene == :back && args.state.scene_to_return_to + scene = args.state.scene_to_return_to + args.state.scene_to_return_to = nil + end + + if reset + args.state.send(scene)&.current_option_i = nil + args.state.send(scene)&.hold_delay = nil + + # you can also add custom reset logic as-needed for specific scenes + # here + end + + args.state.scene = scene + raise FinishTick.new + end + end +end diff --git a/app/scenes/gameplay.rb b/app/scenes/gameplay.rb new file mode 100644 index 0000000..56c89b4 --- /dev/null +++ b/app/scenes/gameplay.rb @@ -0,0 +1,54 @@ +module Scene + class << self + # This is your main entrypoint into the actual fun part of your game! + def tick_gameplay(args) + labels = [] + sprites = [] + + # focus tracking + if !args.state.has_focus && args.inputs.keyboard.has_focus + args.state.has_focus = true + elsif args.state.has_focus && !args.inputs.keyboard.has_focus + args.state.has_focus = false + end + + # auto-pause & input-based pause + if !args.state.has_focus || pause_down?(args) + return pause(args) + end + + tick_pause_button(args, sprites) if mobile? + + draw_bg(args, BLACK) + + labels << label("GAMEPLAY", x: 40, y: args.grid.top - 40, size: SIZE_LG, font: FONT_BOLD) + args.outputs.labels << labels + args.outputs.sprites << sprites + end + + def pause(args) + play_sfx(args, :select) + return Scene.switch(args, :paused, reset: true) + end + + def tick_pause_button(args, sprites) + pause_button = { + x: 72.from_right, + y: 72.from_top, + w: 52, + h: 52, + path: Sprite.for(:pause), + } + pause_rect = pause_button.dup + pause_padding = 12 + pause_rect.x -= pause_padding + pause_rect.y -= pause_padding + pause_rect.w += pause_padding * 2 + pause_rect.h += pause_padding * 2 + if args.inputs.mouse.down && args.inputs.mouse.inside_rect?(pause_rect) + return pause(args) + end + sprites << pause_button + end + end +end diff --git a/app/scenes/main_menu.rb b/app/scenes/main_menu.rb new file mode 100644 index 0000000..a20201f --- /dev/null +++ b/app/scenes/main_menu.rb @@ -0,0 +1,51 @@ +module Scene + class << self + # what's displayed when your game starts + def tick_main_menu(args) + draw_bg(args, DARK_PURPLE) + options = [ + { + key: :start, + on_select: -> (args) { Scene.switch(args, :gameplay, reset: true) } + }, + { + key: :settings, + on_select: -> (args) { Scene.switch(args, :settings, reset: true, return_to: :main_menu) } + }, + ] + + if args.gtk.platform?(:desktop) + options << { + key: :quit, + on_select: -> (args) { args.gtk.request_quit } + } + end + + Menu.tick(args, :main_menu, options) + + labels = [] + labels << label( + "v#{version}", + x: 32.from_left, y: 32.from_top, + size: SIZE_XS, align: ALIGN_LEFT) + labels << label( + title.upcase, x: args.grid.w / 2, y: args.grid.top - 100, + size: SIZE_LG, align: ALIGN_CENTER, font: FONT_BOLD_ITALIC) + labels << label( + "#{text(:made_by)} #{dev_title}", + x: args.grid.left + 24, y: 48, + size: SIZE_XS, align: ALIGN_LEFT) + labels << label( + :controls_title, + x: args.grid.right - 24, y: 84, + size: SIZE_SM, align: ALIGN_RIGHT) + labels << label( + args.inputs.controller_one.connected ? :controls_gamepad : :controls_keyboard, + x: args.grid.right - 24, y: 48, + size: SIZE_XS, align: ALIGN_RIGHT) + + args.outputs.labels << labels + end + + end +end diff --git a/app/scenes/paused.rb b/app/scenes/paused.rb new file mode 100644 index 0000000..4f712b5 --- /dev/null +++ b/app/scenes/paused.rb @@ -0,0 +1,39 @@ +module Scene + class << self + # scene reached from gameplay when the player needs a break + def tick_paused(args) + draw_bg(args, DARK_YELLOW) + + options = [ + { + key: :resume, + on_select: -> (args) { Scene.switch(args, :gameplay) } + }, + { + key: :settings, + on_select: -> (args) { Scene.switch(args, :settings, reset: true, return_to: :paused) } + }, + { + key: :return_to_main_menu, + on_select: -> (args) { Scene.switch(args, :main_menu) } + }, + ] + + if args.gtk.platform?(:desktop) + options << { + key: :quit, + on_select: -> (args) { args.gtk.request_quit } + } + end + + Menu.tick(args, :paused, options) + + if secondary_down?(args.inputs) + play_sfx(args, :select) + options.find { |o| o[:key] == :resume }[:on_select].call(args) + end + + args.outputs.labels << label(:paused, x: args.grid.w / 2, y: args.grid.top - 200, align: ALIGN_CENTER, size: SIZE_LG, font: FONT_BOLD) + end + end +end diff --git a/app/scenes/settings.rb b/app/scenes/settings.rb new file mode 100644 index 0000000..5ff9775 --- /dev/null +++ b/app/scenes/settings.rb @@ -0,0 +1,60 @@ +module Scene + class << self + # reachable via main menu or pause menu, allows for configuring the game + # for the player's preferences. + def tick_settings(args) + draw_bg(args, DARK_GREEN) + + options = [ + { + key: :sfx, + kind: :toggle, + setting_val: args.state.setting.sfx, + on_select: -> (args) do + GameSetting.save_after(args) do |args| + args.state.setting.sfx = !args.state.setting.sfx + end + end + }, + { + key: :music, + kind: :toggle, + setting_val: args.state.setting.music, + on_select: -> (args) do + GameSetting.save_after(args) do |args| + args.state.setting.music = !args.state.setting.music + set_music_vol(args) + end + end + }, + { + key: :back, + on_select: -> (args) { Scene.switch(args, :back) } + }, + ] + + if args.gtk.platform?(:desktop) + options.insert(options.length - 1, { + key: :fullscreen, + kind: :toggle, + setting_val: args.state.setting.fullscreen, + on_select: -> (args) do + GameSetting.save_after(args) do |args| + args.state.setting.fullscreen = !args.state.setting.fullscreen + args.gtk.set_window_fullscreen(args.state.setting.fullscreen) + end + end + }) + end + + Menu.tick(args, :settings, options) + + if secondary_down?(args.inputs) + play_sfx(args, :select) + options.find { |o| o[:key] == :back }[:on_select].call(args) + end + + args.outputs.labels << label(:settings, x: args.grid.w / 2, y: args.grid.top - 200, align: ALIGN_CENTER, size: SIZE_LG, font: FONT_BOLD) + end + end +end diff --git a/app/sound.rb b/app/sound.rb new file mode 100644 index 0000000..e0f5673 --- /dev/null +++ b/app/sound.rb @@ -0,0 +1,30 @@ +# play a sound effect. the file in sounds/ must match the key name. ex: +# play_sfx(args, :select) +def play_sfx(args, key) + if args.state.setting.sfx + args.outputs.sounds << "sounds/#{key}.wav" + end +end + +# play the specified music track, the key must correspond to the +# `sounds/#{key}.ogg` file naming scheme. +def play_music(args, key) + args.audio[:music] = { input: "sounds/#{key}.ogg", looping: true, } + set_music_vol(args) +end + +# sets the music vol based on whether or not music is enabled or disabled +def set_music_vol(args) + vol = args.state.setting.music ? 0.8 : 0.0 + args.audio[:music]&.gain = vol +end + +# pause the currently playing music track +def pause_music(args) + args.audio[:music].paused = true +end + +# pause the current music track +def resume_music(args) + args.audio[:music].paused = false +end diff --git a/app/sprite.rb b/app/sprite.rb new file mode 100644 index 0000000..4648aab --- /dev/null +++ b/app/sprite.rb @@ -0,0 +1,25 @@ +module Sprite + # annoying to track but useful for reloading with +i+ in debug mode; would be + # nice to define a different way + SPRITES = { + bullet: "sprites/bullet.png", + enemy: "sprites/enemy.png", + enemy_king: "sprites/enemy_king.png", + enemy_super: "sprites/enemy_super.png", + exp_chip: "sprites/exp_chip.png", + familiar: "sprites/familiar.png", + player: "sprites/player.png", + pause: "sprites/pause.png", + } + + class << self + def reset_all(args) + SPRITES.each { |_, v| args.gtk.reset_sprite(v) } + end + + def for(key) + SPRITES.fetch(key) + end + end +end + diff --git a/app/tests.rb b/app/tests.rb new file mode 100644 index 0000000..664545c --- /dev/null +++ b/app/tests.rb @@ -0,0 +1,8 @@ +# NOTE: don't write tests in this file, instead put them in `test/main_test.rb`. + +require "lib/dragon_test.rb" + +# add requires for additional test files here + +# this must be required last +require "test/tests.rb" diff --git a/app/text.rb b/app/text.rb new file mode 100644 index 0000000..d790904 --- /dev/null +++ b/app/text.rb @@ -0,0 +1,58 @@ +# Why put our text in a Hash? It makes it easier to proofread when near each +# other, makes the game easier to localize, and it's easier to manage than +# scouring the codebase. +# +# Don't access via this constant! Use the `#text` method instead. +TEXT = { + back: "Back", + controls_title: "Controls", + controls_keyboard: "WASD/Arrows to move | J/Z/Space to confirm | Esc/P to pause", + controls_gamepad: "Stick/D-Pad to move | A to confirm | Start to pause", + fullscreen: "Fullscreen", + made_by: "A game by", + music: "Music", + off: "OFF", + on: "ON", + paused: "Paused", + quit: "Quit", + resume: "Resume", + return_to_main_menu: "Return to Main Menu", + settings: "Settings", + sfx: "Sound Effects", + start: "Start", +} + +# Gets the text for the passed in `key`. Raises if it does not exist. We don't +# want missing text! +def text(key) + TEXT.fetch(key) +end + +SIZE_XS = 0 +SIZE_SM = 4 +SIZE_MD = 6 +SIZE_LG = 10 + +FONT_REGULAR = "fonts/Atkinson-Hyperlegible-Regular-102.ttf" +FONT_ITALIC = "fonts/Atkinson-Hyperlegible-Italic-102.ttf" +FONT_BOLD = "fonts/Atkinson-Hyperlegible-Bold-102.ttf" +FONT_BOLD_ITALIC = "fonts/Atkinson-Hyperlegible-BoldItalic-102.ttf" + +# Friendly method with sensible defaults for creating DRGTK label data +# structures. +def label(value_or_key, x:, y:, align: ALIGN_LEFT, size: SIZE_MD, color: WHITE, font: FONT_REGULAR) + text = if value_or_key.is_a?(Symbol) + text(value_or_key) + else + value_or_key + end + + { + text: text, + x: x, + y: y, + alignment_enum: align, + size_enum: size, + font: font, + }.merge(color) +end diff --git a/app/tick.rb b/app/tick.rb new file mode 100644 index 0000000..3fc51f4 --- /dev/null +++ b/app/tick.rb @@ -0,0 +1,79 @@ +# Code that only gets run once on game start +def init(args) + reset_swipe(args) + GameSetting.load_settings(args) +end + +def tick(args) + init(args) if args.state.tick_count == 0 + + # this looks good on non 16:9 resolutions; game background is different + args.outputs.background_color = TRUE_BLACK.values + + args.state.has_focus ||= true + args.state.scene ||= :main_menu + + track_swipe(args) if mobile? + + Scene.send("tick_#{args.state.scene}", args) + + debug_tick(args) +rescue FinishTick +end + +# raise this as an easy way to end the current tick early +class FinishTick < StandardError; end + +# code that only runs while developing +# put shortcuts and helpful info here +def debug_tick(args) + return unless debug? + + debug_label( + args, 24.from_right, 24.from_top, + "v#{version} | DR v#{$gtk.version} (#{$gtk.platform}) | Ticks: #{args.state.tick_count} | FPS: #{args.gtk.current_framerate.round}", + ALIGN_RIGHT) + + + if args.inputs.keyboard.key_down.zero + play_sfx(args, :select) + args.state.render_debug_details = !args.state.render_debug_details + end + + if args.inputs.keyboard.key_down.i + play_sfx(args, :select) + Sprite.reset_all(args) + args.gtk.notify!("Sprites reloaded") + end + + if args.inputs.keyboard.key_down.r + play_sfx(args, :select) + $gtk.reset + end + + if args.inputs.keyboard.key_down.m + play_sfx(args, :select) + args.state.simulate_mobile = !args.state.simulate_mobile + msg = if args.state.simulate_mobile + "Mobile simulation on" + else + "Mobile simulation off" + end + args.gtk.notify!(msg) + end +end + +# render a label that is only shown when in debug mode and the debug details +# are shown; toggle with +0+ key +def debug_label(args, x, y, text, align=ALIGN_LEFT) + return unless debug? + return unless args.state.render_debug_details + + args.outputs.debug << { x: x, y: y, text: text, alignment_enum: align }.merge(WHITE).label! +end + +# different than background_color... use this to change the bg color for the +# visible portion of the game +def draw_bg(args, color) + args.outputs.solids << { x: args.grid.left, y: args.grid.bottom, w: args.grid.w, h: args.grid.h }.merge(color) +end diff --git a/app/util.rb b/app/util.rb new file mode 100644 index 0000000..b2b1e51 --- /dev/null +++ b/app/util.rb @@ -0,0 +1,165 @@ +########### +# utility, math, and misc methods + +# returns random val between min & max, inclusive +# needs integers, use rand if you don't need min/max and don't care much +def random(min, max) + min = Integer(min) + max = Integer(max) + rand((max + 1) - min) + min +end + +# returns true the passed in % of the time +# ex: `percent_chance?(25)` -- 1/4 chance of returning true +def percent_chance?(percent) + error("percent param (#{percent}) can't be above 100!") if percent > 100.0 + return false if percent == 0.0 + return true if percent == 100.0 + rand() < (percent / 100.0) +end + +# strips away the junk added by GTK::OpenEntity +def open_entity_to_hash(open_entity) + open_entity.as_hash.except(:entity_id, :entity_name, :entity_keys_by_ref, :__thrash_count__) +end + +# Executes the block for each intersection of the collections. Doesn't check +# intersections within a parameter +# +# Block arguments are an instance of each collection, ordered by the parameter +# order. +# +# ex: +# collide(tiles, enemies) do |tile, enemy| +# # do stuff! +# end +# +# collide(player, enemies) do |player, enemy| +# player.health -= enemy.power +# end +def collide(col1, col2, &block) + col1 = [col1] unless col1.is_a?(Array) + col2 = [col2] unless col2.is_a?(Array) + + col1.each do |i| + col2.each do |j| + if i.intersect_rect?(j) + block.call(i, j) + end + end + end +end + +# +angle+ is expected to be in degrees with 0 being facing right +def vel_from_angle(angle, speed) + [speed * Math.cos(deg_to_rad(angle)), speed * Math.sin(deg_to_rad(angle))] +end + +# returns diametrically opposed angle +# uses degrees +def opposite_angle(angle) + add_to_angle(angle, 180) +end + +# returns a new angle from the og `angle` one summed with the `diff` +# degrees! of course +def add_to_angle(angle, diff) + ((angle + diff) % 360).abs +end + +def deg_to_rad(deg) + (deg * Math::PI / 180).round(4) +end + +# Returns degrees +def angle_for_dir(dir) + case dir + when DIR_RIGHT + 0 + when DIR_LEFT + 180 + when DIR_UP + 90 + when DIR_DOWN + 270 + else + error("invalid dir: #{dir}") + end +end + +# checks if the passed in `rect` is outside of the `container` +# `container` can be any rectangle-ish data structure +def out_of_bounds?(container, rect) + rect.x > container.right || + rect.x + rect.w < container.left || + rect.y > container.top || + rect.y + rect.h < container.bottom +end + +# Raises an exception with the passing in error message +# `msg` - String +def error(msg) + raise StandardError.new(msg) +end + +# The version of your game defined in `metadata/game_metadata.txt` +def version + $gtk.args.cvars['game_metadata.version'].value +end + +# Name of who make the game +def dev_title + $gtk.args.cvars['game_metadata.devtitle'].value +end + +# Title of the game +def title + $gtk.args.cvars['game_metadata.gametitle'].value +end + +# debug mode is what's running when you're making the game +# when you build and ship your game, it's in production mode +def debug? + @debug ||= !$gtk.production +end + +# whether or not you're on a mobile device (or simulating one) +def mobile? + $gtk.platform?(:mobile) || $gtk.args.state.simulate_mobile +end + +# sets the passed in entity's color for the specified number of ticks +def flash(entity, color, tick_count) + entity.flashing = true + entity.flash_ticks_remaining = tick_count + entity.flash_color = color +end + +def tick_flasher(entity) + if entity.flashing + entity.flash_ticks_remaining -= 1 + entity.merge!(entity.flash_color) + if entity.flash_ticks_remaining <= 0 + entity.flashing = false + reset_color(entity) + end + end +end + +def reset_color(entity) + entity.a = nil + entity.r = nil + entity.g = nil + entity.b = nil +end + +# Returns a hash of the x and y position coords of the center of the entity. +# The passed in `entity` must have x, y, h, and w attributes +# +# Ex: +# center_of({ x: 100, y: 100, w: 200, h: 250 }) +# # => { x: 200.0, y: 225.0 } +def center_of(entity) + raise StandardError.new("entity does not have needed properties to find center; must have x, y, w, and h properties") unless entity.x && entity.y && entity.h && entity.w + { x: entity.x + entity.w / 2, y: entity.y + entity.h / 2 } +end diff --git a/concom-config.json b/concom-config.json new file mode 100644 index 0000000..df33883 --- /dev/null +++ b/concom-config.json @@ -0,0 +1,9 @@ +{ + "types": [ + "feat", + "adjust", + "fix", + "chore" + ], + "breaking_changes": true +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..c1ffee3 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ +Put level data and other txt files here. \ No newline at end of file diff --git a/fonts/.gitkeep b/fonts/.gitkeep new file mode 100644 index 0000000..a03e35e --- /dev/null +++ b/fonts/.gitkeep @@ -0,0 +1 @@ +Put your custom fonts here. \ No newline at end of file diff --git a/fonts/Atkinson-Hyperlegible-Bold-102.ttf b/fonts/Atkinson-Hyperlegible-Bold-102.ttf new file mode 100644 index 0000000..14b7196 Binary files /dev/null and b/fonts/Atkinson-Hyperlegible-Bold-102.ttf differ diff --git a/fonts/Atkinson-Hyperlegible-BoldItalic-102.ttf b/fonts/Atkinson-Hyperlegible-BoldItalic-102.ttf new file mode 100644 index 0000000..4532705 Binary files /dev/null and b/fonts/Atkinson-Hyperlegible-BoldItalic-102.ttf differ diff --git a/fonts/Atkinson-Hyperlegible-Italic-102.ttf b/fonts/Atkinson-Hyperlegible-Italic-102.ttf new file mode 100644 index 0000000..89e5ce4 Binary files /dev/null and b/fonts/Atkinson-Hyperlegible-Italic-102.ttf differ diff --git a/fonts/Atkinson-Hyperlegible-Regular-102.ttf b/fonts/Atkinson-Hyperlegible-Regular-102.ttf new file mode 100644 index 0000000..c4fa6fb Binary files /dev/null and b/fonts/Atkinson-Hyperlegible-Regular-102.ttf differ diff --git a/lib/dragon_test.rb b/lib/dragon_test.rb new file mode 100644 index 0000000..0acbdc9 --- /dev/null +++ b/lib/dragon_test.rb @@ -0,0 +1,96 @@ +def run_tests + $gtk.tests&.passed.clear + $gtk.tests&.inconclusive.clear + $gtk.tests&.failed.clear + puts "💨 running tests" + $gtk.reset 100 + $gtk.log_level = :on + $gtk.tests.start + + if $gtk.tests.failed.any? + puts "🙀 tests failed!" + failures = $gtk.tests.failed.uniq.map do |failure| + "🔴 ##{failure[:m]} - #{failure[:e]}" + end + + if $gtk.cli_arguments.keys.include?(:"exit-on-fail") + $gtk.write_file("test-failures.txt", failures.join("\n")) + exit(1) + end + else + puts "đŸĒŠ tests passed!" + end +end + +# an optional BDD-like method to use to group and document tests +def it(message) + yield +end + +class GTK::Assert + # define custom assertions here! + # def rect!(obj) + # true!(obj.x && obj.y && obj.w && obj.h, "doesn't have needed properties") + # end + + # takes three params: lambda that gets called, the error class, and the + # expected message. + # usage: assert.exception!(KeyError, "Key not found: :not_present") { text(args, :not_present) } + def exception!(error_class, message=nil) + begin + yield + rescue StandardError => e + equal!(e.class, error_class) + + if message + equal!(e.message, message) + end + end + end + + # usage: assert.includes!([1, 2, 3], 3) + def includes!(arr, val) + true!(arr.include?(val), "array: #{arr} does not include the val: #{val}") + end + + # usage: assert.not_includes!([1, 2, 3], 4) + def not_includes!(arr, val) + false!(arr.include?(val), "array: #{arr} does include the val: #{val}") + end + + # usage: assert.int!(2 + 3) + def int!(obj) + true!(obj.is_a?(Integer), "that's no integer!") + end +end + +def test(method) + test_name = "test_#{method}" + + define_method(test_name) do |args, assert| + yield(args, assert) + end +end + +test :assert_includes do |args, assert| + it "works!" do + assert.includes!([1, 2, 3], 3) + end +end + +test :assert_not_includes do |args, assert| + it "works!" do + assert.not_includes!([1, 2, 3], 4) + end +end + +test :assert_int do |args, assert| + it "works!" do + assert.int!(2 + 3) + end +end + +test :assert_exception do |args, assert| + class MyError < StandardError; end + assert.exception!(MyError, "oh no") { raise MyError.new("oh no") } +end diff --git a/metadata/cvars.txt b/metadata/cvars.txt new file mode 100644 index 0000000..24f5115 --- /dev/null +++ b/metadata/cvars.txt @@ -0,0 +1,7 @@ +log.filter_subsystems=HTTPServer + +# Should use the whole display +# renderer.fullscreen=true + +# Milliseconds to sleep per frame when the game is in the background (zero to disable) +# renderer.background_sleep=0 diff --git a/metadata/game_metadata.txt b/metadata/game_metadata.txt new file mode 100644 index 0000000..4f614fe --- /dev/null +++ b/metadata/game_metadata.txt @@ -0,0 +1,73 @@ +devid=myitchusername +devtitle=My Name +gameid=my-game-title +gametitle=My Game Title +version=0.1-dev +icon=metadata/icon.png + +# === Flags available at all licensing tiers === + +# Defines the render scale quality for sprites. scale_quality=0 (default) is nearest neighbor, scale_quality=1 is linear, scale_quality=2 is antialiased. +# scale_quality=0 + +# === Flags available in DragonRuby Game Toolkit Pro ==== + +# Uncomment the entry below to bytecode compile your Ruby code +# compile_ruby=false + +# Uncomment the entry below to specify the package name for your APK +# packageid=org.dev.gamename + +# Setting this property to true will enable High DPI rendering (try in combination with scale_quality to see what looks best) +# highdpi=false + +# === Portrait Mode === +# The orientation can be set to either landscape (1280x720) or portrait (720x1280) +# orientation=landscape + +# === HD Mode === + +# HD Mode: when enabled, will give you 720p, 1080p, 1440p, 4k, and 5k rendering options +# Check out the following YouTube Video for a demo of DragonRuby's HD Capabilities +# https://youtu.be/Rnc6z84zaa4 +# hd=false + +# === Texture Atlases === + +# See sample app for texture atlas usage: =./samples/07_advanced_rendering_hd/02_texture_atlases= +# DragonRuby will recursively search the following directory for texture atlases. +# sprites_directory=sprites + +# === All Screen Mode === + +# All Screen Mode: when enabled, removes the letter box and lets you render outside of the 16:9 safe area +# NOTE: requires hd=true +# allscreen=false + +# All Screen Mode's Max Scale: You can specify the maximum scale for your game. Any resolution higher than your max scale will give more area outside of your resolutions safe area: + +# default value is 100 (which keeps the baseline 720p and draws to all screen area from there) +# allscreen_max_scale=100 + +# Supported values for max scale: + +# 720p: scales up to 1280x720 (and draws to all screen area from there) +# allscreen_max_scale=100 + +# HD+: scales up to 1600x900 +# allscreen_max_scale=125 + +# 1080p: scales up to 1920x1080 +# allscreen_max_scale=150 + +# 1440p: scales up to 2560x1440 +# allscreen_max_scale=200 + +# 1800p: scales up to 3200x1800 +# allscreen_max_scale=250 + +# 4k: scales up to 3200x2160 +# allscreen_max_scale=300 + +# 5k: scales up to 6400x2880 +# allscreen_max_scale=400 diff --git a/metadata/icon.png b/metadata/icon.png new file mode 100644 index 0000000..e20e8c2 Binary files /dev/null and b/metadata/icon.png differ diff --git a/metadata/ios_metadata.txt b/metadata/ios_metadata.txt new file mode 100644 index 0000000..0ba387e --- /dev/null +++ b/metadata/ios_metadata.txt @@ -0,0 +1,13 @@ +# ios_metadata.txt is used by the Pro version of DragonRuby Game Toolkit to create iOS apps. +# Information about the Pro version can be found at: http://dragonruby.org/toolkit/game#purchase + +# teamid needs to be set to your assigned Team Id which can be found at https://developer.apple.com/account/#/membership/ +teamid= +# appid needs to be set to your application identifier which can be found at https://developer.apple.com/account/resources/identifiers/list +appid= +# appname is the name you want to show up underneath the app icon on the device. Keep it under 10 characters. +appname= +# devcert is the certificate to use for development/deploying to your local device. This is the NAME of the certificate as it's displayed in Keychain Access. +devcert= +# prodcert is the certificate to use for distribution to the app store. This is the NAME of the certificate as it's displayed in Keychain Access. +prodcert= diff --git a/run_tests b/run_tests new file mode 100755 index 0000000..d7a05a6 --- /dev/null +++ b/run_tests @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -e +rm -f test-failures.txt +if ! ../dragonruby . --eval app/tests.rb --no-tick --exit-on-fail; then + echo "🙀 tests failed!" + cat test-failures.txt + exit 1 +else + echo "đŸĒŠ tests passed!" +fi diff --git a/sounds/.gitkeep b/sounds/.gitkeep new file mode 100644 index 0000000..a99ec00 --- /dev/null +++ b/sounds/.gitkeep @@ -0,0 +1 @@ +Put your sounds here. \ No newline at end of file diff --git a/sounds/menu.wav b/sounds/menu.wav new file mode 100644 index 0000000..46b19b7 Binary files /dev/null and b/sounds/menu.wav differ diff --git a/sounds/select.wav b/sounds/select.wav new file mode 100644 index 0000000..748bbf5 Binary files /dev/null and b/sounds/select.wav differ diff --git a/sprites/.gitkeep b/sprites/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sprites/pause.png b/sprites/pause.png new file mode 100644 index 0000000..ed0ca64 Binary files /dev/null and b/sprites/pause.png differ diff --git a/test/tests.rb b/test/tests.rb new file mode 100644 index 0000000..4212163 --- /dev/null +++ b/test/tests.rb @@ -0,0 +1,178 @@ +# To run the tests: ./run_tests +# +# Available assertions: +# assert.true! +# assert.false! +# assert.equal! +# assert.exception! +# assert.includes! +# assert.not_includes! +# assert.int! +# + any that you define +# +# Powered by Dragon Test: https://github.com/DragonRidersUnite/dragon_test + +return unless debug? + +test :menu_text_for_setting_val do |args, assert| + assert.equal!(Menu.text_for_setting_val(true), "ON") + assert.equal!(Menu.text_for_setting_val(false), "OFF") + assert.equal!(Menu.text_for_setting_val("other"), "other") +end + +test :out_of_bounds do |args, assert| + grid = { + x: 0, + y: 0, + w: 1280, + h: 720, + } + assert.true!(out_of_bounds?(grid, { x: -30, y: 30, w: 24, h: 24 })) + assert.true!(out_of_bounds?(grid, { x: 30, y: -50, w: 24, h: 24 })) + assert.false!(out_of_bounds?(grid, { x: 30, y: 30, w: 24, h: 24 })) +end + +test :angle_for_dir do |args, assert| + assert.equal!(angle_for_dir(DIR_RIGHT), 0) + assert.equal!(angle_for_dir(DIR_LEFT), 180) + assert.equal!(angle_for_dir(DIR_UP), 90) + assert.equal!(angle_for_dir(DIR_DOWN), 270) +end + +test :vel_from_angle do |args, assert| + it "calculates core four angles properly" do + assert.equal!(vel_from_angle(0, 5), [5.0, 0.0]) + assert.equal!(vel_from_angle(90, 5).map { |v| v.round(2) }, [0.0, 5.0]) + assert.equal!(vel_from_angle(180, 5).map { |v| v.round(2) }, [-5.0, 0.0]) + assert.equal!(vel_from_angle(270, 5).map { |v| v.round(2) }, [0.0, -5.0]) + end + + it "calculates other values as expected" do + assert.equal!(vel_from_angle(12, 5).map { |v| v.round(2) }, [4.89, 1.04]) + end +end + +test :open_entity_to_hash do |args, assert| + it "strips OpenEntity keys" do + args.state.foo.bar = true + args.state.foo.biz = false + assert.equal!(open_entity_to_hash(args.state.foo), { bar: true, biz: false }) + end +end + +test :game_setting_settings_for_save do |args, assert| + it "joins hash keys and values" do + assert.equal!(GameSetting.settings_for_save({ fullscreen: true, sfx: false}), "fullscreen:true,sfx:false") + end +end + +test :text do |args, assert| + it "returns the value for the passed in key" do + assert.equal!(text(:fullscreen), "Fullscreen") + end + + it "raises when the key isn't present" do + assert.exception!(KeyError, "Key not found: :not_present") { text(:not_present) } + end +end + +test :opposite_angle do |args, assert| + it "returns the diametrically opposed angle" do + assert.equal!(opposite_angle(0), 180) + assert.equal!(opposite_angle(180), 0) + assert.equal!(opposite_angle(360), 180) + assert.equal!(opposite_angle(90), 270) + assert.equal!(opposite_angle(270), 90) + end +end + +test :add_to_angle do |args, assert| + it "returns the new angle on the circle" do + assert.equal!(add_to_angle(0, 30), 30) + assert.equal!(add_to_angle(0, -30), 330) + assert.equal!(add_to_angle(180, -30), 150) + assert.equal!(add_to_angle(320, 60), 20) + assert.equal!(add_to_angle(320, -60), 260) + end +end + +test :percent_chance? do |args, assert| + it "returns false if the percent is 0" do + assert.false!(percent_chance?(0)) + end + + it "returns true if the percent is 100" do + assert.true!(percent_chance?(100)) + end + + it "returns a boolean" do + assert.true!([TrueClass, FalseClass].include?(percent_chance?(50).class)) + end +end + +test :collide do |args, assert| + it "calls the block for every intersection of the two collections" do + counter = 0 + enemies = [{ x: 0, y: 0, w: 8, h: 8, type: :e}, { x: 8, y: 0, w: 8, h: 8, type: :e}] + # 2 enemies intersect with only 1 of these tiles + tiles = [{ x: 0, y: 0, w: 32, h: 32}, { x: 32, y: 0, w: 32, h: 32}] + + collide(enemies, tiles) do |enemy, tile| + counter += 1 + assert.equal!(enemy.type, :e) + end + + assert.equal!(counter, 2) + end + + it "has access to args in the block" do + args.state.detect = false + enemies = [{ x: 0, y: 0, w: 8, h: 8}] + tiles = [{ x: 0, y: 0, w: 32, h: 32}] + + collide(enemies, tiles) do |enemy, tile| + args.state.detect = true + end + + assert.true!(args.state.detect) + end + + it "wraps non-arrays in an array" do + counter = 0 + tiles = [{ x: 0, y: 0, w: 32, h: 32}, { x: 32, y: 0, w: 32, h: 32}] + # player only intersects with 1 tile + player = { x: 0, y: 0, w: 8, h: 8, type: :player} + + collide(tiles, player) do |tile, player| + counter += 1 + assert.equal!(player.type, :player) + end + + assert.equal!(counter, 1) + end +end + +test :mobile do |args, assert| + it "supports simulation setting" do + $gtk.args.state.simulate_mobile = true + assert.true!(mobile?) + $gtk.args.state.simulate_mobile = false + assert.false!(mobile?) + end +end + +test :center_of do |args, assert| + it "returns a hash with the x and y coord of the center of the rectangle-ish object" do + assert.equal!(center_of({ x: 100, y: 100, w: 200, h: 250 }), { x: 200.0, y: 225.0 }) + end + + it "errors when the object isn't rectangle-ish" do + assert.exception!(StandardError, "entity does not have needed properties to find center; must have x, y, w, and h properties") do + center_of({ x: 100, h: 250 }) + end + end +end + +# add your tests here + +run_tests