[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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
|
|
|
|||
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 "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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue