Compare commits

..

8 commits

Author SHA1 Message Date
80307f21f7 Add Rainbow Pool homepage, with basic filter form 2024-09-26 21:10:25 -07:00
75040ffbf3 Add pages for the Rainbow Pool pet states 2024-09-26 20:24:31 -07:00
6f45cd0485 Add a bit more info to Rainbow Pool glitched label 2024-09-26 19:34:30 -07:00
4e33477c65 Hide unconverted below the "Other" list for Rainbow Pool poses 2024-09-26 19:33:16 -07:00
b28255cafd WIP: Better styles for Rainbow Pool pet type page 2024-09-26 18:39:32 -07:00
99e8b46157 Oops, fix bug parsing "8-Bit-Chia" in Rainbow Pool URLs 2024-09-26 18:36:49 -07:00
734b7fba1d WIP: Outfit viewers on pet type Rainbow Pool page
Now that we have such a convenient lil outfit viewer component we built
for the item page preview, it's easy peasy to drop it in here too! And
it's all nice and lightweight, since in this case it's basically just.
image tags, with some supporting enhancements.

Anyway, this page has no actual useful styles of its own yet. Gonna
make it look nice and such!
2024-09-26 18:20:05 -07:00
a1d6961249 WIP: Placeholder page for Rainbow Pool pet type
I'm experimenting with a Rainbow Pool ish UI, mainly as a support tool
for exploring and labeling poses—but one we can probably just show to
real users too!

Right now, I just use pet type images as a placeholder, and I polished
up some of the `pet_type_image` API. But we're probably gonna drop
these for a full outfit viewer, now that I think of it.
2024-09-26 14:56:45 -07:00
20 changed files with 426 additions and 112 deletions

View file

@ -0,0 +1,110 @@
@import "../partials/clean/constants"
// When loading, fade in the loading spinner after a brief delay. We only apply
// the delay here, not on the base styles, because fading *out* on load should
// be instant.
//
// This is implemented as a mixin, so that the item page can leverage the same
// loading state when loading a new preview altogether. Once CSS container
// style queries gain wider support, maybe use that instead.
=outfit-viewer-loading
cursor: wait
.loading-indicator
opacity: 1
transition-delay: 2s
// If the outfit *starts* in loading state, still delay the fade-in.
@starting-style
opacity: 0
outfit-viewer
display: block
position: relative
overflow: hidden
// These are default widths, expected to often be overridden.
width: 300px
height: 300px
// There's no useful text in here, but double-clicking the play/pause
// button can cause a weird selection state. Disable text selection.
user-select: none
-webkit-user-select: none
outfit-layer
display: block
position: absolute
inset: 0
// We disable pointer-events most importantly for the iframes, which
// will ignore our `cursor: wait` and show a plain cursor for the
// inside of its own document. But also, the context menus for these
// elements are kinda actively misleading, too!
pointer-events: none
img, iframe
width: 100%
height: 100%
.loading-indicator
position: absolute
z-index: 1000
bottom: 0px
right: 4px
padding: 8px
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
opacity: 0
.play-pause-button
position: absolute
z-index: 1001
left: 8px
bottom: 8px
display: none
align-items: center
justify-content: center
color: white
background: rgba(0, 0, 0, 0.64)
width: 2.5em
height: 2.5em
border-radius: 100%
border: 2px solid transparent
transition: all .25s
.playing-label, .paused-label
display: none
width: 1em
height: 1em
.play-pause-toggle
// Visually hidden
clip: rect(0 0 0 0)
clip-path: inset(50%)
height: 1px
overflow: hidden
position: absolute
white-space: nowrap
width: 1px
&:checked ~ .playing-label
display: block
&:not(:checked) ~ .paused-label
display: block
&:hover, &:has(.play-pause-toggle:focus)
border: 2px solid $module-border-color
background: $module-bg-color
color: $text-color
&:has(.play-pause-toggle:active)
transform: translateY(2px)
&:has(outfit-layer:state(has-animations))
.play-pause-button
display: flex
&:has(outfit-layer:state(loading))
+outfit-viewer-loading

