Last active
October 24, 2025 13:11
-
-
Save amirrajan/a40c587da128ce4ca9d213d1f89ae937 to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - Definitely not Wordle
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| class Game | |
| attr_gtk | |
| GREEN = { r: 98, g: 140, b: 84 } | |
| YELLOW = { r: 177, g: 159, b: 54 } | |
| GRAY = { r: 64, g: 64, b: 64 } | |
| def initialize | |
| # get the list of words that can be inputed | |
| @valid_words = GTK.read_file("data/valid.txt") | |
| .each_line | |
| .map { |l| l.strip } | |
| .reject { |l| l.length == 0 } | |
| # get the list of words that will be picked from | |
| @play_words = GTK.read_file("data/play.txt") | |
| .each_line | |
| .map { |l| l.strip } | |
| .reject { |l| l.length == 0 } | |
| @player_progress = (GTK.read_file("user-data/progress.txt") || "") | |
| .each_line | |
| .map { |l| l.strip } | |
| .reject { |l| l.length == 0 } | |
| .map do |l| | |
| word, result = l.split "," | |
| { word: word, result: result.to_sym } | |
| end | |
| # animation spline for when a letter is typed | |
| @enter_char_spline_duration = 15 | |
| @enter_char_spline = [ | |
| [0.0, 0.0, 0.66, 1.0], | |
| [1.0, 1.0, 1.0, 1.0], | |
| [1.0, 0.33, 0.0, 0.0] | |
| ] | |
| # animation spline for when a letter is flipped | |
| @flip_spline_duration = 15 | |
| @flip_spline = [ | |
| [1.0, 0.66, 0.33, 0.0], | |
| [0.0, 0.33, 0.66, 1.0], | |
| ] | |
| # animation spline for an invalid word | |
| @invalid_spline_duration = 15 | |
| @invalid_spline = [ | |
| [0.0, -0.5, 0.0, 0.5], | |
| [0.0, -0.5, 0.0, 0.5], | |
| ] | |
| # start a new game | |
| new_game! | |
| end | |
| def save_progress! | |
| content = @player_progress.map do |h| | |
| "#{h.word},#{h.result}" | |
| end.join "\n" | |
| GTK.write_file "user-data/progress.txt", content | |
| end | |
| def new_game! | |
| # from the list of playable words, choose a word | |
| @target_word = @play_words.reject do |w| | |
| @player_progress.any? { |h| h.word == w } | |
| end.sample | |
| # this is a look up table for coloring the keys | |
| @key_colors = { } | |
| # the current row the player is on | |
| @current_guess_index = 0 | |
| # the current char the player is on | |
| @current_guess_char_index = 0 | |
| # point at which the game has ended | |
| @game_over_at = nil | |
| # flag for when the game has endend | |
| @game_over = false | |
| # flag denoting whether the player won or lost when the game has ended | |
| @winner = false | |
| @new_game_at = Kernel.tick_count | |
| # data structure for where the guesses will be stored, | |
| # { rect:, action:, action_at:, char: } | |
| # Layout api is used to create the board | |
| @guesses = [ | |
| [ | |
| { rect: Layout.rect(row: 4, col: 1, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 4, col: 3, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 4, col: 5, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 4, col: 7, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 4, col: 9, w: 2, h: 2) }, | |
| ], | |
| [ | |
| { rect: Layout.rect(row: 6, col: 1, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 6, col: 3, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 6, col: 5, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 6, col: 7, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 6, col: 9, w: 2, h: 2) }, | |
| ], | |
| [ | |
| { rect: Layout.rect(row: 8, col: 1, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 8, col: 3, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 8, col: 5, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 8, col: 7, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 8, col: 9, w: 2, h: 2) }, | |
| ], | |
| [ | |
| { rect: Layout.rect(row: 10, col: 1, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 10, col: 3, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 10, col: 5, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 10, col: 7, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 10, col: 9, w: 2, h: 2) }, | |
| ], | |
| [ | |
| { rect: Layout.rect(row: 12, col: 1, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 12, col: 3, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 12, col: 5, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 12, col: 7, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 12, col: 9, w: 2, h: 2) }, | |
| ], | |
| [ | |
| { rect: Layout.rect(row: 14, col: 1, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 14, col: 3, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 14, col: 5, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 14, col: 7, w: 2, h: 2) }, | |
| { rect: Layout.rect(row: 14, col: 9, w: 2, h: 2) }, | |
| ], | |
| ] | |
| # generate the keyboard layout and wire up the button callbacks | |
| @keyboard = [ | |
| *keyboard_buttons(17.25 + 1.5 * 0, 0, ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"]), | |
| *keyboard_buttons(17.25 + 1.5 * 1, 0.6, ["A", "S", "D", "F", "G", "H", "J", "K", "L"]), | |
| *keyboard_buttons(17.25 + 1.5 * 2, 0, ["ENT", "Z", "X", "C", "V", "B", "N", "M", "BKSP"], | |
| { | |
| "ENT" => lambda { guess_word! }, | |
| "BKSP" => lambda { unset_char! } | |
| }) | |
| ] | |
| end | |
| def guess_word! | |
| # when the player presses enter, or clicks the "ENT" button | |
| # get the full word for the current row | |
| full_word = @guesses[@current_guess_index].map { |guess| guess.char }.join | |
| # the word is valid if its length is 5 and it's in the valid word dictionary | |
| is_valid = full_word.length == 5 && @valid_words.include?(full_word) | |
| # if it's valid, then enumerate each one of the guess entries and queue up | |
| # their animations | |
| if is_valid | |
| @guesses[@current_guess_index].each_with_index do |guess, i| | |
| if @target_word[i] == guess.char | |
| # if the index of the word matches exactly, then flip to green | |
| guess.action = :flip_green | |
| guess.action_at = Kernel.tick_count + i * @flip_spline_duration | |
| # update the keyboard color lookup and queue it to be rendered | |
| # after all animations have completed | |
| if !@key_colors[guess.char] || @key_colors[guess.char].color_id == :yellow || @key_colors[guess.char].color_id == :gray | |
| @key_colors[guess.char] ||= { **GREEN, at: Kernel.tick_count + 5 * @flip_spline_duration, color_id: :green } | |
| end | |
| elsif @target_word.include? guess.char | |
| # if the target word contains the character, then flip to yellow | |
| guess.action = :flip_yellow | |
| guess.action_at = Kernel.tick_count + i * @flip_spline_duration | |
| # update the keyboard color lookup and queue it to be rendered | |
| # after all animations have completed | |
| if !@key_colors[guess.char] || @key_colors[guess.char].color_id == :gray | |
| @key_colors[guess.char] ||= { **YELLOW, at: Kernel.tick_count + 5 * @flip_spline_duration, color_id: :yellow } | |
| end | |
| else | |
| # otherwise flip to gray | |
| guess.action = :flip_gray | |
| guess.action_at = Kernel.tick_count + i * @flip_spline_duration | |
| # update the keyboard color lookup and queue it to be rendered | |
| # after all animations have completed | |
| if !@key_colors[guess.char] | |
| @key_colors[guess.char] ||= { **GRAY, at: Kernel.tick_count + 5 * @flip_spline_duration, color_id: :gray } | |
| end | |
| end | |
| end | |
| if full_word == @target_word | |
| # the player has won if their guess matches the target word | |
| @game_over = true | |
| @game_over_at = Kernel.tick_count + 5 * @flip_spline_duration | |
| @winner = true | |
| @player_progress << { word: @target_word, result: :win } | |
| elsif @current_guess_index == 5 | |
| # the player has lost if they've run out of rows | |
| @game_over = true | |
| @game_over_at = Kernel.tick_count + 5 * @flip_spline_duration | |
| @winner = false | |
| @player_progress << { word: @target_word, result: :loss } | |
| else | |
| # increment to the next row after the guess | |
| @current_guess_index += 1 | |
| @current_guess_char_index = 0 | |
| end | |
| else | |
| # if the word they selected isn't in the valid word dictionary, | |
| # then queue the invalid animation | |
| @guesses[@current_guess_index].each_with_index do |guess, i| | |
| guess.action = :invalid | |
| guess.action_at = Kernel.tick_count | |
| end | |
| end | |
| end | |
| def generate_letter_prefabs! | |
| # on frame zero, generate textures/glpyhs for all the letters | |
| return if Kernel.tick_count != 0 | |
| r = Layout.rect(row: 0, col: 0, w: 2, h: 2) | |
| ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", | |
| "A", "S", "D", "F", "G", "H", "J", "K", "L", | |
| "Z", "X", "C", "V", "B", "N", "M"].each do |c| | |
| outputs[c.downcase].w = r.w | |
| outputs[c.downcase].h = r.h | |
| outputs[c.downcase].background_color = [0, 0, 0, 0] | |
| outputs[c.downcase].primitives << { x: r.w / 2, y: r.h / 2, text: c, anchor_x: 0.5, anchor_y: 0.5, size_px: r.h / 2, r: 255, g: 255, b: 255 } | |
| end | |
| end | |
| def calc | |
| return if @game_over_at && @game_over_at.elapsed_time < 30 | |
| return if @new_game_at && @new_game_at.elapsed_time < 30 | |
| if @game_over | |
| # if they clicked or pressed enter, then start a new game | |
| if inputs.mouse.click || inputs.keyboard.key_up.char == "\r" || inputs.keyboard.key_down == "\r" | |
| save_progress! | |
| new_game! | |
| end | |
| else | |
| if inputs.mouse.click | |
| # if they are using the mouse and they click, find the key that the mouse intersects with | |
| keyboard_key = Geometry.find_intersect_rect(inputs.mouse, @keyboard, using: :rect) | |
| # if the key is found, then call the on_click callback on the key | |
| if keyboard_key | |
| keyboard_key.on_click.call | |
| end | |
| elsif inputs.keyboard.key_up.char | |
| # if they used the keyboard and it's backspace or enter, | |
| # then delete or guess word | |
| if inputs.keyboard.key_up.char == "\b" | |
| unset_char! | |
| elsif inputs.keyboard.key_up.char == "\r" | |
| guess_word! | |
| else | |
| # if it's any other key, then check to see if the keyboard buttons has | |
| # the key that was pressed | |
| key = @keyboard.find do |k| | |
| k.char == inputs.keyboard.key_up.char.upcase | |
| end | |
| # if so, then invoke the on_click callback (as if they clicked it with the mouse) | |
| key.on_click.call if key | |
| end | |
| end | |
| end | |
| end | |
| def tick | |
| generate_letter_prefabs! | |
| calc | |
| render | |
| # outputs.primitives << Layout.debug_primitives(invert_colors: true) | |
| end | |
| def unset_char! | |
| # unsetting a char/deleting a char logic | |
| if @current_guess_char_index == 4 && @guesses[@current_guess_index][@current_guess_char_index].char | |
| # if it's the last spot and there is a char to be deleted, then clear out the char | |
| @guesses[@current_guess_index][@current_guess_char_index].char = nil | |
| elsif @current_guess_char_index == 0 && @guesses[@current_guess_index][@current_guess_char_index].char | |
| # if it's the first spot and there is a char to be deleted, then clear out the char | |
| @guesses[@current_guess_index][@current_guess_char_index].char = nil | |
| elsif @current_guess_char_index != 0 | |
| # otherwise move back a spot, and clear out the char in that spot | |
| @current_guess_char_index -= 1 | |
| @guesses[@current_guess_index][@current_guess_char_index].char = nil | |
| end | |
| end | |
| def set_char! c | |
| # set the current spot's char and increment to the next spot if they aren't already on the las | |
| # spot | |
| if !@guesses[@current_guess_index][@current_guess_char_index].char | |
| @guesses[@current_guess_index][@current_guess_char_index].char = c | |
| @guesses[@current_guess_index][@current_guess_char_index].action_at = Kernel.tick_count | |
| @guesses[@current_guess_index][@current_guess_char_index].action = :set_char | |
| @current_guess_char_index += 1 if @current_guess_char_index != 4 | |
| end | |
| end | |
| def keyboard_buttons(start_row, start_col, chars, callback_overrides = {}) | |
| # button construction | |
| # layout api is used to create the rectangle, and the call back is set to | |
| # set_char!(char) by default unless there is a callback override (eg for ENT and BKSP) | |
| running_col = 0 | |
| chars.map_with_index do |c, i| | |
| w = if c.length > 1 | |
| 1.8 | |
| else | |
| 1.2 | |
| end | |
| r = if c.length > 1 | |
| Layout.rect(row: start_row, col: start_col + running_col, w: w, h: 1.5) | |
| else | |
| Layout.rect(row: start_row, col: start_col + running_col, w: w, h: 1.5) | |
| end | |
| running_col += w | |
| on_click = callback_overrides[c] || ->() { set_char! c } | |
| { rect: r, char: c, on_click: on_click } | |
| end | |
| end | |
| def guess_char_prefab guess_char | |
| # this is the prefab for rendering a tile | |
| # get the location of the spot and a char (if one is there) | |
| r = guess_char.rect | |
| c = guess_char.char | |
| # the default color for the tile is a grayish border | |
| # with a dark background, and the character texture (if the spot has a character set) | |
| border_color = if c | |
| { r: 90, g: 90, b: 90 } | |
| else | |
| { r: 45, g: 45, b: 45 } | |
| end | |
| outer_tile = r.center.merge(path: :solid, **border_color, w: r.w, h: r.h, anchor_x: 0.5, anchor_y: 0.5) | |
| inner_tile = r.center.merge(path: :solid, r: 18, g: 18, b: 18, w: r.w - 4, h: r.h - 4, anchor_x: 0.5, anchor_y: 0.5) | |
| label_prefab = r.center.merge(path: c.downcase, w: r.w, h: r.h, anchor_x: 0.5, anchor_y: 0.5) if c | |
| # sorting for rendering so that animations aren't behind other tiles (default is 0) | |
| sort_order = 0 | |
| if guess_char.action_at && guess_char.action == :set_char | |
| # if an action as been queued, and the action is :set_char | |
| # use the enter_char_spline animation to compute the percentage | |
| perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @enter_char_spline_duration | |
| Easing.spline(guess_char.action_at, Kernel.tick_count, @enter_char_spline_duration, @enter_char_spline) | |
| else | |
| 0 | |
| end | |
| # the percentage is used to scale the rect up, and back down | |
| label_prefab = r.center.merge(path: c.downcase, w: r.w + 32 * perc, h: r.h + 32 * perc, anchor_x: 0.5, anchor_y: 0.5) if c | |
| outer_tile = r.center.merge(path: :solid, **border_color, w: r.w + 32 * perc, h: r.h + 32 * perc, anchor_x: 0.5, anchor_y: 0.5) | |
| inner_tile = r.center.merge(path: :solid, r: 18, g: 18, b: 18, w: r.w - 4 + 32 * perc, h: r.h - 4 + 32 * perc, anchor_x: 0.5, anchor_y: 0.5) | |
| # set the sort order to 1 so that it renders at the top | |
| sort_order = 1 | |
| elsif guess_char.action_at && guess_char.action == :flip_green | |
| # if the animation is flip to green, then use the flip animation spline to compute the percentage | |
| perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @flip_spline_duration && guess_char.action_at.elapsed_time > 0 | |
| Easing.spline(guess_char.action_at, Kernel.tick_count, @flip_spline_duration, @flip_spline) | |
| else | |
| 1 | |
| end | |
| # default colors before the flip/reveal occurs | |
| outer_tile_color = { r: 90, g: 90, b: 90 } | |
| inner_tile_color = { r: 18, g: 18, b: 18 } | |
| # half way through the animation, flip to green | |
| if guess_char.action_at.elapsed_time > @flip_spline_duration.idiv(2) | |
| outer_tile_color = GREEN | |
| inner_tile_color = GREEN | |
| end | |
| # the perc value is used to control the height of the prefab | |
| label_prefab = r.center.merge(path: c.downcase, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5) if c | |
| outer_tile = r.center.merge(path: :solid, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5, **outer_tile_color) | |
| inner_tile = r.center.merge(path: :solid, w: r.w - 4, h: (r.h - 4) * perc, anchor_x: 0.5, anchor_y: 0.5, **inner_tile_color) | |
| sort_order = 1 | |
| elsif guess_char.action_at && guess_char.action == :flip_yellow | |
| # same as flip_green, but yellow color | |
| perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @flip_spline_duration && guess_char.action_at.elapsed_time > 0 | |
| Easing.spline(guess_char.action_at, Kernel.tick_count, @flip_spline_duration, @flip_spline) | |
| else | |
| 1 | |
| end | |
| outer_tile_color = { r: 90, g: 90, b: 90 } | |
| inner_tile_color = { r: 18, g: 18, b: 18 } | |
| if guess_char.action_at.elapsed_time > @flip_spline_duration.idiv(2) | |
| outer_tile_color = YELLOW | |
| inner_tile_color = YELLOW | |
| end | |
| label_prefab = r.center.merge(path: c.downcase, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5) if c | |
| outer_tile = r.center.merge(path: :solid, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5, **outer_tile_color) | |
| inner_tile = r.center.merge(path: :solid, w: r.w - 4, h: (r.h - 4) * perc, anchor_x: 0.5, anchor_y: 0.5, **inner_tile_color) | |
| sort_order = 1 | |
| elsif guess_char.action_at && guess_char.action == :flip_gray | |
| # same logic as flip_green, but gray color | |
| perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @flip_spline_duration && guess_char.action_at.elapsed_time > 0 | |
| Easing.spline(guess_char.action_at, Kernel.tick_count, @flip_spline_duration, @flip_spline) | |
| else | |
| 1 | |
| end | |
| outer_tile_color = { r: 90, g: 90, b: 90 } | |
| inner_tile_color = { r: 18, g: 18, b: 18 } | |
| if guess_char.action_at.elapsed_time > @flip_spline_duration.idiv(2) | |
| outer_tile_color = GRAY | |
| inner_tile_color = GRAY | |
| end | |
| label_prefab = r.center.merge(path: c.downcase, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5) if c | |
| outer_tile = r.center.merge(path: :solid, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5, **outer_tile_color) | |
| inner_tile = r.center.merge(path: :solid, w: r.w - 4, h: (r.h - 4) * perc, anchor_x: 0.5, anchor_y: 0.5, **inner_tile_color) | |
| sort_order = 1 | |
| elsif guess_char.action_at && guess_char.action == :invalid | |
| # if the animation that's queued is an invalid word, | |
| # compute the prec using the @invalid_spline | |
| perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @invalid_spline_duration && guess_char.action_at.elapsed_time > 0 | |
| Easing.spline(guess_char.action_at, Kernel.tick_count, @invalid_spline_duration, @invalid_spline) | |
| else | |
| 0 | |
| end | |
| # the perc value is used to shift the x value (shake animation) | |
| label_prefab = { x: r.center.x + 64 * perc, y: r.center.y, w: r.w, h: r.h, anchor_x: 0.5, anchor_y: 0.5, path: c.downcase } if c | |
| outer_tile = { x: r.center.x + 64 * perc, y: r.center.y, path: :solid, **border_color, w: r.w, h: r.h, anchor_x: 0.5, anchor_y: 0.5 } | |
| inner_tile = { x: r.center.x + 64 * perc, y: r.center.y, path: :solid, r: 18, g: 18, b: 18, w: r.w - 4, h: r.h - 4, anchor_x: 0.5, anchor_y: 0.5 } | |
| sort_order = 1 | |
| end | |
| # return a structure that contains the sort order for rendering, and the prefab/primitives to render | |
| { | |
| sort_order: sort_order, | |
| prefab: [ | |
| outer_tile, | |
| inner_tile, | |
| label_prefab | |
| ] | |
| } | |
| end | |
| def render | |
| # set the back ground color | |
| outputs.background_color = [18, 18, 18] | |
| # enumerate all the guesses, flat map the prefabs | |
| # then sort each prefab by the sort order | |
| # and shovel the prefab data into output.primitives | |
| outputs.primitives << @guesses.flat_map do |guess| | |
| guess.map do |guess_char| | |
| guess_char_prefab(guess_char) | |
| end | |
| end.sort_by { |pf_container| pf_container.sort_order } | |
| .map { |pf| pf.prefab } | |
| # render the keyboard | |
| outputs.primitives << @keyboard.map do |keyboard_key| | |
| c = keyboard_key.char | |
| r = keyboard_key.rect | |
| # the color of the key is set to a defualt gray, unless there is a color override | |
| # in the key_colors lookup (along with a time stamp of when to show the color -> we don't want to change the | |
| # color during the reveal of a guess) | |
| color = if @key_colors[keyboard_key.char] && @key_colors[keyboard_key.char].at < Kernel.tick_count | |
| @key_colors[keyboard_key.char] | |
| else | |
| { r: 131, g: 131, b: 131 } | |
| end | |
| # return the prefab for the key which is a combination of the color, plus a label representing the key | |
| [ | |
| r.merge(path: :solid, **color), | |
| r.center.merge(text: c, anchor_x: 0.5, anchor_y: 0.5, r: 255, g: 255, b: 255, size_px: 40) | |
| ] | |
| end | |
| end | |
| end | |
| # boot up of game | |
| def boot args | |
| args.state = {} | |
| end | |
| # top level tick function | |
| def tick args | |
| $game ||= Game.new | |
| $game.args = args | |
| $game.tick | |
| end | |
| # reset logic used when hotloading | |
| def reset args | |
| $game = nil | |
| end | |
| GTK.reset |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment