diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 5e2481a..3184f81 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -1818,7 +1818,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { victoryDrops = e.onEnemyDeath(hero, enemy, now) } - dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{ MonstersKilled: 1, @@ -1829,7 +1828,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { }) cancel() - e.emitEvent(model.CombatEvent{ Type: "combat_end", HeroID: cs.HeroID, diff --git a/backend/internal/game/hero_meet.go b/backend/internal/game/hero_meet.go index fe11486..fd83798 100644 --- a/backend/internal/game/hero_meet.go +++ b/backend/internal/game/hero_meet.go @@ -386,11 +386,12 @@ func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase hm.SyncToHero() px, py := ph.Hero.PositionX, ph.Hero.PositionY partner := model.HeroMeetPartnerSnapshot{ - ID: ph.Hero.ID, - Name: ph.Hero.Name, - Level: ph.Hero.Level, - PositionX: px, - PositionY: py, + ID: ph.Hero.ID, + Name: ph.Hero.Name, + Level: ph.Hero.Level, + ModelVariant: ph.Hero.ModelVariant, + PositionX: px, + PositionY: py, } anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid) var promptEnds *time.Time diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 221493b..bf1d4d9 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -1618,15 +1618,15 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) { return } - // Default radius: 500 units, max 50 heroes. - radius := 500.0 + // Default radius: 50 units, max 5 heroes. + radius := 50.0 if rStr := r.URL.Query().Get("radius"); rStr != "" { if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 { radius = parsed } } - if radius > 2000 { - radius = 2000 + if radius > 100 { + radius = 100 } posX, posY := hero.PositionX, hero.PositionY @@ -1636,7 +1636,7 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) { } } - nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 50) + nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 5) if err != nil { h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index e0a6674..5bba636 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -13,57 +13,58 @@ const ( ) type Hero struct { - ID int64 `json:"id"` - TelegramID int64 `json:"telegramId"` - Name string `json:"name"` - HP int `json:"hp"` - MaxHP int `json:"maxHp"` - Attack int `json:"attack"` - Defense int `json:"defense"` - Speed float64 `json:"speed"` // attacks per second base rate - Strength int `json:"strength"` - Constitution int `json:"constitution"` - Agility int `json:"agility"` - Luck int `json:"luck"` - State GameState `json:"state"` + ID int64 `json:"id"` + TelegramID int64 `json:"telegramId"` + Name string `json:"name"` + ModelVariant int `json:"modelVariant"` + HP int `json:"hp"` + MaxHP int `json:"maxHp"` + Attack int `json:"attack"` + Defense int `json:"defense"` + Speed float64 `json:"speed"` // attacks per second base rate + Strength int `json:"strength"` + Constitution int `json:"constitution"` + Agility int `json:"agility"` + Luck int `json:"luck"` + State GameState `json:"state"` Gear map[EquipmentSlot]*GearItem `json:"gear"` // Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots. - Inventory []*GearItem `json:"inventory,omitempty"` - Buffs []ActiveBuff `json:"buffs,omitempty"` - Debuffs []ActiveDebuff `json:"debuffs,omitempty"` + Inventory []*GearItem `json:"inventory,omitempty"` + Buffs []ActiveBuff `json:"buffs,omitempty"` + Debuffs []ActiveDebuff `json:"debuffs,omitempty"` // DebuffCatalog is effective debuff definitions (durations from live catalog); not persisted. - DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"` - Gold int64 `json:"gold"` - XP int64 `json:"xp"` - Level int `json:"level"` - XPToNext int64 `json:"xpToNext"` - AttackSpeed float64 `json:"attackSpeed,omitempty"` - AttackPower int `json:"attackPower,omitempty"` - DefensePower int `json:"defensePower,omitempty"` - MoveSpeed float64 `json:"moveSpeed,omitempty"` - PositionX float64 `json:"positionX"` - PositionY float64 `json:"positionY"` - Potions int `json:"potions"` - ReviveCount int `json:"reviveCount"` - SubscriptionActive bool `json:"subscriptionActive"` - SubscriptionExpiresAt *time.Time `json:"subscriptionExpiresAt,omitempty"` + DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"` + Gold int64 `json:"gold"` + XP int64 `json:"xp"` + Level int `json:"level"` + XPToNext int64 `json:"xpToNext"` + AttackSpeed float64 `json:"attackSpeed,omitempty"` + AttackPower int `json:"attackPower,omitempty"` + DefensePower int `json:"defensePower,omitempty"` + MoveSpeed float64 `json:"moveSpeed,omitempty"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` + Potions int `json:"potions"` + ReviveCount int `json:"reviveCount"` + SubscriptionActive bool `json:"subscriptionActive"` + SubscriptionExpiresAt *time.Time `json:"subscriptionExpiresAt,omitempty"` // Deprecated: BuffFreeChargesRemaining is the legacy shared counter. Use BuffCharges instead. - BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"` + BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"` // Deprecated: BuffQuotaPeriodEnd is the legacy shared period end. Use BuffCharges instead. - BuffQuotaPeriodEnd *time.Time `json:"buffQuotaPeriodEnd,omitempty"` + BuffQuotaPeriodEnd *time.Time `json:"buffQuotaPeriodEnd,omitempty"` // BuffCharges holds per-buff-type free charge state (remaining count + period window). BuffCharges map[string]BuffChargeState `json:"buffCharges"` // Stat tracking for achievements. - TotalKills int `json:"totalKills"` - EliteKills int `json:"eliteKills"` - TotalDeaths int `json:"totalDeaths"` + TotalKills int `json:"totalKills"` + EliteKills int `json:"eliteKills"` + TotalDeaths int `json:"totalDeaths"` KillsSinceDeath int `json:"killsSinceDeath"` - LegendaryDrops int `json:"legendaryDrops"` + LegendaryDrops int `json:"legendaryDrops"` // Movement state (persisted to DB for reconnect recovery). - CurrentTownID *int64 `json:"currentTownId,omitempty"` - DestinationTownID *int64 `json:"destinationTownId,omitempty"` - RestKind RestKind `json:"restKind,omitempty"` + CurrentTownID *int64 `json:"currentTownId,omitempty"` + DestinationTownID *int64 `json:"destinationTownId,omitempty"` + RestKind RestKind `json:"restKind,omitempty"` // ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise. ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"` // ExcursionKind is "roadside" | "adventure" | "town" during attractor-based sessions; empty otherwise. @@ -80,9 +81,9 @@ type Hero struct { // WsDisconnectedAt is when the last WebSocket session ended (DB only; optional telemetry). WsDisconnectedAt *time.Time `json:"-"` // ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only). - ChangelogAckVersion string `json:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ChangelogAckVersion string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // BuffChargeState tracks the remaining free charges and period window for a single buff type. @@ -139,7 +140,7 @@ func (h *Hero) LevelUp() bool { h.MaxHP += hpBase + h.Constitution/6 } if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 { - h.Attack ++ + h.Attack++ } if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 { h.Defense++ @@ -162,29 +163,29 @@ func (h *Hero) LevelUp() bool { } type statBonuses struct { - strengthBonus int - constitutionBonus int - agilityBonus int - attackMultiplier float64 - speedMultiplier float64 - defenseMultiplier float64 - critChanceBonus float64 - critDamageBonus float64 - blockChanceBonus float64 + strengthBonus int + constitutionBonus int + agilityBonus int + attackMultiplier float64 + speedMultiplier float64 + defenseMultiplier float64 + critChanceBonus float64 + critDamageBonus float64 + blockChanceBonus float64 movementMultiplier float64 } func (h *Hero) activeStatBonuses(now time.Time) statBonuses { out := statBonuses{ - strengthBonus: 0, - constitutionBonus: 0, - agilityBonus: 0, - attackMultiplier: 1.0, - speedMultiplier: 1.0, - defenseMultiplier: 1.0, - critChanceBonus: 0.0, - critDamageBonus: 0.0, - blockChanceBonus: 0.0, + strengthBonus: 0, + constitutionBonus: 0, + agilityBonus: 0, + attackMultiplier: 1.0, + speedMultiplier: 1.0, + defenseMultiplier: 1.0, + critChanceBonus: 0.0, + critDamageBonus: 0.0, + blockChanceBonus: 0.0, movementMultiplier: 1.0, } for _, ab := range h.Buffs { diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index 56e6929..c4e8d50 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -286,11 +286,12 @@ type ExcursionEndPayload struct{} // HeroMeetPartnerSnapshot is the other hero for UI (render + name). type HeroMeetPartnerSnapshot struct { - ID int64 `json:"id"` - Name string `json:"name"` - Level int `json:"level"` - PositionX float64 `json:"positionX"` - PositionY float64 `json:"positionY"` + ID int64 `json:"id"` + Name string `json:"name"` + Level int `json:"level"` + ModelVariant int `json:"modelVariant"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` } // HeroMeetStartPayload begins a paired meet session (server → client). diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index a5cf458..4425452 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -21,7 +21,7 @@ import ( // Gear is loaded separately via GearStore.GetHeroGear after the hero row is loaded. const heroSelectQuery = ` SELECT - h.id, h.telegram_id, h.name, + h.id, h.telegram_id, h.name, h.hero_model_variant, h.hp, h.max_hp, h.attack, h.defense, h.speed, h.strength, h.constitution, h.agility, h.luck, h.state, @@ -265,12 +265,14 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro hero.CreatedAt = now hero.UpdatedAt = now + hero.ModelVariant = randomHeroModelVariant() buffChargesJSON := marshalBuffCharges(hero.BuffCharges) query := ` INSERT INTO heroes ( telegram_id, name, + hero_model_variant, hp, max_hp, attack, defense, speed, strength, constitution, agility, luck, state, @@ -284,22 +286,24 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro current_town_id, destination_town_id ) VALUES ( $1, $2, - $3, $4, $5, $6, $7, - $8, $9, $10, $11, - $12, - $13, $14, $15, - $16, $17, $18, - $19, $20, $21, - $22, $23, $24, - $25, $26, $27, $28, $29, - $30, - $31, $32, - $33, $34 + $3, + $4, $5, $6, $7, $8, + $9, $10, $11, $12, + $13, + $14, $15, $16, + $17, $18, $19, + $20, $21, $22, + $23, $24, $25, + $26, $27, $28, $29, $30, + $31, + $32, $33, + $34, $35 ) RETURNING id ` err := s.pool.QueryRow(ctx, query, hero.TelegramID, hero.Name, + hero.ModelVariant, hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed, hero.Strength, hero.Constitution, hero.Agility, hero.Luck, string(hero.State), @@ -553,26 +557,28 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error { query := ` UPDATE heroes SET - hp = $1, max_hp = $2, - attack = $3, defense = $4, speed = $5, - strength = $6, constitution = $7, agility = $8, luck = $9, - state = $10, - gold = $11, xp = $12, level = $13, - revive_count = $14, subscription_active = $15, subscription_expires_at = $16, - buff_free_charges_remaining = $17, buff_quota_period_end = $18, buff_charges = $19, - position_x = $20, position_y = $21, potions = $22, - total_kills = $23, elite_kills = $24, total_deaths = $25, - kills_since_death = $26, legendary_drops = $27, - last_online_at = $28, - updated_at = $29, - destination_town_id = $30, - current_town_id = $31, - town_pause = $32 - WHERE id = $33 + hero_model_variant = $1, + hp = $2, max_hp = $3, + attack = $4, defense = $5, speed = $6, + strength = $7, constitution = $8, agility = $9, luck = $10, + state = $11, + gold = $12, xp = $13, level = $14, + revive_count = $15, subscription_active = $16, subscription_expires_at = $17, + buff_free_charges_remaining = $18, buff_quota_period_end = $19, buff_charges = $20, + position_x = $21, position_y = $22, potions = $23, + total_kills = $24, elite_kills = $25, total_deaths = $26, + kills_since_death = $27, legendary_drops = $28, + last_online_at = $29, + updated_at = $30, + destination_town_id = $31, + current_town_id = $32, + town_pause = $33 + WHERE id = $34 ` townPauseJSON := marshalTownPause(hero.TownPause) tag, err := s.pool.Exec(ctx, query, + hero.ModelVariant, hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed, hero.Strength, hero.Constitution, hero.Agility, hero.Luck, @@ -701,7 +707,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) { var townPauseRaw []byte err := rows.Scan( - &h.ID, &h.TelegramID, &h.Name, + &h.ID, &h.TelegramID, &h.Name, &h.ModelVariant, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.Strength, &h.Constitution, &h.Agility, &h.Luck, &state, @@ -720,6 +726,9 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) { } h.BuffCharges = unmarshalBuffCharges(buffChargesRaw) h.State = model.GameState(state) + if !model.IsValidHeroModelVariant(h.ModelVariant) { + h.ModelVariant = model.HeroModelVariantMin + } h.Gear = make(map[model.EquipmentSlot]*model.GearItem) h.TownPause = unmarshalTownPause(townPauseRaw) @@ -736,7 +745,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) { var townPauseRaw []byte err := row.Scan( - &h.ID, &h.TelegramID, &h.Name, + &h.ID, &h.TelegramID, &h.Name, &h.ModelVariant, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.Strength, &h.Constitution, &h.Agility, &h.Luck, &state, @@ -758,6 +767,9 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) { } h.BuffCharges = unmarshalBuffCharges(buffChargesRaw) h.State = model.GameState(state) + if !model.IsValidHeroModelVariant(h.ModelVariant) { + h.ModelVariant = model.HeroModelVariantMin + } h.Gear = make(map[model.EquipmentSlot]*model.GearItem) h.TownPause = unmarshalTownPause(townPauseRaw) @@ -956,11 +968,12 @@ func unmarshalBuffCharges(raw []byte) map[string]model.BuffChargeState { // HeroSummary is a lightweight projection of a hero for nearby-heroes queries. type HeroSummary struct { - ID int64 `json:"id"` - Name string `json:"name"` - Level int `json:"level"` - PositionX float64 `json:"positionX"` - PositionY float64 `json:"positionY"` + ID int64 `json:"id"` + Name string `json:"name"` + Level int `json:"level"` + ModelVariant int `json:"modelVariant"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` } // UpdateOnlineStatus updates last_online_at and position for shared-world presence. @@ -977,24 +990,21 @@ func (s *HeroStore) UpdateOnlineStatus(ctx context.Context, heroID int64, posX, // GetNearbyHeroes returns other heroes within radius who were online recently (< 2 min). func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, posY, radius float64, limit int) ([]HeroSummary, error) { - if limit <= 0 { - limit = 20 + if limit < 1 { + limit = 1 } - if limit > 100 { - limit = 100 + if limit > 5 { + limit = 5 } - cutoff := time.Now().Add(-2 * time.Minute) - rows, err := s.pool.Query(ctx, ` - SELECT id, name, level, position_x, position_y + SELECT id, name, level, hero_model_variant, position_x, position_y FROM heroes WHERE id != $1 - AND last_online_at > $2 - AND sqrt(power(position_x - $3, 2) + power(position_y - $4, 2)) <= $5 + AND sqrt(power(position_x - $2, 2) + power(position_y - $3, 2)) <= $4 ORDER BY last_online_at DESC - LIMIT $6 - `, heroID, cutoff, posX, posY, radius, limit) + LIMIT $5 + `, heroID, posX, posY, radius, limit) if err != nil { return nil, fmt.Errorf("get nearby heroes: %w", err) } @@ -1003,9 +1013,12 @@ func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, pos var heroes []HeroSummary for rows.Next() { var h HeroSummary - if err := rows.Scan(&h.ID, &h.Name, &h.Level, &h.PositionX, &h.PositionY); err != nil { + if err := rows.Scan(&h.ID, &h.Name, &h.Level, &h.ModelVariant, &h.PositionX, &h.PositionY); err != nil { return nil, fmt.Errorf("scan nearby hero: %w", err) } + if !model.IsValidHeroModelVariant(h.ModelVariant) { + h.ModelVariant = model.HeroModelVariantMin + } heroes = append(heroes, h) } if err := rows.Err(); err != nil { @@ -1043,6 +1056,10 @@ func (s *HeroStore) CreatePayment(ctx context.Context, p *model.Payment) error { ).Scan(&p.ID) } +func randomHeroModelVariant() int { + return rand.Intn(model.HeroModelVariantMax-model.HeroModelVariantMin+1) + model.HeroModelVariantMin +} + func derefStr(p *string) string { if p == nil { return "" diff --git a/frontend/public/assets/game/manifest.json b/frontend/public/assets/game/manifest.json index 4ea9739..e5b9075 100644 --- a/frontend/public/assets/game/manifest.json +++ b/frontend/public/assets/game/manifest.json @@ -1,5 +1,5 @@ { - "version": 27, + "version": 32, "assetsRoot": "frontend/assets", "note": "file paths relative to frontend/assets. Rest camp: prop.camp_tent/fire/bag.v0 (wild rest). Other props + heroes + NPC.", "textures": { @@ -903,152 +903,182 @@ "enemy.battle_lizard_l7_8_meadow.south": { "file": "enemies/enemy.battle_lizard_l7_8_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "178ab49f-dbf7-4dc9-904b-e7c44af5597e" }, "enemy.battle_lizard_l7_8_forest.south": { "file": "enemies/enemy.battle_lizard_l7_8_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "6c523a79-a9c7-442a-a443-94777a826694" }, "enemy.battle_lizard_l9_10_forest.south": { "file": "enemies/enemy.battle_lizard_l9_10_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "8186ea7f-4b01-472b-b5f7-efe63ef02b09" }, "enemy.battle_lizard_l9_10_ruins.south": { "file": "enemies/enemy.battle_lizard_l9_10_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "3cca65eb-26d5-4f1f-884d-d222a4316f97" }, "enemy.battle_lizard_l11_12_ruins.south": { "file": "enemies/enemy.battle_lizard_l11_12_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "80552aec-a742-4d69-922a-89be636c6313" }, "enemy.battle_lizard_l11_12_canyon.south": { "file": "enemies/enemy.battle_lizard_l11_12_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "b45dfcee-a428-479f-9160-127a1f2fc27b" }, "enemy.battle_lizard_l13_14_canyon.south": { "file": "enemies/enemy.battle_lizard_l13_14_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "6e77f976-98e3-449f-9bf3-e222be0166e2" }, "enemy.battle_lizard_l13_14_swamp.south": { "file": "enemies/enemy.battle_lizard_l13_14_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "f44d0028-a0a1-4c71-b85c-dd104fc61a24" }, "enemy.battle_lizard_l15_15_volcanic.south": { "file": "enemies/enemy.battle_lizard_l15_15_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "7ccc658e-fae3-41f9-a600-730f58d2aeb3" }, "enemy.battle_lizard_l15_15_astral.south": { "file": "enemies/enemy.battle_lizard_l15_15_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "32c8c165-d92c-47d5-b997-bcb15f391bea" }, "enemy.element_l18_20_meadow.south": { "file": "enemies/enemy.element_l18_20_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "a9205f81-3a5e-4e21-ab29-e902064abc9f" }, "enemy.element_l12_14_forest.south": { "file": "enemies/enemy.element_l12_14_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "7d2ca2d6-ee37-4f6e-848a-7b2c8596a69e" }, "enemy.element_l21_22_forest.south": { "file": "enemies/enemy.element_l21_22_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "da0c0b8b-a2cd-4a02-b2e7-f554e49be4ca" }, "enemy.element_l15_16_ruins.south": { "file": "enemies/enemy.element_l15_16_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "5ffa8eee-5672-4286-bcb8-f92691c4a1f3" }, "enemy.element_l23_24_ruins.south": { "file": "enemies/enemy.element_l23_24_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "5ab2150e-c992-4713-9b3e-10c0c0a9bcc8" }, "enemy.element_l17_18_canyon.south": { "file": "enemies/enemy.element_l17_18_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "ddb676da-c342-4094-8022-e100892b1ccf" }, "enemy.element_l25_26_canyon.south": { "file": "enemies/enemy.element_l25_26_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "6afe13a2-e96e-48bd-bb97-b1c0f850ea54" }, "enemy.element_l19_20_swamp.south": { "file": "enemies/enemy.element_l19_20_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "472ba2aa-6178-44a6-b385-6902ef30de7a" }, "enemy.element_l27_28_volcanic.south": { "file": "enemies/enemy.element_l27_28_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "cc984511-8ee9-4b24-9a27-87298da7f052" }, "enemy.element_l21_22_astral.south": { "file": "enemies/enemy.element_l21_22_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "63a41e4c-e608-4333-afcf-03fab592293f" }, "enemy.demon_l10_12_meadow.south": { "file": "enemies/enemy.demon_l10_12_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "4e7e8204-5668-4a5b-88f8-eb915bd5414c" }, "enemy.demon_l10_12_forest.south": { "file": "enemies/enemy.demon_l10_12_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "842a964e-3f57-4dbd-a923-c39d2f9f4764" }, "enemy.demon_l13_14_forest.south": { "file": "enemies/enemy.demon_l13_14_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "9a0ce33f-4807-437c-8bdc-f0bc055d8cf6" }, "enemy.demon_l13_14_ruins.south": { "file": "enemies/enemy.demon_l13_14_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "ea2338c9-cfe1-4e46-a46c-5bb5c2f640a3" }, "enemy.demon_l15_16_ruins.south": { "file": "enemies/enemy.demon_l15_16_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "a6d012c6-24af-4de7-b282-64cffea42ed5" }, "enemy.demon_l15_16_canyon.south": { "file": "enemies/enemy.demon_l15_16_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "6e71fa5a-877c-4453-93e8-2e7a9c3bc84c" }, "enemy.demon_l17_18_canyon.south": { "file": "enemies/enemy.demon_l17_18_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "0aeebaae-8202-4232-9987-c5040a0349e2" }, "enemy.demon_l17_18_swamp.south": { "file": "enemies/enemy.demon_l17_18_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "1254d0fc-1e97-496c-9a42-b5d0b911e69e" }, "enemy.demon_l19_20_volcanic.south": { "file": "enemies/enemy.demon_l19_20_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "ee10f4e6-cbd1-4614-8de6-064c38fd5b07" }, "enemy.demon_l19_20_astral.south": { "file": "enemies/enemy.demon_l19_20_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "ed0cc675-e3c9-497d-a200-66e8250529b9" }, "enemy.skeleton_king_l15_17_meadow.south": { "file": "enemies/enemy.skeleton_king_l15_17_meadow.south.png", @@ -1103,52 +1133,62 @@ "enemy.forest_warden_l20_22_meadow.south": { "file": "enemies/enemy.forest_warden_l20_22_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "97fcaa46-cfb2-4fc8-960e-361f26cdbcf9" }, "enemy.forest_warden_l20_22_forest.south": { "file": "enemies/enemy.forest_warden_l20_22_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "c94c42a6-49bc-46b1-ad59-92e8a0a36efe" }, "enemy.forest_warden_l23_24_forest.south": { "file": "enemies/enemy.forest_warden_l23_24_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "051886bc-aa90-4506-a19b-5573fc32bc82" }, "enemy.forest_warden_l23_24_ruins.south": { "file": "enemies/enemy.forest_warden_l23_24_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "b8a2179b-6776-484b-ac48-1e248837de3f" }, "enemy.forest_warden_l25_26_ruins.south": { "file": "enemies/enemy.forest_warden_l25_26_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "e50f50b1-b43e-4cc4-9251-7181b044850c" }, "enemy.forest_warden_l25_26_canyon.south": { "file": "enemies/enemy.forest_warden_l25_26_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "416e283b-b09f-4970-81d6-ca3fb85decc6" }, "enemy.forest_warden_l27_28_canyon.south": { "file": "enemies/enemy.forest_warden_l27_28_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "17ee0740-2c27-404d-ba56-e66ecd76acfb" }, "enemy.forest_warden_l27_28_swamp.south": { "file": "enemies/enemy.forest_warden_l27_28_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "de1c3fef-2f91-44c0-b080-f4750f48e6f6" }, "enemy.forest_warden_l29_30_volcanic.south": { "file": "enemies/enemy.forest_warden_l29_30_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "453a1ba7-434e-42ac-89de-5d93e604cad8" }, "enemy.forest_warden_l29_30_astral.south": { "file": "enemies/enemy.forest_warden_l29_30_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "9fa1b822-692a-41d6-a8f1-a9f9bc58227f" }, "enemy.titan_l25_27_meadow.south": { "file": "enemies/enemy.titan_l25_27_meadow.south.png", @@ -1203,52 +1243,62 @@ "enemy.golem_l8_10_meadow.south": { "file": "enemies/enemy.golem_l8_10_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "d56fc0ce-b6a2-4fd7-b528-e2c2cf30a40c" }, "enemy.golem_l8_10_forest.south": { "file": "enemies/enemy.golem_l8_10_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "1126edff-9084-49dd-9c05-a6901f57cfb3" }, "enemy.golem_l11_12_forest.south": { "file": "enemies/enemy.golem_l11_12_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "8528a01d-515b-4c6f-85e4-ba3d218bc07f" }, "enemy.golem_l11_12_ruins.south": { "file": "enemies/enemy.golem_l11_12_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "0a29a08a-bedd-44bb-b286-d4023f66d4aa" }, "enemy.golem_l13_14_ruins.south": { "file": "enemies/enemy.golem_l13_14_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "3ecbed81-0459-490b-8160-339dd6efdba8" }, "enemy.golem_l13_14_canyon.south": { "file": "enemies/enemy.golem_l13_14_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "eb7de5d0-5083-4ae7-b6c0-43adc96129a2" }, "enemy.golem_l15_16_canyon.south": { "file": "enemies/enemy.golem_l15_16_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "bf4969f6-46b0-4847-bf15-1bba1edc850f" }, "enemy.golem_l15_16_swamp.south": { "file": "enemies/enemy.golem_l15_16_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "41294b8f-6dcf-41cc-b32c-6128d9740ecc" }, "enemy.golem_l17_18_volcanic.south": { "file": "enemies/enemy.golem_l17_18_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "cf5139fa-d186-4e4d-a576-6ec5afa7994f" }, "enemy.golem_l17_18_astral.south": { "file": "enemies/enemy.golem_l17_18_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "e5021b18-29ec-457a-9e35-5a6340228a85" }, "enemy.wraith_l5_6_meadow.south": { "file": "enemies/enemy.wraith_l5_6_meadow.south.png", @@ -1363,52 +1413,62 @@ "enemy.cultist_l6_8_meadow.south": { "file": "enemies/enemy.cultist_l6_8_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "2c8d4a66-1a87-4727-b85f-a09cc3e35b7d" }, "enemy.cultist_l6_8_forest.south": { "file": "enemies/enemy.cultist_l6_8_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "581ad2b6-aede-4e9c-a97a-ffea83f6de3b" }, "enemy.cultist_l9_10_forest.south": { "file": "enemies/enemy.cultist_l9_10_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "b45ef21e-05a0-463e-837f-bc2c7de2c526" }, "enemy.cultist_l9_10_ruins.south": { "file": "enemies/enemy.cultist_l9_10_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "caba4624-9ed8-41b3-adc9-6a5119ae5eb7" }, "enemy.cultist_l11_12_ruins.south": { "file": "enemies/enemy.cultist_l11_12_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "5468b86d-836b-439d-b2a3-39397f17d8a8" }, "enemy.cultist_l11_12_canyon.south": { "file": "enemies/enemy.cultist_l11_12_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "18c1d500-b2b6-43fa-8b67-8fc02d200a35" }, "enemy.cultist_l13_14_canyon.south": { "file": "enemies/enemy.cultist_l13_14_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "f8f9d60c-722e-4f8e-b0f4-31e3deed413d" }, "enemy.cultist_l13_14_swamp.south": { "file": "enemies/enemy.cultist_l13_14_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "94bca324-2d16-4ecc-866a-8e7cf9139787" }, "enemy.cultist_l15_16_volcanic.south": { "file": "enemies/enemy.cultist_l15_16_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "ad3d8ecc-cc7e-4086-8128-66b077d0399e" }, "enemy.cultist_l15_16_astral.south": { "file": "enemies/enemy.cultist_l15_16_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "0c4d44c4-83c9-4e8e-943b-8ac380ad9782" }, "enemy.treant_l18_20_meadow.south": { "file": "enemies/enemy.treant_l18_20_meadow.south.png", @@ -1463,12 +1523,14 @@ "enemy.basilisk_l9_11_meadow.south": { "file": "enemies/enemy.basilisk_l9_11_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "9cfcca7a-4206-4366-9d5a-e831a0cd29af" }, "enemy.basilisk_l9_11_forest.south": { "file": "enemies/enemy.basilisk_l9_11_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "c8a953a2-e5a1-4e66-bb2f-46f1d21cc6eb" }, "enemy.basilisk_l12_13_forest.south": { "file": "enemies/enemy.basilisk_l12_13_forest.south.png", @@ -1509,12 +1571,14 @@ "enemy.basilisk_l18_19_volcanic.south": { "file": "enemies/enemy.basilisk_l18_19_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "3c36859c-e53d-4cbf-979f-caac5ed03331" }, "enemy.basilisk_l18_19_astral.south": { "file": "enemies/enemy.basilisk_l18_19_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "89600a0a-059e-4e58-8a28-1660c2363097" }, "enemy.wyvern_l12_14_meadow.south": { "file": "enemies/enemy.wyvern_l12_14_meadow.south.png", @@ -1589,12 +1653,14 @@ "enemy.harpy_l10_11_ruins.south": { "file": "enemies/enemy.harpy_l10_11_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "b9dd0946-76cf-432e-bfac-b57719dcc1bf" }, "enemy.harpy_l10_11_canyon.south": { "file": "enemies/enemy.harpy_l10_11_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "726fa3a3-5654-4cc3-a19b-ab2c8292b288" }, "enemy.harpy_l12_13_canyon.south": { "file": "enemies/enemy.harpy_l12_13_canyon.south.png", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d6e3bf1..49c3fb8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -172,6 +172,10 @@ function xpToNextLevel(level: number): number { return Math.round(23000 * Math.pow(1.10, level - 30)); } +function normalizeHeroModelVariant(v: unknown): number { + return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 2 ? v : 0; +} + /** Map backend buffCharges (keyed by string) into typed Partial>. */ function mapBuffCharges( raw: Record | undefined, @@ -299,6 +303,7 @@ function heroResponseToState(res: HeroResponse): HeroState { const now = Date.now(); return { id: res.id, + modelVariant: normalizeHeroModelVariant(res.modelVariant), hp: res.hp, maxHp: res.maxHp, position: { x: res.positionX ?? 0, y: res.positionY ?? 0 }, @@ -827,6 +832,7 @@ export function App() { id: h.id, name: h.name, level: h.level, + modelVariant: normalizeHeroModelVariant(h.modelVariant), positionX: h.positionX, positionY: h.positionY, })))) @@ -837,6 +843,7 @@ export function App() { id: h.id, name: h.name, level: h.level, + modelVariant: normalizeHeroModelVariant(h.modelVariant), positionX: h.positionX, positionY: h.positionY, })))) @@ -1082,12 +1089,13 @@ export function App() { onHeroMeetStart: (p) => { const partner: NearbyHeroData = { - id: p.partner.id, - name: p.partner.name, - level: p.partner.level, - positionX: p.partner.positionX, - positionY: p.partner.positionY, - }; + id: p.partner.id, + name: p.partner.name, + level: p.partner.level, + modelVariant: normalizeHeroModelVariant(p.partner.modelVariant), + positionX: p.partner.positionX, + positionY: p.partner.positionY, + }; engine.setHeroMeetOverlay(partner); const phase = p.meetPhase; const showChat = phase === 'meet' || phase === undefined; diff --git a/frontend/src/game/assets/spriteMapping.ts b/frontend/src/game/assets/spriteMapping.ts index 335b5cc..cbc7ba5 100644 --- a/frontend/src/game/assets/spriteMapping.ts +++ b/frontend/src/game/assets/spriteMapping.ts @@ -1,7 +1,6 @@ export const ROAD_SPRITE_KEY = 'terrain.road.v1'; const DEFAULT_TERRAIN_KEY = 'terrain.grass.v1'; -const HERO_SPRITE_KEY = 'hero.player.v1.south'; -const MEET_PARTNER_SPRITE_KEY = 'hero.meet_partner'; +const HERO_MODEL_VARIANTS = [0, 1, 2] as const; /** Wilderness rest: separate props (transparent PNG), tent ~30px art height. */ export const CAMP_TENT_TEXTURE_KEY = 'prop.camp_tent.v0'; @@ -75,12 +74,11 @@ export function buildingTypeToTextureKey(buildingType: string): string | null { return BUILDING_TEXTURE_BY_TYPE[buildingType] ?? null; } -export function heroTextureKey(): string { - return HERO_SPRITE_KEY; -} - -export function meetPartnerTextureKey(): string { - return MEET_PARTNER_SPRITE_KEY; +export function heroTextureKey(modelVariant: number, facing: 'south' | 'north'): string { + const variant = Number.isInteger(modelVariant) && modelVariant >= 0 && modelVariant <= 2 + ? modelVariant + : 0; + return `hero.player.v${variant}.${facing}`; } export function restCampTextureKeys(): [string, string, string] { @@ -106,8 +104,7 @@ export function getRequiredSpriteKeys(): string[] { ...objectKeys, ...npcKeys, ...buildingKeys, - HERO_SPRITE_KEY, - MEET_PARTNER_SPRITE_KEY, + ...HERO_MODEL_VARIANTS.flatMap((v) => [`hero.player.v${v}.south`, `hero.player.v${v}.north`]), CAMP_TENT_TEXTURE_KEY, CAMP_FIRE_TEXTURE_KEY, CAMP_BAG_TEXTURE_KEY, diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 16f6e73..1b8c8c1 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -990,6 +990,7 @@ export class GameEngine { this._heroDisplayY, animPhase, now, + state.hero.modelVariant ?? 0, ); // Thought bubble during rest/town pauses @@ -1024,14 +1025,21 @@ export class GameEngine { // Draw nearby heroes from the shared world (meet partner uses full sprite below, not diamonds) const nearbyMerged = this._mergedNearbyHeroes(now); if (nearbyMerged.length > 0) { - this.renderer.drawNearbyHeroes(nearbyMerged, now); + this.renderer.drawNearbyHeroes(nearbyMerged); } else { this.renderer.clearNearbyHeroes(); } const meetOv = this._heroMeetOverlay; if (meetOv) { - this.renderer.drawMeetPartner(meetOv.positionX, meetOv.positionY, meetOv.name, meetOv.level, now); + this.renderer.drawMeetPartner( + meetOv.positionX, + meetOv.positionY, + meetOv.name, + meetOv.level, + now, + meetOv.modelVariant ?? 0, + ); } else { this.renderer.clearMeetPartner(); } diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index 8a69a9c..01c9df5 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -8,7 +8,6 @@ import { GameSpriteRegistry } from './assets/gameSpriteRegistry'; import { buildingTypeToTextureKey, heroTextureKey, - meetPartnerTextureKey, npcTypeToTextureKey, objectToTextureKey, restCampTextureKeys, @@ -155,6 +154,8 @@ export class GameRenderer { private _nearbyHeroGfx: Graphics | null = null; private _nearbyHeroLabels: Text[] = []; private _nearbyHeroLabelPool: Text[] = []; + private _nearbyHeroSpritePool = new Map(); + private _usedNearbyHeroSprites = new Set(); private _lastEntitySortMs = 0; private _entitySortIntervalMs = 120; @@ -894,11 +895,11 @@ export class GameRenderer { /** * Draw the hero as a compact adventurer (silhouette + cape + blade) with bob / combat flash. */ - drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number): void { + drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number, modelVariant: number): void { const gfx = this._heroGfx; if (!gfx) return; const iso = worldToScreen(wx, wy); - const textureKey = this._spritesReady ? heroTextureKey() : null; + const textureKey = this._spritesReady ? heroTextureKey(modelVariant, 'south') : null; const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null; let cy = iso.y; @@ -937,12 +938,12 @@ export class GameRenderer { /** * Meet opponent: same figure as the main hero, idle stance, distinct tint + name label. */ - drawMeetPartner(wx: number, wy: number, name: string, level: number, now: number): void { + drawMeetPartner(wx: number, wy: number, name: string, level: number, now: number, modelVariant: number): void { const gfx = this._meetPartnerGfx; const lbl = this._meetPartnerLabel; if (!gfx || !lbl) return; const iso = worldToScreen(wx, wy); - const textureKey = this._spritesReady ? meetPartnerTextureKey() : null; + const textureKey = this._spritesReady ? heroTextureKey(modelVariant, 'north') : null; const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null; let cy = iso.y; @@ -969,7 +970,7 @@ export class GameRenderer { } lbl.text = `${name} Lv.${level}`; lbl.x = iso.x; - lbl.y = iso.y - 42; + lbl.y = iso.y + 10; lbl.visible = true; lbl.zIndex = cy + 199; } @@ -1790,10 +1791,8 @@ export class GameRenderer { Math.abs(iso.y - camY) > halfH ) continue; - // Idle sway based on NPC id - const swayY = Math.sin(now * 0.002 + npc.id * 1.7) * 2.5; const cx = iso.x; - const cy = iso.y + swayY; + const cy = iso.y; // Shadow gfx.ellipse(cx, cy + 8, 10, 3.5); @@ -2015,12 +2014,18 @@ export class GameRenderer { } /** - * Draw nearby heroes as semi-transparent green diamonds with name + level labels. + * Draw nearby heroes using player sprites + name/level labels. * Each hero gets a subtle idle sway animation. */ drawNearbyHeroes( - heroes: ReadonlyArray<{ name: string; level: number; positionX: number; positionY: number }>, - now: number, + heroes: ReadonlyArray<{ + id: number; + name: string; + level: number; + modelVariant: number; + positionX: number; + positionY: number; + }>, ): void { const gfx = this._nearbyHeroGfx; if (!gfx) return; @@ -2032,34 +2037,37 @@ export class GameRenderer { } let labelIdx = 0; + const usedNearbySprites = this._usedNearbyHeroSprites; + usedNearbySprites.clear(); for (const hero of heroes) { const iso = worldToScreen(hero.positionX, hero.positionY); - // Idle sway: use a per-hero offset based on name hash - const hashOffset = hero.name.length * 1.37; - const swayY = Math.sin(now * 0.003 + hashOffset) * 2; const cx = iso.x; - const cy = iso.y + swayY; + const cy = iso.y; // Shadow gfx.ellipse(cx, cy + 8, 10, 3); gfx.fill({ color: 0x000000, alpha: 0.2 }); - // Green diamond body (smaller than hero) - const s = 0.7; - gfx.poly([ - cx, cy - 16 * s, - cx + 10 * s, cy, - cx, cy + 8 * s, - cx - 10 * s, cy, - ]); - gfx.fill({ color: 0x44aa55, alpha: 0.55 }); - gfx.stroke({ color: 0x2d7a3a, width: 1.2, alpha: 0.6 }); - - // Small head circle - gfx.circle(cx, cy - 14 * s, 4 * s); - gfx.fill({ color: 0x66cc77, alpha: 0.5 }); + const textureKey = this._spritesReady ? heroTextureKey(hero.modelVariant ?? 0, 'south') : null; + const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null; + if (textureKey && texture) { + const poolKey = `nearby_hero:${hero.id}`; + usedNearbySprites.add(poolKey); + const entry = this._ensureSprite( + this._nearbyHeroSpritePool, + poolKey, + textureKey, + texture, + this.entityLayer, + ); + entry.sprite.x = cx; + entry.sprite.y = cy; + entry.sprite.scale.set(0.85); + entry.sprite.zIndex = cy + 100; + entry.sprite.visible = true; + } // Label: "Name Lv.X" let label: Text; @@ -2087,7 +2095,7 @@ export class GameRenderer { label.text = `${hero.name} Lv.${hero.level}`; label.x = cx; - label.y = cy - 22; + label.y = cy - 90; label.visible = true; label.zIndex = cy + 101; labelIdx++; @@ -2095,6 +2103,8 @@ export class GameRenderer { // Set gfx z-index for depth sorting gfx.zIndex = cy + 100; } + + this._hideUnusedSprites(this._nearbyHeroSpritePool, usedNearbySprites); } /** @@ -2168,6 +2178,7 @@ export class GameRenderer { for (const lbl of this._nearbyHeroLabels) { lbl.visible = false; } + this._hideUnusedSprites(this._nearbyHeroSpritePool, this._emptySpriteSet); } /** Sort entity layer by y-position for correct isometric depth */ diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 19a438a..0587cf3 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -129,6 +129,7 @@ export interface Position { export interface HeroState { id: number; + modelVariant: number; hp: number; maxHp: number; position: Position; @@ -460,6 +461,7 @@ export interface NearbyHeroData { id: number; name: string; level: number; + modelVariant: number; positionX: number; positionY: number; } @@ -673,6 +675,7 @@ export interface HeroMeetPartnerSnapshot { id: number; name: string; level: number; + modelVariant: number; positionX: number; positionY: number; } diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index bc28190..9dcfac2 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -94,6 +94,7 @@ export interface HeroResponse { id: number; telegramId: number; name: string; + modelVariant?: number; hp: number; maxHp: number; attack: number; @@ -823,6 +824,7 @@ export interface NearbyHero { id: number; name: string; level: number; + modelVariant?: number; positionX: number; positionY: number; } @@ -830,7 +832,9 @@ export interface NearbyHero { /** Fetch heroes near the player's current position */ export async function getNearbyHeroes(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; - return apiGet(`/hero/nearby${query}`); + const raw = await apiGet<{ heroes?: NearbyHero[] } | NearbyHero[]>(`/hero/nearby${query}`); + if (Array.isArray(raw)) return raw; + return raw.heroes ?? []; } // ---- Daily Tasks (backend-driven, spec 10.1) ----