View file

@ -2,6 +2,8 @@
@import "../partials/clean/mixins" @import "../partials/clean/mixins"
@import "../partials/item_header" @import "../partials/item_header"
@import "../application/outfit-viewer"
#container #container
width: 900px // A bit more generous to the preview area! width: 900px // A bit more generous to the preview area!
@ -78,93 +80,10 @@
width: var(--natural-width) width: var(--natural-width)
outfit-viewer outfit-viewer
display: block
position: relative
width: 300px width: 300px
height: 300px height: 300px
border: 1px solid $module-border-color border: 1px solid $module-border-color
border-radius: 1em border-radius: 1em
overflow: hidden
// There's no useful text in here, but double-clicking the play/pause
// button can cause a weird selection state. Disable text selection.
user-select: none
-webkit-user-select: none
outfit-layer
display: block
position: absolute
inset: 0
// We disable pointer-events most importantly for the iframes, which
// will ignore our `cursor: wait` and show a plain cursor for the
// inside of its own document. But also, the context menus for these
// elements are kinda actively misleading, too!
pointer-events: none
img, iframe
width: 100%
height: 100%
.loading-indicator
position: absolute
z-index: 1000
bottom: 0px
right: 4px
padding: 8px
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
opacity: 0
transition: opacity .5s
.play-pause-button
position: absolute
z-index: 1001
left: 8px
bottom: 8px
display: none
align-items: center
justify-content: center
color: white
background: rgba(0, 0, 0, 0.64)
width: 2.5em
height: 2.5em
border-radius: 100%
border: 2px solid transparent
transition: all .25s
.playing-label, .paused-label
display: none
width: 1em
height: 1em
.play-pause-toggle
// Visually hidden
clip: rect(0 0 0 0)
clip-path: inset(50%)
height: 1px
overflow: hidden
position: absolute
white-space: nowrap
width: 1px
&:checked ~ .playing-label
display: block
&:not(:checked) ~ .paused-label
display: block
&:hover, &:has(.play-pause-toggle:focus)
border: 2px solid $module-border-color
background: $module-bg-color
color: $text-color
&:has(.play-pause-toggle:active)
transform: translateY(2px)
&:has(outfit-layer:state(has-animations))
.play-pause-button
display: flex
.error-indicator .error-indicator
font-size: 85% font-size: 85%
@ -179,16 +98,8 @@ outfit-viewer
// //
// We only apply the delay here, not on the base styles, because fading // We only apply the delay here, not on the base styles, because fading
// *out* on load should be instant. // *out* on load should be instant.
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading)) #item-preview[busy] outfit-viewer
cursor: wait +outfit-viewer-loading
.loading-indicator
opacity: 1
transition-delay: 2s
// If the outfit *starts* in loading state, still delay the fade-in.
@starting-style
opacity: 0
#item-preview:has(outfit-layer:state(error)) #item-preview:has(outfit-layer:state(error))
outfit-viewer outfit-viewer

View file

@ -0,0 +1,5 @@
outfit-viewer
margin: 0 auto
dt
cursor: help

View file

@ -0,0 +1,32 @@
.pet-types
list-style-type: none
display: flex
flex-wrap: wrap
justify-content: center
gap: .5em
> li
width: 150px
max-width: calc(50% - .25em)
min-width: 150px
box-sizing: border-box
text-align: center
a
display: block
border-radius: 1em
padding: .5em
img
width: 100%
height: auto
aspect-ratio: 1 / 1
position: relative
z-index: -1
margin-bottom: -1em
.name
background: white
padding: .25em .5em
border-radius: .5em
margin: 0 auto

View file

@ -0,0 +1,36 @@
@import "../partials/clean/constants"
.pet-states
list-style-type: none
display: flex
flex-wrap: wrap
justify-content: center
gap: .5em
> li
width: 200px
max-width: calc(50% - .25em)
min-width: 150px
box-sizing: border-box
text-align: center
a
display: block
border-radius: 1em
padding: .5em
outfit-viewer
width: 100%
height: auto
aspect-ratio: 1 / 1
position: relative
z-index: -1
margin-bottom: -1em
.name
background: white
padding: .25em .5em
border-radius: .5em
.glitched
cursor: help

