From 95bbc822fb273d52d32f94271a164a0c8e5717c1 Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Wed, 26 Aug 2026 11:46:04 +0200 Subject: [PATCH] dev jump --- app/assets/stylesheets/application.css | 66 +++++++++++++ app/controllers/concerns/dev_jump.rb | 122 ++++++++++++++++++++++++ app/controllers/game_controller.rb | 1 + app/helpers/dev_jump_helper.rb | 11 +++ app/javascript/application.js | 44 +++++++++ app/views/layouts/application.html.erb | 1 + app/views/shared/_dev_jump.html.erb | 124 +++++++++++++++++++++++++ 7 files changed, 369 insertions(+) create mode 100644 app/controllers/concerns/dev_jump.rb create mode 100644 app/helpers/dev_jump_helper.rb create mode 100644 app/views/shared/_dev_jump.html.erb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 0c1d0ab..c45b133 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -317,6 +317,21 @@ main, .modal-panel { to { transform: translateX(-100vw); } } +@keyframes screen-slide-in { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +@keyframes answers-sink { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(1.5rem); } +} + +@keyframes answers-rise { + from { opacity: 0; transform: translateY(1.5rem); } + to { opacity: 1; transform: translateY(0); } +} + @media (max-width: 1023.98px) and (prefers-reduced-motion: no-preference) { .start .hero-container { overflow: clip; @@ -371,6 +386,57 @@ main, .modal-panel { flex: 0 0 auto; transition: height var(--dur-base) var(--ease-out); } + + html[data-screen-transition="slide-left"] main { + view-transition-name: game; + } + + html[data-screen-transition="slide-left"]::view-transition-old(root), + html[data-screen-transition="slide-left"]::view-transition-new(root), + html[data-screen-transition="slide-left"]::view-transition-old(game), + html[data-screen-transition="slide-left"]::view-transition-new(game) { + mix-blend-mode: normal; + } + + html[data-screen-transition="slide-left"]::view-transition-old(root), + html[data-screen-transition="slide-left"]::view-transition-new(root), + html[data-screen-transition="slide-left"]::view-transition-old(game) { + animation: none; + } + + html[data-screen-transition="slide-left"]::view-transition-new(game) { + animation: screen-slide-in var(--dur-base) var(--ease-out) both; + } + + html[data-screen-transition="answer"] .hero-container { + view-transition-name: hero; + } + + html[data-screen-transition="answer"] .answers-container { + view-transition-name: answers; + } + + html[data-screen-transition="answer"]::view-transition-group(hero), + html[data-screen-transition="answer"]::view-transition-group(answers), + html[data-screen-transition="answer"]::view-transition-old(hero), + html[data-screen-transition="answer"]::view-transition-new(hero) { + animation-duration: var(--dur-base); + animation-timing-function: var(--ease-out); + } + + html[data-screen-transition="answer"]::view-transition-old(answers), + html[data-screen-transition="answer"]::view-transition-new(answers) { + mix-blend-mode: normal; + } + + html[data-screen-transition="answer"]::view-transition-old(answers) { + animation: answers-sink calc(var(--dur-base) * 0.6) var(--ease-in) both; + } + + html[data-screen-transition="answer"]::view-transition-new(answers) { + animation: answers-rise calc(var(--dur-base) * 0.6) var(--ease-out) + calc(var(--dur-base) * 0.4) both; + } } .facts { diff --git a/app/controllers/concerns/dev_jump.rb b/app/controllers/concerns/dev_jump.rb new file mode 100644 index 0000000..3b7bcc9 --- /dev/null +++ b/app/controllers/concerns/dev_jump.rb @@ -0,0 +1,122 @@ +# Development-only shortcut so any screen can be opened directly instead of +# playing the game from the start. Creates a player on demand and back-fills +# whatever progress the requested screen expects to find. +# +# ?dev=good|bad|chance|chance_bad picks which answer the back-fill records and +# always starts from a fresh player, so the whole run leading up to the screen +# is answered that way. Without it an existing session player is kept and only +# the gaps are filled, so a jump can be played on from. ?dev_last_save=1 also +# answers the last-save question. +module DevJump + extend ActiveSupport::Concern + + SCREENS = %w[index facts intro stage stage_result last_save done results].freeze + + included do + prepend_before_action :dev_jump!, only: SCREENS.map(&:to_sym), if: :dev_jump? + end + + +private + + + def dev_jump? + Rails.env.development? + end + + + def dev_jump! + set_locale + + # The start screen is the one place a leftover player is in the way: it + # would show the debug panel a first-time visitor never sees. + return session.delete(:player_id) if action_name == "index" + + session.delete(:player_id) if params[:dev] + dev_player + dev_seed_progress + end + + + def dev_player + Current.player = Player.find_by(id: session[:player_id]) + return Current.player if Current.player + + Current.player = Player.create(locale: I18n.locale.to_s, + country: Player.valid_country_code(params[:country]), + device: Player.device_from_user_agent(request.user_agent)) + session[:player_id] = Current.player.id + Current.player + end + + + def dev_seed_progress + case action_name + when "stage" then dev_fill_stages(params[:id].to_i - 1) + when "stage_result" then dev_fill_stages(params[:id].to_i) + when "last_save" then dev_fill_stages(n_stages) + when "done", "results" + dev_fill_stages(n_stages) + dev_fill_last_save if params[:dev_last_save] + end + end + + + # Answers every stage up to `upto` that the player hasn't answered yet, scoring + # each one the way the real answer action would. + def dev_fill_stages(upto) + return if upto < 1 + + stages.first(upto).each_with_index do |stage_node, i| + index = i + 1 + next if current_player.answer_id_for(index) + + answer = dev_answer_for(stage_node) + next unless answer + + current_player.record_answer(index, answer.id) + apply_score(score_entry_for(index, answer)) + end + end + + + def dev_fill_last_save + return if current_player.last_save_answer_id + + node = last_save_node + return unless node + + answer = dev_pick_from(node.children.ordered) + return unless answer + + current_player.record_last_save_answer(answer.id) + apply_score(last_save_score_entry_for(answer)) + end + + + # A chance answer is only ever recorded through one of its outcomes, so it + # resolves one level deeper -- ?dev=chance lands on the good outcome, + # ?dev=chance_bad on the bad one. + def dev_answer_for(stage_node) + answer = dev_pick_from(stage_node.children.ordered) + return answer unless answer&.chance? + + outcomes = answer.children.ordered.to_a + wanted = params[:dev] == "chance_bad" ? :bad_answer? : :good_answer? + outcomes.find(&wanted) || outcomes.first + end + + + # Not every stage offers every kind -- stage 5 has no bad answer, stage 1's + # chance cannot go wrong -- so an unavailable pick settles for the first + # answer rather than leaving the stage unanswered. + def dev_pick_from(nodes) + nodes = nodes.to_a + + case params[:dev] + when "bad" then nodes.find(&:bad_answer?) + when "chance", "chance_bad" then nodes.find(&:chance?) + else nodes.find(&:good_answer?) + end || nodes.first + end +end diff --git a/app/controllers/game_controller.rb b/app/controllers/game_controller.rb index 0ae2776..230887c 100644 --- a/app/controllers/game_controller.rb +++ b/app/controllers/game_controller.rb @@ -1,5 +1,6 @@ class GameController < ApplicationController include QuizHelperMethods + include DevJump skip_before_action :require_player!, only: [ :index, :start ] # The whole game lives in one turbo-frame declared in the application layout, diff --git a/app/helpers/dev_jump_helper.rb b/app/helpers/dev_jump_helper.rb new file mode 100644 index 0000000..2f205cb --- /dev/null +++ b/app/helpers/dev_jump_helper.rb @@ -0,0 +1,11 @@ +# Only ever called from the development-only jump panel. +module DevJumpHelper + def dev_jump_stage_count + Node.roots.viewable.first&.children&.ordered&.stage&.size.to_i + end + + + def dev_jump_path(path, query = {}) + [ "/#{I18n.locale}#{path}", query.to_query.presence ].compact.join("?") + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index a21393b..a9bf998 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -28,6 +28,50 @@ document.addEventListener("turbo:frame-render", (event) => { if (event.target.id === "game") window.scrollTo(0, 0) }) +// Which screen pairs swap with a view transition instead of appearing outright, +// keyed by the template class the layout puts on
. The value names the +// animation, which lives in application.css under [data-screen-transition]. +const SCREEN_TRANSITIONS = { + "intro>stage": "slide-left", + "stage>good_answer": "answer", + "stage>bad_answer": "answer" +} + +const TRANSITION_MEDIA = "(max-width: 1023.98px) and (prefers-reduced-motion: no-preference)" + +function screenTransition(newFrame) { + if (!document.startViewTransition) return null + if (!window.matchMedia(TRANSITION_MEDIA).matches) return null + + const from = document.querySelector("turbo-frame#game main")?.classList[0] + const to = newFrame.querySelector("main")?.classList[0] + + return SCREEN_TRANSITIONS[`${from}>${to}`] +} + +// Turbo hands over the function that swaps the frame's contents, so running it +// inside a view transition puts the old and the new screen on screen together +// for as long as the animation lasts. +document.addEventListener("turbo:before-frame-render", (event) => { + if (event.target.id !== "game") return + + const name = screenTransition(event.detail.newFrame) + if (!name) return + + const render = event.detail.render + event.detail.render = (currentElement, newElement) => { + document.documentElement.dataset.screenTransition = name + + // Two navigations at once -- the intro video ending on top of a tap -- leave + // the first transition aborted, so the settled state is reached either way. + const clear = () => delete document.documentElement.dataset.screenTransition + const transition = document.startViewTransition(() => render(currentElement, newElement)) + transition.finished.then(clear, clear) + + return transition.updateCallbackDone.catch(clear) + } +}) + // Configure Stimulus development experience application.debug = false window.Stimulus = application diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index f13061d..5a0f2ba 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -46,5 +46,6 @@ <% end %> <% end %> + <%= render "shared/dev_jump" if Rails.env.development? %> diff --git a/app/views/shared/_dev_jump.html.erb b/app/views/shared/_dev_jump.html.erb new file mode 100644 index 0000000..8df4651 --- /dev/null +++ b/app/views/shared/_dev_jump.html.erb @@ -0,0 +1,124 @@ +<% stage_count = dev_jump_stage_count %> + +
+
+ + +
+ + + <% (1..stage_count).each do |i| %> +
+ ">stage <%= i %> + <% { "good" => "✓", "bad" => "✗", "chance" => "50✓", "chance_bad" => "50✗" }.each do |pick, label| %> + " title="result, <%= pick %>"><%= label %> + <% end %> +
+ <% end %> + + + + +
+
+
+ + + +