1
0
Fork 0
forked from OpenNeo/impress

Add alt style support to Outfit#visible_layers

This commit is contained in:
Emi Matchu 2026-01-03 11:05:23 -08:00
parent cf80f96410
commit f823bac717
2 changed files with 207 additions and 24 deletions

View file

@ -170,52 +170,67 @@ class Outfit < ApplicationRecord
end
def visible_layers
# TODO: This method doesn't currently handle alt styles! If the outfit has
# an alt_style, we should use its layers instead of pet_state layers, and
# filter items to only those with body_id=0. This isn't needed yet because
# this method is only used on item pages, which don't support alt styles.
# See useOutfitAppearance.js for the complete logic including alt styles.
item_appearances = item_appearances(swf_asset_includes: [:zone])
# Step 1: Choose biology layers - use alt style if present, otherwise pet state
if alt_style
biology_layers = alt_style.swf_assets.includes(:zone).to_a
body = alt_style
using_alt_style = true
else
biology_layers = pet_state.swf_assets.includes(:zone).to_a
body = pet_type
using_alt_style = false
end
pet_layers = pet_state.swf_assets.includes(:zone).to_a
# Step 2: Load item appearances for the appropriate body
item_appearances = Item.appearances_for(
worn_items,
body,
swf_asset_includes: [:zone]
).values
item_layers = item_appearances.map(&:swf_assets).flatten
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
# For alt styles, only body_id=0 items are compatible
if using_alt_style
item_layers.reject! { |sa| sa.body_id != 0 }
end
# Step 3: Apply restriction rules
biology_restricted_zone_ids = biology_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.
# Rule 3a: When an item restricts a zone, it hides biology 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) }
biology_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?)
# Rule 3b: When a biology 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
# NOTE: This can result in both biology 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
# NOTE: We used to also include the biology 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.
# above biology 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
@ -232,14 +247,16 @@ class Outfit < ApplicationRecord
item_layers.reject! { |sa| sa.body_specific? }
else
item_layers.reject! { |sa| sa.body_specific? &&
pet_restricted_zone_ids.include?(sa.zone_id) }
biology_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) }
# Rule 3c: A biology appearance can also restrict its own zones. The Wraith
# Uni is an interesting example: it has a horn, but its zone restrictions
# hide it!
biology_layers.reject! { |sa| biology_restricted_zone_ids.include?(sa.zone_id) }
(pet_layers + item_layers).sort_by(&:depth)
# Step 4: Sort by depth and return
(biology_layers + item_layers).sort_by(&:depth)
end
def wardrobe_params

View file

