Browse Source

dev jump

main
Mattias Bodlund 3 weeks ago
parent
commit
95bbc822fb
7 changed files with 369 additions and 0 deletions
  1. +66
    -0
      app/assets/stylesheets/application.css
  2. +122
    -0
      app/controllers/concerns/dev_jump.rb
  3. +1
    -0
      app/controllers/game_controller.rb
  4. +11
    -0
      app/helpers/dev_jump_helper.rb
  5. +44
    -0
      app/javascript/application.js
  6. +1
    -0
      app/views/layouts/application.html.erb
  7. +124
    -0
      app/views/shared/_dev_jump.html.erb

+ 66
- 0
app/assets/stylesheets/application.css View File

@ -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 {


+ 122
- 0
app/controllers/concerns/dev_jump.rb View File

@ -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

+ 1
- 0
app/controllers/game_controller.rb View File

@ -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,


+ 11
- 0
app/helpers/dev_jump_helper.rb View File

@ -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

+ 44
- 0
app/javascript/application.js View File

@ -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 <main>. 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


+ 1
- 0
app/views/layouts/application.html.erb View File

@ -46,5 +46,6 @@
<% end %>
<% end %>
<%= render "shared/dev_jump" if Rails.env.development? %>
</body>
</html>

+ 124
- 0
app/views/shared/_dev_jump.html.erb View File

@ -0,0 +1,124 @@
<% stage_count = dev_jump_stage_count %>
<div id="dev-jump">
<details id="dev-jump-panel">
<summary>⚡</summary>
<div class="dev-jump-rows">
<div>
<a data-turbo-frame="_top" href="<%= dev_jump_path("") %>">start</a>
<a data-turbo-frame="_top" href="<%= dev_jump_path("/facts") %>">facts</a>
<a data-turbo-frame="_top" href="<%= dev_jump_path("/intro") %>">intro</a>
</div>
<% (1..stage_count).each do |i| %>
<div>
<a data-turbo-frame="_top" class="key" href="<%= dev_jump_path("/stage/#{i}") %>">stage <%= i %></a>
<% { "good" => "✓", "bad" => "✗", "chance" => "50✓", "chance_bad" => "50✗" }.each do |pick, label| %>
<a data-turbo-frame="_top" href="<%= dev_jump_path("/stage/#{i}/result", dev: pick) %>" title="result, <%= pick %>"><%= label %></a>
<% end %>
</div>
<% end %>
<div>
<a data-turbo-frame="_top" class="key" href="<%= dev_jump_path("/last_save") %>">last save</a>
<a data-turbo-frame="_top" href="<%= dev_jump_path("/done") %>">done</a>
<a data-turbo-frame="_top" href="<%= dev_jump_path("/done", dev_last_save: 1) %>" title="done via last save">done ls</a>
</div>
<div>
<a data-turbo-frame="_top" class="key" href="<%= dev_jump_path("/results") %>">results</a>
<a data-turbo-frame="_top" href="<%= dev_jump_path("/results", dev: "good") %>">✓</a>
<a data-turbo-frame="_top" href="<%= dev_jump_path("/results", dev: "bad") %>">✗</a>
<a data-turbo-frame="_top" href="<%= dev_jump_path("/results", dev: "chance_bad", dev_last_save: 1) %>" title="results, bad chance + last save">50✗ ls</a>
</div>
</div>
</details>
</div>
<style>
#dev-jump {
position: fixed;
inset: auto auto 0.5rem 0.5rem;
z-index: 2147483647;
margin: 0;
padding: 0;
border: none;
background: none;
overflow: visible;
font: 400 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
}
#dev-jump-panel {
background: rgba(20, 20, 20, 0.92);
color: #fff;
border-radius: 0.75rem;
padding: 0.25rem;
max-width: min(20rem, calc(100vw - 1rem));
}
#dev-jump-panel > summary {
list-style: none;
cursor: pointer;
width: 1.5rem;
height: 1.5rem;
display: grid;
place-items: center;
user-select: none;
}
#dev-jump-panel > summary::-webkit-details-marker {
display: none;
}
#dev-jump .dev-jump-rows {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.25rem;
}
#dev-jump .dev-jump-rows > div {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
}
#dev-jump a {
color: #fff;
text-decoration: none;
background: rgba(255, 255, 255, 0.14);
border-radius: 100vw;
padding: 0.25rem 0.5rem;
white-space: nowrap;
}
#dev-jump a.key {
min-width: 4.5rem;
background: rgba(63, 184, 62, 0.55);
}
#dev-jump a:hover {
background: rgba(255, 255, 255, 0.35);
}
</style>
<script>
(() => {
const wrapper = document.getElementById("dev-jump")
const panel = document.getElementById("dev-jump-panel")
panel.open = localStorage.getItem("dev-jump-open") === "1"
panel.addEventListener("toggle", () => localStorage.setItem("dev-jump-open", panel.open ? "1" : "0"))
// A modal dialog paints in the top layer, above anything left in the page,
// so the panel rides along inside it for as long as it is open.
const showModal = HTMLDialogElement.prototype.showModal
HTMLDialogElement.prototype.showModal = function () {
showModal.call(this)
this.append(wrapper)
this.addEventListener("close", () => document.body.append(wrapper), { once: true })
}
})()
</script>

Loading…
Cancel
Save