[WV2] Pose picker first draft
This commit is contained in:
parent
78931ddb47
commit
76496f8a6d
6 changed files with 316 additions and 3 deletions
31
app/assets/javascripts/pose-picker.js
Normal file
31
app/assets/javascripts/pose-picker.js
Normal file
|
|
@ -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);
|
||||||
|
|
@ -41,6 +41,176 @@ body.wardrobe-v2 {
|
||||||
font-size: 1.2rem;
|
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 floats over the preview at the bottom */
|
||||||
species-color-picker {
|
species-color-picker {
|
||||||
position: absolute;
|
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) {
|
@media (hover: hover) {
|
||||||
|
&:hover pose-picker {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover species-color-picker {
|
&:hover species-color-picker {
|
||||||
opacity: 1;
|
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 {
|
&:has(species-color-picker:focus-within) species-color-picker {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,35 @@ class WardrobeController < ApplicationController
|
||||||
# (might differ from requested color if we fell back to a simple color)
|
# (might differ from requested color if we fell back to a simple color)
|
||||||
@selected_color = @pet_type&.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
|
# Load items from the objects[] parameter
|
||||||
item_ids = params[:objects] || []
|
item_ids = params[:objects] || []
|
||||||
items = Item.where(id: item_ids)
|
items = Item.where(id: item_ids)
|
||||||
|
|
||||||
# Build the outfit
|
# Build the outfit
|
||||||
@outfit = Outfit.new(
|
@outfit = Outfit.new(
|
||||||
pet_state: @pet_type&.canonical_pet_state,
|
pet_state: @pet_state,
|
||||||
worn_items: items,
|
worn_items: items,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -49,6 +71,30 @@ class WardrobeController < ApplicationController
|
||||||
|
|
||||||
private
|
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)
|
def build_search_filters(query_params, outfit)
|
||||||
filters = []
|
filters = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ module WardrobeHelper
|
||||||
|
|
||||||
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
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(: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)
|
unless except.include?(:worn_items)
|
||||||
outfit.worn_items.each do |item|
|
outfit.worn_items.each do |item|
|
||||||
|
|
|
||||||
21
app/views/wardrobe/_pose_option.html.haml
Normal file
21
app/views/wardrobe/_pose_option.html.haml
Normal file
|
|
@ -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"} ❓
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
= javascript_include_tag "idiomorph", async: true
|
= javascript_include_tag "idiomorph", async: true
|
||||||
= javascript_include_tag "outfit-viewer", async: true
|
= javascript_include_tag "outfit-viewer", async: true
|
||||||
= javascript_include_tag "species-color-picker", async: true
|
= javascript_include_tag "species-color-picker", async: true
|
||||||
|
= javascript_include_tag "pose-picker", async: true
|
||||||
= javascript_include_tag "wardrobe/show", async: true
|
= javascript_include_tag "wardrobe/show", async: true
|
||||||
= csrf_meta_tags
|
= csrf_meta_tags
|
||||||
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
||||||
|
|
@ -28,6 +29,41 @@
|
||||||
- else
|
- else
|
||||||
= outfit_viewer @outfit
|
= 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
|
%species-color-picker
|
||||||
= form_with url: wardrobe_v2_path, method: :get do |f|
|
= form_with url: wardrobe_v2_path, method: :get do |f|
|
||||||
= outfit_state_params except: [:color, :species]
|
= outfit_state_params except: [:color, :species]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue