Support zone restriction in new item preview

Adapted from wardrobe-2020's `getVisibleLayers`! Thanks past-Matchu for
all the comments lol!
This commit is contained in:
Emi Matchu 2024-07-01 16:54:39 -07:00
parent 74748acaaf
commit 3f38fbd1b0
5 changed files with 91 additions and 25 deletions

View file

@ -187,7 +187,7 @@ class ItemsController < ApplicationController
appearance_params[:color_id], appearance_params[:species_id])
end
target.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]).
target.appearances_for(@items, swf_asset_includes: [:zone]).
tap do |appearances|
# Preload the manifests for these SWF assets concurrently, rather than
# loading them in sequence when we generate the JSON.
@ -223,7 +223,7 @@ class ItemsController < ApplicationController
def validate_preview
if @selected_preview_pet_type.new_record?
:pet_type_does_not_exist
elsif @preview_outfit.item_appearances.values.any?(&:empty?)
elsif @preview_outfit.item_appearances.any?(&:empty?)
:no_item_data
end
end

View file

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

View file

@ -555,17 +555,23 @@ class Item < ApplicationRecord
# 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
# bit more suited to it!
Appearance = Struct.new(:body, :swf_assets) do
Appearance = Struct.new(:item, :body, :swf_assets) do
include ActiveModel::Serializers::JSON
delegate :present?, :empty?, to: :swf_assets
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
Appearance::Body = Struct.new(:id, :species) do
include ActiveModel::Serializers::JSON
def attributes
{id: id, species: species}
{id:, species:}
end
end
@ -582,7 +588,7 @@ class Item < ApplicationRecord
# If there are no body-specific assets, return one appearance for them all.
if swf_assets_by_body_id.empty?
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
# Otherwise, create an appearance for each real (nonzero) body ID. We don't
@ -592,22 +598,22 @@ class Item < ApplicationRecord
swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies
species = Species.with_body_id(body_id).first!
body = Appearance::Body.new(body_id, species)
Appearance.new(body, swf_assets_for_body)
Appearance.new(self, body, swf_assets_for_body)
end
end
def appearance_for(target, ...)
Item.appearances_for([id], target, ...)[id]
Item.appearances_for([self], target, ...)[id]
end
# Given a list of item IDs, return how they look on the given target (either
# a pet type or an alt style).
def self.appearances_for(item_ids, target, swf_asset_includes: [])
# Given a list of items, return how they look on the given target (either a
# pet type or an alt style).
def self.appearances_for(items, target, swf_asset_includes: [])
# First, load all the relationships for these items that also fit this
# body.
relationships = ParentSwfAssetRelationship.
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]})
pet_type_body = Appearance::Body.new(target.body_id, target.species)
@ -618,13 +624,13 @@ class Item < ApplicationRecord
transform_values { |rels| rels.map(&:swf_asset) }
# Finally, for each item, return an appearance—even if it's empty!
item_ids.to_h do |item_id|
assets = assets_by_item_id.fetch(item_id, [])
items.to_h do |item|
assets = assets_by_item_id.fetch(item.id, [])
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
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

View file

@ -160,13 +160,73 @@ class Outfit < ApplicationRecord
end
def item_appearances(...)
Item.appearances_for(worn_item_ids, pet_type, ...)
Item.appearances_for(worn_items, pet_type, ...).values
end
def visible_layers
pet_layers = pet_state.swf_assets.includes(:zone)
item_layers = item_appearances(swf_asset_includes: [:zone]).values.
map(&:swf_assets).flatten
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

View file

@ -124,9 +124,9 @@ class PetType < ApplicationRecord
}.first
end
# Given a list of item IDs, return how they look on this pet type.
def appearances_for(item_ids, ...)
Item.appearances_for(item_ids, self, ...)
# Given a list of items, return how they look on this pet type.
def appearances_for(item, ...)
Item.appearances_for(item, self, ...)
end
def self.all_by_ids_or_children(ids, pet_states)