Compare commits

...

6 commits

Author SHA1 Message Date
0a9193aed7 Add basic loading tracking to new item page preview
The UI for it is just basic for my own testing rn: it sets the preview
background to gray while loading, then back to white when done!

This uses the new CSS `:has()` selector: we have JS manage the loading
state on each layer, then the container just restyles itself based on
whether any currently-loading layers are present.
2024-07-01 17:59:07 -07:00
ac002f3151 Track preferred color/species for new item page previews
Also adapted from the Impress 2020 logic!

Note that I refactored `compatible_pet_type` to a series of scopes on
`PetType`. I think this is a simpler, clearer, and more flexible API!
2024-07-01 17:38:31 -07:00
fe6035d438 Default to compatible pet types in new item page preview
Just adapted from Impress 2020 logic again, easy peasy!
2024-07-01 17:20:38 -07:00
21a8a49f50 Remove redundant SwfAsset relation
Huh, this is just in here twice? Weird. Goodbye!
2024-07-01 17:20:05 -07:00
3f38fbd1b0 Support zone restriction in new item preview
Adapted from wardrobe-2020's `getVisibleLayers`! Thanks past-Matchu for
all the comments lol!
2024-07-01 16:54:39 -07:00
74748acaaf Refactor more of item outfit preview into the Outfit class
This is a cute thing that I think sets us up for other stuff down the
line: move more of the outfit appearance logic into the `Outfit` class!
Now, we set up the item page with a temporary instance of `Outfit`,
then ask for its `visible_layers`.

Still missing restricted-zones logic and such, that's next!
2024-07-01 16:07:25 -07:00
10 changed files with 180 additions and 58 deletions

View file

@ -38,7 +38,7 @@ body.items-show
height: 16px height: 16px
width: 16px width: 16px
outfit-viewer .outfit-viewer
position: relative position: relative
display: block display: block
width: 300px width: 300px
@ -49,7 +49,7 @@ body.items-show
margin: 0 auto .75em margin: 0 auto .75em
outfit-layer .outfit-layer
display: block display: block
position: absolute position: absolute
inset: 0 inset: 0
@ -58,6 +58,9 @@ body.items-show
width: 100% width: 100%
height: 100% height: 100%
&:has(.outfit-layer[data-status="loading"])
background: gray
.species-color-picker .species-color-picker
.error-icon .error-icon
cursor: help cursor: help

View file

@ -84,14 +84,10 @@ class ItemsController < ApplicationController
end end
@selected_preview_pet_type = load_selected_preview_pet_type @selected_preview_pet_type = load_selected_preview_pet_type
@preview_pet_type = load_preview_pet_type @preview_outfit = Outfit.new(
pet_state: load_preview_pet_type.canonical_pet_state,
@item_layers = @item.appearance_for( worn_items: [@item],
@preview_pet_type, swf_asset_includes: [:zone] )
).swf_assets
@pet_layers = @preview_pet_type.canonical_pet_state.swf_assets.
includes(:zone)
@preview_error = validate_preview @preview_error = validate_preview
end end
@ -191,7 +187,7 @@ class ItemsController < ApplicationController
appearance_params[:color_id], appearance_params[:species_id]) appearance_params[:color_id], appearance_params[:species_id])
end end
target.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]). target.appearances_for(@items, swf_asset_includes: [:zone]).
tap do |appearances| tap do |appearances|
# Preload the manifests for these SWF assets concurrently, rather than # Preload the manifests for these SWF assets concurrently, rather than
# loading them in sequence when we generate the JSON. # loading them in sequence when we generate the JSON.
@ -206,7 +202,12 @@ class ItemsController < ApplicationController
return load_default_preview_pet_type if color_id.nil? || species_id.nil? return load_default_preview_pet_type if color_id.nil? || species_id.nil?
PetType.find_or_initialize_by(color_id:, species_id:) PetType.find_or_initialize_by(color_id:, species_id:).tap do |pet_type|
if pet_type.persisted?
cookies["preferred-preview-color-id"] = color_id
cookies["preferred-preview-species-id"] = species_id
end
end
end end
def load_preview_pet_type def load_preview_pet_type
@ -218,16 +219,16 @@ class ItemsController < ApplicationController
end end
def load_default_preview_pet_type def load_default_preview_pet_type
PetType.find_by_color_id_and_species_id( @item.compatible_pet_types.
Color.find_by_name("Blue"), preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
Species.find_by_name("Acara"), preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
) preferring_simple.first
end end
def validate_preview def validate_preview
if @selected_preview_pet_type.new_record? if @selected_preview_pet_type.new_record?
:pet_type_does_not_exist :pet_type_does_not_exist
elsif @item_layers.empty? elsif @preview_outfit.item_appearances.any?(&:empty?)
:no_item_data :no_item_data
end end
end end