View file

@ -0,0 +1,6 @@
class PetStatesController < ApplicationController
def show
@pet_type = PetType.matching_name_param(params[:pet_type_name]).first!
@pet_state = @pet_type.pet_states.find(params[:id])
end
end

View file

@ -1,10 +1,78 @@
class PetTypesController < ApplicationController class PetTypesController < ApplicationController
def show def index
@pet_type = PetType. @species_names = Species.order(:name).map(&:human_name)
where(species_id: params[:species_id]). @color_names = Color.order(:name).map(&:human_name)
where(color_id: params[:color_id]).
first
render json: @pet_type if params[:species].present?
@selected_species = Species.find_by!(name: params[:species])
@selected_species_name = @selected_species.human_name
end
if params[:color].present?
@selected_color = Color.find_by!(name: params[:color])
@selected_color_name = @selected_color.human_name
end
@pet_types = PetType.
includes(:color, :species).
order(created_at: :desc).
paginate(page: params[:page], per_page: 30)
if @selected_species
@pet_types = @pet_types.where(species_id: @selected_species)
end
if @selected_color
@pet_types = @pet_types.where(color_id: @selected_color)
end
end
def show
@pet_type = find_pet_type
respond_to do |format|
format.html do
@pet_states = group_pet_states @pet_type.pet_states
end
format.json { render json: @pet_type }
end
end
protected
# The API-ish route uses IDs, but the human-facing route uses names.
def find_pet_type
if params[:species_id] && params[:color_id]
PetType.find_by!(
species_id: params[:species_id],
color_id: params[:color_id],
)
elsif params[:name]
color_name, _, species_name = params[:name].rpartition("-")
raise ActiveRecord::RecordNotFound if species_name.blank?
PetType.matching_name(color_name, species_name).first!
else
raise "expected params: species_id and color_id, or name"
end
end
# The `canonical` pet states are the main ones we want to show: the most
# canonical state for each pose. The `other` pet states are, the others!
#
# If no main poses are available, then we just make all the poses
# "canonical", and show the whole mish-mash!
MAIN_POSES = %w(HAPPY_FEM HAPPY_MASC SAD_FEM SAD_MASC SICK_FEM SICK_MASC)
def group_pet_states(pet_states)
pose_groups = pet_states.emotion_order.group_by(&:pose)
main_groups = pose_groups.select { |k| MAIN_POSES.include?(k) }.values
other_groups = pose_groups.reject { |k| MAIN_POSES.include?(k) }.values
if main_groups.empty?
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}
end
canonical = main_groups.map(&:first).sort_by(&:pose)
main_others = main_groups.map { |l| l.drop(1) }.flatten(1)
other = (main_others + other_groups.flatten(1)).sort_by(&:pose)
{canonical:, other:}
end end
end end

View file

@ -14,19 +14,30 @@ module ItemsHelper
} }
Sizes = { Sizes = {
face: 1, face: 1, # 50x50
thumb: 2, face_3x: 6, # 150x150
zoom: 3,
full: 4, thumb: 2, # 150x150
face_2x: 6, full: 4, # 300x300
large: 5, # 500x500
xlarge: 7, # 640x640
zoom: 3, # 80x80
autocrop: 9, # <varies>
}
SizeUpgrades = {
face: :face_3x,
thumb: :full,
full: :xlarge,
} }
end end
def pet_type_image_url(pet_type, emotion: :happy, size: :face) def pet_type_image_url(pet_type, emotion: :happy, size: :face)
PetTypeImage::Template.expand( PetTypeImage::Template.expand(
hash: pet_type.basic_image_hash || pet_type.image_hash, hash: pet_type.basic_image_hash || pet_type.image_hash,
emotion: PetTypeImage::Emotions[emotion], emotion: PetTypeImage::Emotions.fetch(emotion),
size: PetTypeImage::Sizes[size], size: PetTypeImage::Sizes.fetch(size),
).to_s ).to_s
end end
@ -246,8 +257,10 @@ module ItemsHelper
def pet_type_image(pet_type, emotion, size, **options) def pet_type_image(pet_type, emotion, size, **options)
src = pet_type_image_url(pet_type, emotion:, size:) src = pet_type_image_url(pet_type, emotion:, size:)
srcset = if size == :face
[[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]] size_2x = PetTypeImage::SizeUpgrades[size]
srcset = if size_2x
[[pet_type_image_url(pet_type, emotion:, size: size_2x), "2x"]]
end end
image_tag(src, srcset:, **options) image_tag(src, srcset:, **options)

