Files

467 lines
16 KiB
GDScript

class_name ShopModal
extends Control
## Магазин: светлое окно, до 10 позиций, компактные строки, без крестика.
## Закрытие — только кнопкой «Закрыть» (или Esc).
##
## shop.open(Game.money, Game.upgrades)
## shop.buy_requested.connect(_on_buy)
## shop.closed.connect(_on_closed)
signal buy_requested(id: String)
signal closed
const W := 900.0
const H := 600.0
const HEADER_H := 40.0
const ROW_Y := 96.0
const ROWS_BOTTOM := 536.0
const MAX_ROW_H := 52.0
const DIM := Color(0.11, 0.12, 0.11, 0.30)
## Копия data/balance.json → shop. Backend может добавить shop_sla и фильтр
## Data.available_shop(); здесь — безопасный fallback на 10 позиций.
const FALLBACK_SHOP := [
{"id": "shop_slot3", "name_ru": "Третий слот", "price": 250, "max_level": 1, "effect": "connection_slots"},
{"id": "shop_slot4", "name_ru": "Четвёртый слот", "price": 450, "max_level": 1, "effect": "connection_slots"},
{"id": "shop_buffer", "name_ru": "Буфер +2", "price": 200, "max_level": 2, "effect": "buffer_slots"},
{"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "price": 350, "max_level": 3, "effect": "packet_timer"},
{"id": "shop_zone", "name_ru": "Зона +5%", "price": 300, "max_level": 3, "effect": "zone_width"},
{"id": "shop_channel", "name_ru": "Канал +25", "price": 250, "max_level": 4, "effect": "channel_capacity"},
{"id": "shop_section", "name_ru": "Новая секция", "price": 300, "max_level": 5, "effect": "sections"},
{"id": "shop_search", "name_ru": "Поиск", "price": 250, "max_level": 1, "effect": "search_highlight"},
{"id": "shop_shield", "name_ru": "Щит", "price": 400, "max_level": 1, "effect": "shield"},
{"id": "shop_sla", "name_ru": "SLA +24", "price": 300, "max_level": 3, "effect": "sla_add"},
]
const EFFECT_DESC := {
"connection_slots": "Ещё одно соединение одновременно",
"buffer_slots": "Приём выдерживает больше пакетов",
"packet_timer": "Больше времени на каждый пакет",
"zone_width": "Шире зона попадания в мини-игре",
"channel_capacity": "Канал держит больше нагрузки",
"sections": "Ещё одна секция для папок",
"search_highlight": "Подсветка нужных файлов",
"shield": "Автоматически отражает атаки",
"sla_add": "Запас SLA на смену",
}
var money := 0
var levels: Dictionary = {}
var _items: Array = []
var _buttons: Array[Button90] = []
var _close_btn: Button90
var _row_h := MAX_ROW_H
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_items = _load_items()
_build()
resized.connect(_layout)
_layout()
visible = false
# --- публичное -------------------------------------------------------------
func open(p_money: int, p_levels: Dictionary = {}) -> void:
money = maxi(0, p_money)
levels = p_levels.duplicate(true)
# available_shop() зависит от часа и мета-разблокировок — читаем при каждом открытии.
_items = _load_items()
_rebuild_buttons()
visible = true
_sync_buttons()
_layout()
queue_redraw()
func refresh(p_money: int, p_levels: Dictionary = {}) -> void:
open(p_money, p_levels)
func close() -> void:
if not visible:
return
visible = false
closed.emit()
func is_open() -> bool:
return visible
func item_price(id: String) -> int:
for item in _items:
if str(item["id"]) == id:
return int(item["price"])
return 0
func item_name(id: String) -> String:
for item in _items:
if str(item["id"]) == id:
return str(item["name"])
return id
func item_max_level(id: String) -> int:
for item in _items:
if str(item["id"]) == id:
return int(item["max_level"])
return 1
# --- данные ----------------------------------------------------------------
func _load_items() -> Array:
var data := get_node_or_null("/root/Data")
if data == null:
return _fallback_items()
# Основной путь: готовый отфильтрованный каталог. Показываем ровно его.
if data.has_method("available_shop"):
var filtered = data.call("available_shop")
var from_filter := _normalize_items(filtered)
if not from_filter.is_empty():
return from_filter
# Data есть, но фильтр недоступен/пуст: берём Data.shop.
var raw = data.get("shop")
var out := _normalize_items(raw)
if data.has_method("shop_item_unlocked"):
var opened: Array = []
for item in out:
var src := _find_raw(raw, str(item["id"]))
if src.is_empty() or bool(data.call("shop_item_unlocked", src)):
opened.append(item)
out = opened
if not out.is_empty():
return out
# Совсем нет данных — безопасный статический fallback (dev/превью).
return _fallback_items()
func _normalize_items(raw: Variant) -> Array:
var out: Array = []
if raw is Array:
for it in raw:
if it is Dictionary and not str(it.get("id", "")).is_empty():
out.append(_normalize(it))
return out
func _find_raw(raw: Variant, id: String) -> Dictionary:
if raw is Array:
for it in raw:
if it is Dictionary and str(it.get("id", "")) == id:
return it
return {}
func _fallback_items() -> Array:
var out: Array = []
for fb in FALLBACK_SHOP:
out.append(_normalize(fb))
return out
func _normalize(src: Dictionary) -> Dictionary:
var id := str(src.get("id", ""))
var item_name := str(src.get("name_ru", src.get("name", "")))
if item_name.is_empty():
item_name = _tr("shop." + id, id)
var price := int(src.get("price", src.get("cost", 0)))
var max_level := maxi(1, int(src.get("max_level", 1)))
var effect := str(src.get("effect", ""))
var desc := str(src.get("desc_ru", ""))
if desc.is_empty():
desc = str(EFFECT_DESC.get(effect, "Улучшение рабочего места"))
return {"id": id, "name": item_name, "price": maxi(0, price), "max_level": max_level,
"effect": effect, "desc": desc}
func _tr(key: String, fallback: String) -> String:
var data := get_node_or_null("/root/Data")
if data != null and data.has_method("has_string") and data.has_string(key):
var value := str(data.tr(key))
if not value.is_empty() and value != key:
return value
return fallback
func _level_of(id: String) -> int:
return clampi(int(levels.get(id, 0)), 0, 99)
func _maxed(item: Dictionary) -> bool:
return _level_of(str(item["id"])) >= int(item["max_level"])
func _can_afford(item: Dictionary) -> bool:
return money >= int(item["price"])
# --- сборка ----------------------------------------------------------------
func _build() -> void:
_rebuild_buttons()
_close_btn = Button90.new()
_close_btn.name = "CloseButton"
_close_btn.caption = _tr("btn.close", "Закрыть")
_close_btn.caption_size = Theme90.FS_BODY
_close_btn.pressed.connect(close)
add_child(_close_btn)
## Пересоздаёт кнопки под актуальный список позиций (каталог меняется
## при разблокировке магазина/поиска/щита).
func _rebuild_buttons() -> void:
for b in _buttons:
if is_instance_valid(b):
if b.get_parent() != null:
b.get_parent().remove_child(b)
b.queue_free()
_buttons.clear()
for it in _items:
var b := Button90.new()
b.name = "Buy_" + str(it["id"])
b.caption = _tr("btn.buy", "Купить")
b.caption_size = Theme90.FS_MICRO
b.pressed.connect(_on_buy.bind(str(it["id"])))
add_child(b)
_buttons.append(b)
func _window_rect() -> Rect2:
var s := size
if s.x <= 0.0 or s.y <= 0.0:
s = Vector2(1280, 720)
return Rect2(((s - Vector2(W, H)) * 0.5).round(), Vector2(W, H))
func _layout() -> void:
var r := _window_rect()
var n := maxi(1, _items.size())
_row_h = clampf(floorf((ROWS_BOTTOM - ROW_Y) / float(n)), 30.0, MAX_ROW_H)
var btn_h := minf(24.0, _row_h - 10.0)
for i in _buttons.size():
var ry := ROW_Y + i * _row_h
_buttons[i].position = r.position + Vector2(W - 128.0, ry + (_row_h - btn_h) * 0.5)
_buttons[i].size = Vector2(104, btn_h)
_close_btn.position = r.position + Vector2(W - 136.0, H - 40.0)
_close_btn.size = Vector2(112, 24)
func _sync_buttons() -> void:
for i in _items.size():
if i >= _buttons.size():
break
var item: Dictionary = _items[i]
var b := _buttons[i]
if _maxed(item):
b.caption = "Максимум"
b.disabled = true
b.accent = false
elif not _can_afford(item):
b.caption = _tr("btn.buy", "Купить")
b.disabled = true
b.accent = false
else:
b.caption = _tr("btn.buy", "Купить")
b.disabled = false
b.accent = true
func _on_buy(id: String) -> void:
buy_requested.emit(id)
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
# --- отрисовка -------------------------------------------------------------
func _draw() -> void:
var full := Rect2(Vector2.ZERO, size)
if size.x <= 0.0 or size.y <= 0.0:
full = Rect2(0, 0, 1280, 720)
draw_rect(full, DIM)
var r := _window_rect()
_draw_frame(r)
for i in _items.size():
_draw_row(r, i)
func _draw_frame(r: Rect2) -> void:
draw_style_box(Theme90.box(Theme90.WHITE, Theme90.BORDER, 6, 1,
Color(0, 0, 0, 0.16)), r)
var header := Rect2(r.position.x + 1, r.position.y + 1, r.size.x - 2, HEADER_H - 1)
draw_style_box(Theme90.top_box(Theme90.PAPER_HI, 6), header)
# заголовок: точка + название, подпись справа
draw_circle(Vector2(header.position.x + 20, header.position.y + 20), 3.5,
Theme90.ACCENT)
var f := Theme90.ui()
Theme90.text(self, f, Vector2(header.position.x + 31, header.position.y + 11),
"МАГАЗИН", 16, Theme90.NAVY, 160)
var sub := "УЛУЧШЕНИЯ · %d" % _items.size()
var sw := f.get_string_size(sub, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(header.end.x - sw - 20, header.position.y + 14),
sub, 10, Theme90.SHADE)
draw_rect(Rect2(r.position.x + 1, r.position.y + HEADER_H, r.size.x - 2, 1),
Theme90.HAIRLINE)
# баланс
var hdr := Rect2(r.position.x + 20, r.position.y + 52, r.size.x - 40, 32)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER_SOFT, 3, 1), hdr)
Theme90.text_vcenter(self, f, Rect2(hdr.position.x + 12, hdr.position.y, 54,
hdr.size.y), "БАЛАНС", 10, Theme90.SHADE)
Theme90.text_vcenter(self, Theme90.logo(), Rect2(hdr.position.x + 62, hdr.position.y,
200, hdr.size.y), _fmt(money) + " $", 12, Theme90.INK)
var bought := 0
for it in _items:
if _level_of(str(it["id"])) > 0:
bought += 1
Theme90.text_vcenter(self, f, Rect2(hdr.position.x, hdr.position.y,
hdr.size.x - 12, hdr.size.y), "КУПЛЕНО %d ИЗ %d" % [bought, _items.size()],
10, Theme90.SHADE, -1, HORIZONTAL_ALIGNMENT_RIGHT)
# подвал
Theme90.text(self, f, Vector2(r.position.x + 22, r.position.y + H - 31),
"Улучшения действуют до конца смены · Esc — закрыть", 10, Theme90.SHADE)
func _draw_row(r: Rect2, index: int) -> void:
var item: Dictionary = _items[index]
var ry := r.position.y + ROW_Y + index * _row_h
var row := Rect2(r.position.x + 20, ry, r.size.x - 40, _row_h)
var level := _level_of(str(item["id"]))
var max_level := int(item["max_level"])
var price := int(item["price"])
var f := Theme90.ui()
if index > 0:
draw_rect(Rect2(row.position.x, row.position.y, row.size.x, 1), Theme90.HAIRLINE)
var icon_box := Rect2(row.position.x + 8, ry + (_row_h - 28.0) * 0.5, 28, 28)
var tint := _effect_color(str(item["effect"]))
draw_style_box(Theme90.box(Color(tint.r, tint.g, tint.b, 0.12), Color(0, 0, 0, 0), 5, 0),
icon_box)
_draw_item_icon(str(item["effect"]), icon_box, tint)
Theme90.text(self, f, Vector2(row.position.x + 46, ry + 7),
Theme90.fit(f, str(item["name"]), 12, 360), 12, Theme90.INK)
Theme90.text(self, f, Vector2(row.position.x + 46, ry + 23),
Theme90.fit(f, str(item["desc"]), 10, 380), 10, Theme90.SHADE)
# уровень: подпись + точки
var lx := row.position.x + 452
Theme90.text(self, f, Vector2(lx, ry + 6), "УРОВЕНЬ %d/%d" % [level, max_level], 10,
Theme90.SHADE)
var px := lx
var py := ry + 23.0
for i in max_level:
var pip := Rect2(px + i * 10, py, 7, 6)
if i < level:
draw_style_box(Theme90.fill_box(Theme90.ACCENT, 3), pip)
else:
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER_SOFT, 3, 1), pip)
# цена и подсказка — двумя строками, без наложений
Theme90.text_vcenter(self, f, Rect2(row.position.x + 596, ry + 3, 150, 20),
_fmt(price) + " $", 14, Theme90.INK, -1, HORIZONTAL_ALIGNMENT_RIGHT)
if _maxed(item):
Theme90.text_vcenter(self, f, Rect2(row.position.x + 566, ry + 23,
180, 14), "ВСЁ КУПЛЕНО", 10, Theme90.SUCCESS, -1,
HORIZONTAL_ALIGNMENT_RIGHT)
elif not _can_afford(item):
Theme90.text_vcenter(self, f, Rect2(row.position.x + 546, ry + 23,
200, 14), "НЕ ХВАТАЕТ %d" % (price - money), 10, Theme90.ERROR, -1,
HORIZONTAL_ALIGNMENT_RIGHT)
func _effect_color(effect: String) -> Color:
match effect:
"packet_timer", "sections":
return Theme90.WARN
"zone_width", "shield":
return Theme90.SUCCESS
"search_highlight", "sla_max":
return Theme90.NAVY
_:
return Theme90.ACCENT
func _draw_item_icon(effect: String, box: Rect2, c: Color) -> void:
var x := box.position.x + 5.0
var y := box.position.y + 4.0
var a := Theme90.NAVY
match effect:
"connection_slots":
draw_rect(Rect2(x + 3, y + 4, 5, 9), c, false, 1.0)
draw_rect(Rect2(x + 12, y + 4, 5, 9), c, false, 1.0)
draw_rect(Rect2(x + 6, y + 7, 8, 1), c)
draw_rect(Rect2(x + 9, y + 9, 2, 7), c)
draw_rect(Rect2(x + 7, y + 16, 6, 2), c)
"buffer_slots":
for i in 3:
draw_rect(Rect2(x + 3, y + 4 + i * 5, 14, 3), c if i < 2 else a)
"packet_timer":
draw_rect(Rect2(x + 3, y + 4, 14, 14), c, false, 1.0)
draw_rect(Rect2(x + 9, y + 6, 2, 5), c)
draw_rect(Rect2(x + 10, y + 10, 4, 2), a)
draw_rect(Rect2(x + 8, y + 1, 4, 2), c)
"zone_width":
draw_rect(Rect2(x + 2, y + 3, 16, 14), c, false, 1.0)
draw_rect(Rect2(x + 6, y + 6, 8, 8), a, false, 1.0)
draw_rect(Rect2(x + 9, y + 9, 2, 2), c)
"channel_capacity":
draw_rect(Rect2(x + 2, y + 6, 10, 8), c, false, 1.0)
draw_colored_polygon(PackedVector2Array([
Vector2(x + 12, y + 5), Vector2(x + 18, y + 10), Vector2(x + 12, y + 15)]), a)
"sections":
Theme90.draw_folder_icon(self, Vector2(x + 3, y + 5), c, false)
"search_highlight":
draw_rect(Rect2(x + 3, y + 3, 10, 10), c, false, 1.0)
draw_rect(Rect2(x + 5, y + 5, 6, 6), a)
draw_line(Vector2(x + 12, y + 12), Vector2(x + 17, y + 17), c, 2.0)
"shield":
draw_colored_polygon(PackedVector2Array([
Vector2(x + 10, y + 2), Vector2(x + 17, y + 5), Vector2(x + 16, y + 12),
Vector2(x + 10, y + 18), Vector2(x + 4, y + 12), Vector2(x + 3, y + 5)]), c)
draw_rect(Rect2(x + 9, y + 7, 2, 5), Theme90.WHITE)
draw_rect(Rect2(x + 9, y + 13, 2, 2), Theme90.WHITE)
"sla_max":
draw_rect(Rect2(x + 3, y + 3, 14, 14), c, false, 1.0)
draw_rect(Rect2(x + 6, y + 6, 8, 2), c)
draw_rect(Rect2(x + 6, y + 10, 5, 2), c)
draw_rect(Rect2(x + 6, y + 14, 3, 2), a)
_:
draw_rect(Rect2(x + 4, y + 4, 12, 12), c, false, 1.0)
draw_rect(Rect2(x + 7, y + 7, 6, 6), a)
func _fmt(value: int) -> String:
var neg := value < 0
var digits := str(absi(value))
var out := ""
var count := 0
for i in range(digits.length() - 1, -1, -1):
out = digits[i] + out
count += 1
if count % 3 == 0 and i > 0:
out = " " + out
return ("-" if neg else "") + out