@ -8,7 +8,7 @@ RSpec.describe Outfit do
pet_state = PetState.create!(
pet_type: pet_type,
pose: pose,
swf_asset_ids: swf_assets.map(&:id)
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
)
pet_state.swf_assets = swf_assets
pet_state
@ -376,5 +376,171 @@ RSpec.describe Outfit do
expect(layers).to eq([background, bg_item, hat_asset, body_layer, shirt_asset, head_layer])
end
end
context "alt styles (alternative pet appearances)" do
before do
# Create an alt style with its own body_id distinct from regular pets
@alt_style = AltStyle.create!(
species: species(:acara),
color: colors(:blue),
body_id: 999, # Distinct from the regular pet's body_id (1)
series_name: "Nostalgic",
thumbnail_url: "https://images.neopets.example/alt_style.png"
)
end
it "uses alt style layers instead of pet state layers" do
# Create regular pet layers
regular_head = build_biology_asset(zones(:head), body_id: 1)
regular_body = build_biology_asset(zones(:body), body_id: 1)
pet_state = build_pet_state(@pet_type, swf_assets: [regular_head, regular_body])
# Create alt style layers (with the alt style's body_id)
alt_head = build_biology_asset(zones(:head), body_id: 999)
alt_body = build_biology_asset(zones(:body), body_id: 999)
@alt_style.swf_assets = [alt_head, alt_body]
# Create outfit with alt_style
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
layers = outfit.visible_layers
# Should use alt style layers, not pet state layers
expect(layers).to contain_exactly(alt_head, alt_body)
expect(layers).not_to include(regular_head, regular_body)
end
it "only includes body_id=0 items with alt styles" do
# Create alt style layers
alt_head = build_biology_asset(zones(:head), body_id: 999)
@alt_style.swf_assets = [alt_head]
pet_state = build_pet_state(@pet_type)
# Create a body-specific item for the alt style's body_id
body_specific_asset = build_item_asset(zones(:hat), body_id: 999)
body_specific_item = build_item("Body-specific Hat", swf_assets: [body_specific_asset])
# Create a universal item (body_id=0)
universal_asset = build_item_asset(zones(:background), body_id: 0)
universal_item = build_item("Universal Background", swf_assets: [universal_asset])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [body_specific_item, universal_item]
layers = outfit.visible_layers
# Only the universal item should appear
expect(layers).to contain_exactly(alt_head, universal_asset)
expect(layers).not_to include(body_specific_asset)
end
it "does not include items from the regular pet's body_id" do
# Create alt style layers
alt_body = build_biology_asset(zones(:body), body_id: 999)
@alt_style.swf_assets = [alt_body]
pet_state = build_pet_state(@pet_type)
# Create an item that fits the regular pet's body_id (1)
regular_item_asset = build_item_asset(zones(:hat), body_id: 1)
regular_item = build_item("Regular Pet Hat", swf_assets: [regular_item_asset])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [regular_item]
layers = outfit.visible_layers
# The regular pet item should not appear on the alt style
expect(layers).to contain_exactly(alt_body)
expect(layers).not_to include(regular_item_asset)
end
it "applies item restriction rules with alt styles" do
# Create alt style layers including hair
alt_head = build_biology_asset(zones(:head), body_id: 999)
alt_hair = build_biology_asset(zones(:hairfront), body_id: 999)
@alt_style.swf_assets = [alt_head, alt_hair]
pet_state = build_pet_state(@pet_type)
# Create a universal hat that restricts the hair zone
zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 (Hair Front) = 1
hat_asset = build_item_asset(zones(:hat), body_id: 0, zones_restrict: zones_restrict)
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [hat]
layers = outfit.visible_layers
# Hair should be hidden by the hat's zone restrictions
expect(layers).to contain_exactly(alt_head, hat_asset)
expect(layers).not_to include(alt_hair)
end
it "applies pet restriction rules with alt styles" do
# Create alt style with a layer that restricts a zone
alt_head = build_biology_asset(zones(:head), body_id: 999)
zones_restrict = "0" * 47 + "1" + "0" * 10 # bit 48 (Background Item) = 1
restricting_layer = build_biology_asset(zones(:body), body_id: 999, zones_restrict: zones_restrict)
@alt_style.swf_assets = [alt_head, restricting_layer]
pet_state = build_pet_state(@pet_type)
# Create a universal Background Item
bg_item_asset = build_item_asset(zones(:backgrounditem), body_id: 0)
bg_item = build_item("Universal Background Item", swf_assets: [bg_item_asset])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [bg_item]
layers = outfit.visible_layers
# body_id=0 items should still appear even in restricted zones
# (because they're not body-specific)
expect(layers).to contain_exactly(alt_head, restricting_layer, bg_item_asset)
end
it "applies self-restriction rules with alt styles" do
# Create alt style that restricts its own horn layer
alt_body = build_biology_asset(zones(:body), body_id: 999)
alt_horn = build_biology_asset(zones(:headtransientbiology), body_id: 999)
zones_restrict = "0" * 37 + "1" + "0" * 20 # bit 38 (Head Transient Biology) = 1
restricting_layer = build_biology_asset(zones(:head), body_id: 999, zones_restrict: zones_restrict)
@alt_style.swf_assets = [alt_body, alt_horn, restricting_layer]
pet_state = build_pet_state(@pet_type)
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
layers = outfit.visible_layers
# The horn should be hidden by the alt style's own restrictions
expect(layers).to contain_exactly(alt_body, restricting_layer)
expect(layers).not_to include(alt_horn)
end
it "sorts alt style and item layers by depth correctly" do
# Create alt style layers at various depths
alt_background = build_biology_asset(zones(:background), body_id: 999) # depth 3
alt_body = build_biology_asset(zones(:body), body_id: 999) # depth 18
alt_head = build_biology_asset(zones(:head), body_id: 999) # depth 34
@alt_style.swf_assets = [alt_head, alt_background, alt_body]
pet_state = build_pet_state(@pet_type)
# Add universal items at various depths
bg_item = build_item_asset(zones(:backgrounditem), body_id: 0) # depth 4
trinket = build_item_asset(zones(:righthanditem), body_id: 0) # depth 5
bg = build_item("Background Item", swf_assets: [bg_item])
trinket_item = build_item("Trinket", swf_assets: [trinket])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [trinket_item, bg]
layers = outfit.visible_layers
# Expected order by depth:
# alt_background (3), bg_item (4), trinket (5), alt_body (18), alt_head (34)
expect(layers.map(&:depth)).to eq([3, 4, 5, 18, 34])
expect(layers).to eq([alt_background, bg_item, trinket, alt_body, alt_head])
end
end
end
end