View file

@ -69,5 +69,17 @@ module OutfitsHelper
options = {:spellcheck => false, :id => nil}.merge(options) options = {:spellcheck => false, :id => nil}.merge(options)
text_field_tag 'name', nil, options text_field_tag 'name', nil, options
end end
def outfit_viewer(outfit_or_options)
outfit = if outfit_or_options.is_a? Hash
Outfit.new(outfit_or_options)
elsif outfit_or_options.is_a? Outfit
outfit_or_options
else
raise TypeError, "must be an outfit or hash of options to create one"
end
render partial: "outfit_viewer", locals: {outfit:}
end
end end

View file

@ -0,0 +1,22 @@
module PetStatesHelper
def pose_name(pose)
case pose
when "HAPPY_FEM"
"Happy (Feminine)"
when "HAPPY_MASC"
"Happy (Masculine)"
when "SAD_FEM"
"Sad (Feminine)"
when "SAD_MASC"
"Sad (Masculine)"
when "SICK_FEM"
"Sick (Feminine)"
when "SICK_MASC"
"Sick (Masculine)"
when "UNCONVERTED"
"Unconverted"
else
"(Unknown)"
end
end
end

View file

@ -118,6 +118,10 @@ class PetState < ApplicationRecord
end end
end end
def to_param
"#{id}-#{pose.split('_').map(&:capitalize).join('-')}"
end
def self.from_pet_type_and_biology_info(pet_type, info) def self.from_pet_type_and_biology_info(pet_type, info)
swf_asset_ids = [] swf_asset_ids = []
info.each do |zone_id, asset_info| info.each do |zone_id, asset_info|

View file

@ -15,6 +15,10 @@ class PetType < ApplicationRecord
species = Species.find_by_name!(species_name) species = Species.find_by_name!(species_name)
where(color_id: color.id, species_id: species.id) where(color_id: color.id, species_id: species.id)
} }
scope :matching_name_param, ->(name_param) {
color_name, _, species_name = name_param.rpartition("-")
matching_name(color_name, species_name)
}
scope :preferring_species, ->(species_id) { scope :preferring_species, ->(species_id) {
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id]) joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
} }
@ -108,6 +112,10 @@ class PetType < ApplicationRecord
Item.appearances_for(item, self, ...) Item.appearances_for(item, self, ...)
end end
def to_param
"#{color.human_name}-#{species.human_name}"
end
def self.all_by_ids_or_children(ids, pet_states) def self.all_by_ids_or_children(ids, pet_states)
pet_states_by_pet_type_id = {} pet_states_by_pet_type_id = {}
pet_states.each do |pet_state| pet_states.each do |pet_state|

View file

@ -21,6 +21,6 @@
- if swf_asset.canvas_movie? - if swf_asset.canvas_movie?
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)} %iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
- elsif swf_asset.image_url.present? - elsif swf_asset.image_url.present?
= image_tag swf_asset.image_url, alt: "" = image_tag swf_asset.image_url, alt: "", loading: "lazy"
- else - else
/ No movie or image available for SWF asset: #{swf_asset.url} / No movie or image available for SWF asset: #{swf_asset.url}

View file

