From 76496f8a6d05b44aac3bff096dc764c549feb066 Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Tue, 11 Nov 2025 17:41:57 -0800 Subject: [PATCH] [WV2] Pose picker first draft --- app/assets/javascripts/pose-picker.js | 31 ++++ app/assets/stylesheets/wardrobe/show.css | 182 +++++++++++++++++++++- app/controllers/wardrobe_controller.rb | 48 +++++- app/helpers/wardrobe_helper.rb | 1 + app/views/wardrobe/_pose_option.html.haml | 21 +++ app/views/wardrobe/show.html.haml | 36 +++++ 6 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 app/assets/javascripts/pose-picker.js create mode 100644 app/views/wardrobe/_pose_option.html.haml diff --git a/app/assets/javascripts/pose-picker.js b/app/assets/javascripts/pose-picker.js new file mode 100644 index 00000000..f8cd92a4 --- /dev/null +++ b/app/assets/javascripts/pose-picker.js @@ -0,0 +1,31 @@ +/** + * PosePicker web component + * + * Progressive enhancement for pose picker forms: + * - Auto-submits the form when a pose is selected (if JS is enabled) + * - Shows a submit button as fallback (if JS is disabled or slow to load) + * - Uses Custom Element internals API to communicate state to CSS + */ +class PosePicker extends HTMLElement { + #internals; + + constructor() { + super(); + this.#internals = this.attachInternals(); + } + + connectedCallback() { + // Listen for changes to auto-submit the form, then tell CSS about it! + this.addEventListener("change", this.#handleChange); + this.#internals.states.add("auto-loading"); + } + + #handleChange(e) { + // Only auto-submit if a radio button was changed + if (e.target.type === "radio") { + this.querySelector("form").requestSubmit(); + } + } +} + +customElements.define("pose-picker", PosePicker); diff --git a/app/assets/stylesheets/wardrobe/show.css b/app/assets/stylesheets/wardrobe/show.css index 35d55795..c06182e7 100644 --- a/app/assets/stylesheets/wardrobe/show.css +++ b/app/assets/stylesheets/wardrobe/show.css @@ -41,6 +41,176 @@ body.wardrobe-v2 { font-size: 1.2rem; } + /* Pose picker floats over the preview above the species/color picker */ + pose-picker { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + pointer-events: none; + /* Allow clicks through when hidden */ + + /* Start hidden, reveal on hover or focus */ + opacity: 0; + transition: opacity 0.2s; + + .pose-picker-form { + pointer-events: auto; + /* Re-enable clicks on the form itself */ + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-radius: 12px; + padding: 1rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + } + + .pose-picker-table { + border-collapse: separate; + border-spacing: 0.5rem; + + th { + text-align: center; + color: white; + padding: 0.25rem; + font-weight: normal; + } + + td { + padding: 0; + } + } + + .emoji-icon { + font-size: 1.25rem; + display: inline-block; + user-select: none; + } + + .pose-option { + display: block; + cursor: pointer; + position: relative; + width: 60px; + height: 60px; + + input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + .pose-thumbnail { + width: 60px; + height: 60px; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + background: rgba(255, 255, 255, 0.1); + border: 2px solid transparent; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + + .pose-thumbnail-viewer { + width: 60px !important; + height: 60px !important; + transform: scale(0.8); + } + + .pose-unavailable { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.4; + + .question-mark { + font-size: 2rem; + } + } + + /* Selected state */ + input[type="radio"]:checked + .pose-thumbnail { + border-color: #48BB78; + box-shadow: 0 0 0 3px rgba(72, 187, 120, 0.3); + transform: scale(1.05); + } + + /* Hover state (only for available poses) */ + &.available:hover .pose-thumbnail { + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.05); + } + + /* Focus state */ + input[type="radio"]:focus + .pose-thumbnail { + border-color: rgba(255, 255, 255, 0.8); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2); + } + + /* Unavailable state */ + &.unavailable { + cursor: not-allowed; + + .pose-thumbnail { + opacity: 0.5; + background: rgba(128, 128, 128, 0.2); + } + } + } + + /* Submit button: progressive enhancement pattern */ + .pose-submit-button { + margin-top: 1rem; + padding: 0.5rem 1rem; + font-size: 0.95rem; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 6px; + background: rgba(0, 0, 0, 0.7); + color: white; + cursor: pointer; + width: 100%; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + border-color: rgba(255, 255, 255, 0.5); + } + + &:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.8); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1); + } + } + + /* If JS is enabled, hide the submit button initially with a delay */ + @media (scripting: enabled) { + .pose-submit-button { + opacity: 0; + animation: fade-in 0.25s forwards; + animation-delay: 0.75s; + } + } + + /* Once auto-loading is ready, hide the submit button completely */ + &:state(auto-loading) { + .pose-submit-button { + display: none; + } + } + } + /* Species/color picker floats over the preview at the bottom */ species-color-picker { position: absolute; @@ -142,14 +312,22 @@ body.wardrobe-v2 { } } - /* Show picker on hover (real hover only, not simulated touch hover) */ + /* Show pickers on hover (real hover only, not simulated touch hover) */ @media (hover: hover) { + &:hover pose-picker { + opacity: 1; + } + &:hover species-color-picker { opacity: 1; } } - /* Show picker when it has focus */ + /* Show pickers when they have focus */ + &:has(pose-picker:focus-within) pose-picker { + opacity: 1; + } + &:has(species-color-picker:focus-within) species-color-picker { opacity: 1; } diff --git a/app/controllers/wardrobe_controller.rb b/app/controllers/wardrobe_controller.rb index 6529d913..66e39dea 100644 --- a/app/controllers/wardrobe_controller.rb +++ b/app/controllers/wardrobe_controller.rb @@ -19,13 +19,35 @@ class WardrobeController < ApplicationController # (might differ from requested color if we fell back to a simple color) @selected_color = @pet_type&.color + # Get the selected pose from params, or default to nil (will use canonical) + @selected_pose = params[:pose] + + # Find the pet state for the selected pose, or use canonical + @pet_state = if @pet_type && @selected_pose.present? + @pet_type.pet_states.with_pose(@selected_pose).first || @pet_type.canonical_pet_state + else + @pet_type&.canonical_pet_state + end + + # If we found a pet_state, use its actual pose as the selected pose + @selected_pose = @pet_state&.pose + + # Load all available poses for this pet type (for the pose picker) + @available_poses = @pet_type ? available_poses_for(@pet_type) : {} + + # Preload the layers for all available poses so the thumbnails render efficiently + if @pet_type + pose_pet_states = @available_poses.values.compact + SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets)) + end + # Load items from the objects[] parameter item_ids = params[:objects] || [] items = Item.where(id: item_ids) # Build the outfit @outfit = Outfit.new( - pet_state: @pet_type&.canonical_pet_state, + pet_state: @pet_state, worn_items: items, ) @@ -49,6 +71,30 @@ class WardrobeController < ApplicationController private + # Returns a hash of pose => pet_state for all the main poses, + # indicating which poses are available for this pet type. + # Uses the same logic as the Rainbow Pool to pick the "canonical" pet state + # for each pose when multiple states exist. + def available_poses_for(pet_type) + poses_hash = {} + + # Group all pet states by pose, then pick the best one for each pose + # using emotion_order (same logic as Rainbow Pool) + pet_type.pet_states.emotion_order.group_by(&:pose).each do |pose, states| + # Only include the main poses (skip UNKNOWN, UNCONVERTED, etc.) + if PetState::MAIN_POSES.include?(pose) + poses_hash[pose] = states.first + end + end + + # Ensure all main poses are in the hash, even if nil + PetState::MAIN_POSES.each do |pose| + poses_hash[pose] ||= nil + end + + poses_hash + end + def build_search_filters(query_params, outfit) filters = [] diff --git a/app/helpers/wardrobe_helper.rb b/app/helpers/wardrobe_helper.rb index 1e46416a..d7f72b99 100644 --- a/app/helpers/wardrobe_helper.rb +++ b/app/helpers/wardrobe_helper.rb @@ -7,6 +7,7 @@ module WardrobeHelper fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species) fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color) + fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose) unless except.include?(:worn_items) outfit.worn_items.each do |item| diff --git a/app/views/wardrobe/_pose_option.html.haml b/app/views/wardrobe/_pose_option.html.haml new file mode 100644 index 00000000..0e4607e0 --- /dev/null +++ b/app/views/wardrobe/_pose_option.html.haml @@ -0,0 +1,21 @@ +-# Renders a single pose option in the pose picker grid +-# @param pose [String] The pose name (e.g., "HAPPY_MASC") +-# @param pet_state [PetState, nil] The pet state for this pose, or nil if unavailable +-# @param selected [Boolean] Whether this pose is currently selected + +- is_available = pet_state.present? +- pose_label = pose.split('_').map(&:capitalize).join(' ') + +%label.pose-option{class: [is_available ? 'available' : 'unavailable', selected ? 'selected' : nil]} + = radio_button_tag :pose, pose, selected, + disabled: !is_available, + "aria-label": pose_label + (is_available ? "" : " (not available)") + + .pose-thumbnail + - if is_available + -# Create a minimal outfit with just this pet state for the thumbnail + - thumbnail_outfit = Outfit.new(pet_state: pet_state, worn_items: []) + = outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer" + - else + .pose-unavailable + %span.question-mark{title: "Not available"} ❓ diff --git a/app/views/wardrobe/show.html.haml b/app/views/wardrobe/show.html.haml index 3ffbbd51..6a32fd8b 100644 --- a/app/views/wardrobe/show.html.haml +++ b/app/views/wardrobe/show.html.haml @@ -14,6 +14,7 @@ = javascript_include_tag "idiomorph", async: true = javascript_include_tag "outfit-viewer", async: true = javascript_include_tag "species-color-picker", async: true + = javascript_include_tag "pose-picker", async: true = javascript_include_tag "wardrobe/show", async: true = csrf_meta_tags %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} @@ -28,6 +29,41 @@ - else = outfit_viewer @outfit + - if @pet_type + %pose-picker + = form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f| + = outfit_state_params except: [:pose] + %table.pose-picker-table + %thead + %tr + %th + %th + %span.emoji-icon{title: "Happy"} 😀 + %th + %span.emoji-icon{title: "Sad"} 😢 + %th + %span.emoji-icon{title: "Sick"} 🤢 + %tbody + %tr + %th + %span.emoji-icon{title: "Masculine"} 💁‍♂️ + %td + = render "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC" + %td + = render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC" + %td + = render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC" + %tr + %th + %span.emoji-icon{title: "Feminine"} 💁‍♀️ + %td + = render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM" + %td + = render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM" + %td + = render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM" + = submit_tag "Change pose", name: nil, class: "pose-submit-button" + %species-color-picker = form_with url: wardrobe_v2_path, method: :get do |f| = outfit_state_params except: [:color, :species]