View file

@ -250,7 +250,7 @@ module ItemsHelper
end end
def outfit_viewer_layer(swf_asset) def outfit_viewer_layer(swf_asset)
content_tag "outfit-layer", style: "z-index: #{swf_asset.zone.depth}" do content_tag :div, class: "outfit-layer" do
image_tag swf_asset.image_url, alt: "" image_tag swf_asset.image_url, alt: ""
end end
end end

View file

@ -1,2 +1,32 @@
// eslint-disable-next-line no-console // When we load in a new preview, set the status on the images. We use this in
console.log("OwO!"); // our CSS to show a loading state when needed.
function setImageStatuses() {
for (const layer of document.querySelectorAll(".outfit-layer")) {
const isLoaded = layer.querySelector("img").complete;
layer.setAttribute("data-status", isLoaded ? "loaded" : "loading");
}
}
document.addEventListener("turbo:frame-render", () => setImageStatuses());
document.addEventListener("turbo:load", () => setImageStatuses());
// When a preview image loads or fails, update its status. (Note that `load`
// does not fire for images that were loaded from cache, which is why we need
// both this and `setImageStatuses` when rendering new images!)
document.addEventListener(
"load",
({ target }) => {
if (target.matches(".outfit-layer img")) {
target.closest(".outfit-layer").setAttribute("data-status", "loaded");
}
},
{ capture: true },
);
document.addEventListener(
"error",
({ target }) => {
if (target.matches(".outfit-layer img")) {
target.closest(".outfit-layer").setAttribute("data-status", "error");
}
},
{ capture: true },
);

View file

@ -49,9 +49,9 @@ class AltStyle < ApplicationRecord
swf_asset.image_url swf_asset.image_url
end end
# Given a list of item IDs, return how they look on this alt style. # Given a list of items, return how they look on this alt style.
def appearances_for(item_ids, ...) def appearances_for(items, ...)
Item.appearances_for(item_ids, self, ...) Item.appearances_for(items, self, ...)
end end
def biology=(biology) def biology=(biology)

View file