@ -16,7 +16,7 @@
= turbo_frame_tag "item-preview" do = turbo_frame_tag "item-preview" do
.preview-area .preview-area
= render partial: "outfit_viewer", locals: {outfit: @preview_outfit} = outfit_viewer @preview_outfit
.error-indicator .error-indicator
💥 We couldn't load all of this outfit. Try again? 💥 We couldn't load all of this outfit. Try again?
= link_to wardrobe_path(params: @preview_outfit.wardrobe_params), = link_to wardrobe_path(params: @preview_outfit.wardrobe_params),
@ -122,6 +122,8 @@
- content_for :stylesheets do - content_for :stylesheets do
= stylesheet_link_tag "application/hanger-spinner" = stylesheet_link_tag "application/hanger-spinner"
-# This is imported into items/show directly, to gain access to its mixins.
-# = stylesheet_link_tag "application/outfit-viewer"
= page_stylesheet_link_tag "layouts/items" = page_stylesheet_link_tag "layouts/items"
= page_stylesheet_link_tag "items/show" = page_stylesheet_link_tag "items/show"

View file

@ -0,0 +1,6 @@
%li
= link_to [pet_state.pet_type, pet_state] do
= outfit_viewer pet_state:
.name= pose_name pet_state.pose
- if pet_state.glitched?
%span.glitched{title: "Glitched"} 👾

View file

@ -0,0 +1,36 @@
- title "#{@pet_type.human_name}: #{pose_name @pet_state.pose} [\##{@pet_state.id}]"
- use_responsive_design
= outfit_viewer pet_state: @pet_state
%dl
%dt{title: "Pose usually affects just the eyes and mouth. Neopets " +
"genders these as Male/Female, but I don't like those " +
"terms for like… it's just eyelashes! Sheesh!"}
Pose
%dd
= pose_name @pet_state.pose
- if @pet_state.pose == "UNCONVERTED"
(Retired, replaced by #{link_to "Alt Styles", alt_styles_path})
%dt{title: "This is our own internal ID number, nothing to do with " +
"Neopets's official data."}
DTI ID
%dd= @pet_state.id
%dt{title: "When we notice a form looks wrong, we mark it Glitched, to " +
"tell our systems to prefer other forms for this pose instead."}
Glitched?
%dd
- if @pet_state.glitched?
👾 Yes, it's bad news bonko'd
- else
✅ Not marked as Glitched
- content_for :stylesheets do
= stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer"
= stylesheet_link_tag "pet_states/show"
- content_for :javascripts do
= javascript_include_tag "outfit-viewer", async: true

View file

@ -0,0 +1,4 @@
%li
= link_to pet_type do
= pet_type_image pet_type, :happy, :thumb
.name= pet_type.human_name

View file

@ -0,0 +1,16 @@
- title "Rainbow Pool"
- use_responsive_design
= form_with method: :get do |form|
= form.select :color, @color_names, selected: @selected_color&.human_name, include_blank: "Color…"
= form.select :species, @species_names, selected: @selected_species&.human_name, include_blank: "Species…"
= form.submit "Filter"
= will_paginate @pet_types
%ui.pet-types= render @pet_types
= will_paginate @pet_types
- content_for :stylesheets do
= stylesheet_link_tag "pet_types/index"

View file

@ -0,0 +1,19 @@
- title "#{@pet_type.human_name}"
- use_responsive_design
%ul.pet-states
= render @pet_states[:canonical]
- if @pet_states[:other].present?
%details
%summary Other
%ul.pet-states
= render @pet_states[:other]
- content_for :stylesheets do
= stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer"
= stylesheet_link_tag "pet_types/show"
- content_for :javascripts do
= javascript_include_tag "outfit-viewer", async: true

View file

@ -37,6 +37,10 @@ OpenneoImpressItems::Application.routes.draw do
end end
resources :alt_styles, path: 'alt-styles', only: [:index] resources :alt_styles, path: 'alt-styles', only: [:index]
resources :swf_assets, path: 'swf-assets', only: [:show] resources :swf_assets, path: 'swf-assets', only: [:show]
resources :pet_types, path: 'rainbow-pool', param: "name",
only: [:index, :show] do
resources :pet_states, only: [:show], path: "forms"
end
# Loading and modeling pets! # Loading and modeling pets!
post '/pets/load' => 'pets#load', :as => :load_pet post '/pets/load' => 'pets#load', :as => :load_pet