@ -489,6 +489,15 @@ class Item < ApplicationRecord
}.merge(options)) }.merge(options))
end end
def compatible_body_ids
swf_assets.map(&:body_id).uniq
end
def compatible_pet_types
return PetType.all if compatible_body_ids.include?(0)
PetType.where(body_id: compatible_body_ids)
end
def handle_assets! def handle_assets!
if @parent_swf_asset_relationships_to_update && @current_body_id if @parent_swf_asset_relationships_to_update && @current_body_id
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id) new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id)
@ -555,16 +564,23 @@ class Item < ApplicationRecord
# instead of like a hash, so you can target its children with things like # instead of like a hash, so you can target its children with things like
# the `include` option. This feels clunky though, I wish I had something a # the `include` option. This feels clunky though, I wish I had something a
# bit more suited to it! # bit more suited to it!
Appearance = Struct.new(:body, :swf_assets) do Appearance = Struct.new(:item, :body, :swf_assets) do
include ActiveModel::Serializers::JSON include ActiveModel::Serializers::JSON
delegate :present?, :empty?, to: :swf_assets
def attributes def attributes
{body: body, swf_assets: swf_assets} {item:, body:, swf_assets:}
end
def restricted_zone_ids
return [] if empty?
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
end end
end end
Appearance::Body = Struct.new(:id, :species) do Appearance::Body = Struct.new(:id, :species) do
include ActiveModel::Serializers::JSON include ActiveModel::Serializers::JSON
def attributes def attributes
{id: id, species: species} {id:, species:}
end end
end end
@ -581,7 +597,7 @@ class Item < ApplicationRecord
# If there are no body-specific assets, return one appearance for them all. # If there are no body-specific assets, return one appearance for them all.
if swf_assets_by_body_id.empty? if swf_assets_by_body_id.empty?
body = Appearance::Body.new(0, nil) body = Appearance::Body.new(0, nil)
return [Appearance.new(body, swf_assets_for_all_bodies)] return [Appearance.new(self, body, swf_assets_for_all_bodies)]
end end
# Otherwise, create an appearance for each real (nonzero) body ID. We don't # Otherwise, create an appearance for each real (nonzero) body ID. We don't
@ -591,22 +607,22 @@ class Item < ApplicationRecord
swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies
species = Species.with_body_id(body_id).first! species = Species.with_body_id(body_id).first!
body = Appearance::Body.new(body_id, species) body = Appearance::Body.new(body_id, species)
Appearance.new(body, swf_assets_for_body) Appearance.new(self, body, swf_assets_for_body)
end end
end end
def appearance_for(target, ...) def appearance_for(target, ...)
Item.appearances_for([id], target, ...)[id] Item.appearances_for([self], target, ...)[id]
end end
# Given a list of item IDs, return how they look on the given target (either # Given a list of items, return how they look on the given target (either a
# a pet type or an alt style). # pet type or an alt style).
def self.appearances_for(item_ids, target, swf_asset_includes: []) def self.appearances_for(items, target, swf_asset_includes: [])
# First, load all the relationships for these items that also fit this # First, load all the relationships for these items that also fit this
# body. # body.
relationships = ParentSwfAssetRelationship. relationships = ParentSwfAssetRelationship.
includes(swf_asset: swf_asset_includes). includes(swf_asset: swf_asset_includes).
where(parent_type: "Item", parent_id: item_ids). where(parent_type: "Item", parent_id: items.map(&:id)).
where(swf_asset: {body_id: [target.body_id, 0]}) where(swf_asset: {body_id: [target.body_id, 0]})
pet_type_body = Appearance::Body.new(target.body_id, target.species) pet_type_body = Appearance::Body.new(target.body_id, target.species)
@ -617,13 +633,13 @@ class Item < ApplicationRecord
transform_values { |rels| rels.map(&:swf_asset) } transform_values { |rels| rels.map(&:swf_asset) }
# Finally, for each item, return an appearance—even if it's empty! # Finally, for each item, return an appearance—even if it's empty!
item_ids.to_h do |item_id| items.to_h do |item|
assets = assets_by_item_id.fetch(item_id, []) assets = assets_by_item_id.fetch(item.id, [])
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 } fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
body = fits_all_pets ? all_pets_body : pet_type_body body = fits_all_pets ? all_pets_body : pet_type_body
[item_id, Appearance.new(body, assets)] [item.id, Appearance.new(item, body, assets)]
end end
end end

View file

@ -25,7 +25,12 @@ class Outfit < ApplicationRecord
before_validation :ensure_unique_name, if: :user_id? before_validation :ensure_unique_name, if: :user_id?
attr_reader :biology attr_reader :biology
delegate :color, to: :pet_state delegate :pose, to: :pet_state
delegate :pet_type, to: :pet_state
delegate :color, to: :pet_type
delegate :color_id, to: :pet_type
delegate :species, to: :pet_type
delegate :species_id, to: :pet_type
scope :wardrobe_order, -> { order('starred DESC', :name) } scope :wardrobe_order, -> { order('starred DESC', :name) }
@ -107,18 +112,6 @@ class Outfit < ApplicationRecord
) )
end end
def color_id
pet_state.pet_type.color_id
end
def species_id
pet_state.pet_type.species_id
end
def pose
pet_state.pose
end
def biology=(biology) def biology=(biology)
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id) @biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
@ -166,6 +159,78 @@ class Outfit < ApplicationRecord
self.item_outfit_relationships = new_relationships self.item_outfit_relationships = new_relationships
end end
def item_appearances(...)
Item.appearances_for(worn_items, pet_type, ...).values
end
def visible_layers
item_appearances = item_appearances(swf_asset_includes: [:zone])
pet_layers = pet_state.swf_assets.includes(:zone).to_a
item_layers = item_appearances.map(&:swf_assets).flatten
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
flatten.to_set
item_restricted_zone_ids = item_appearances.
map(&:restricted_zone_ids).flatten.to_set
# When an item restricts a zone, it hides pet layers of the same zone.
# We use this to e.g. make a hat hide a hair ruff.
#
# NOTE: Items' restricted layers also affect what items you can wear at
# the same time. We don't enforce anything about that here, and
# instead assume that the input by this point is valid!
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
# When a pet appearance restricts a zone, or when the pet is Unconverted,
# it makes body-specific items incompatible. We use this to disallow UCs
# from wearing certain body-specific Biology Effects, Statics, etc, while
# still allowing non-body-specific items in those zones! (I think this
# happens for some Invisible pet stuff, too?)
#
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
# should be doing this way earlier, to prevent the item from even
# showing up even in search results!
#
# NOTE: This can result in both pet layers and items occupying the same
# zone, like Static, so long as the item isn't body-specific! That's
# correct, and the item layer should be on top! (Here, we implement
# it by placing item layers second in the list, and rely on JS sort
# stability, and *then* rely on the UI to respect that ordering when
# rendering them by depth. Not great! 😅)
#
# NOTE: We used to also include the pet appearance's *occupied* zones in
# this condition, not just the restricted zones, as a sensible
# defensive default, even though we weren't aware of any relevant
# items. But now we know that actually the "Bruce Brucey B Mouth"
# occupies the real Mouth zone, and still should be visible and
# above pet layers! So, we now only check *restricted* zones.
#
# NOTE: UCs used to implement their restrictions by listing specific
# zones, but it seems that the logic has changed to just be about
# UC-ness and body-specific-ness, and not necessarily involve the
# set of restricted zones at all. (This matters because e.g. UCs
# shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
# don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
# zone restriction case running too, because I don't think it
# _hurts_ anything, and I'm not confident enough in this conclusion.
#
# TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
# use zone restrictions?
if pet_state.pose === "UNCONVERTED"
item_layers.reject! { |sa| sa.body_specific? }
else
item_layers.reject! { |sa| sa.body_specific? &&
pet_restricted_zone_ids.include?(sa.zone_id) }
end
# A pet appearance can also restrict its own zones. The Wraith Uni is an
# interesting example: it has a horn, but its zone restrictions hide it!
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
(pet_layers + item_layers).sort_by(&:depth)
end
def ensure_unique_name def ensure_unique_name
# If no name was provided, start with "Untitled outfit". # If no name was provided, start with "Untitled outfit".
self.name = "Untitled outfit" if name.blank? self.name = "Untitled outfit" if name.blank?

View file

@ -16,6 +16,17 @@ 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 :preferring_species, ->(species_id) {
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
}
scope :preferring_color, ->(color_id) {
joins(:color).order([Arel.sql("color_id = ? DESC"), color_id])
}
scope :preferring_simple, -> {
joins(:species, :color).
merge(Species.order(name: :asc)).
merge(Color.order(basic: :desc, standard: :desc, name: :asc))
}
def self.special_color_or_basic(special_color) def self.special_color_or_basic(special_color)
color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id) color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id)
@ -124,9 +135,9 @@ class PetType < ApplicationRecord
}.first }.first
end end
# Given a list of item IDs, return how they look on this pet type. # Given a list of items, return how they look on this pet type.
def appearances_for(item_ids, ...) def appearances_for(item, ...)
Item.appearances_for(item_ids, self, ...) Item.appearances_for(item, self, ...)
end end
def self.all_by_ids_or_children(ids, pet_states) def self.all_by_ids_or_children(ids, pet_states)

View file

@ -15,7 +15,6 @@ class SwfAsset < ApplicationRecord
belongs_to :zone belongs_to :zone
has_many :parent_swf_asset_relationships has_many :parent_swf_asset_relationships
has_one :contribution, :as => :contributed, :inverse_of => :contributed has_one :contribution, :as => :contributed, :inverse_of => :contributed
has_many :parent_swf_asset_relationships
before_validation :normalize_manifest_url, if: :manifest_url? before_validation :normalize_manifest_url, if: :manifest_url?

View file

@ -14,11 +14,8 @@
sorry! sorry!
= turbo_frame_tag "item-preview" do = turbo_frame_tag "item-preview" do
%outfit-viewer .outfit-viewer
%outfit-pet-appearance = outfit_viewer_layers @preview_outfit.visible_layers
= outfit_viewer_layers @pet_layers
%outfit-item-appearance
= outfit_viewer_layers @item_layers
= form_for item_path(@item), method: :get, class: "species-color-picker", = form_for item_path(@item), method: :get, class: "species-color-picker",
data: {"is-valid": @preview_error.nil?} do |f| data: {"is-valid": @preview_error.nil?} do |f|