fixed # 修复作物资源位置不对的历史遗留BUG
add # 新增作物会生虫的逻辑
@@ -8,6 +8,7 @@ from .domain import (
|
||||
Asset, FarmError, Identity, Plot, boosted, integer, quantity, text,
|
||||
)
|
||||
from .ports import Store
|
||||
from .pests import advance_plot, start_growth
|
||||
|
||||
|
||||
class FarmService:
|
||||
@@ -41,7 +42,11 @@ class FarmService:
|
||||
|
||||
async def snapshot(self, identity: Identity):
|
||||
self._identity(identity)
|
||||
return await self.store.snapshot(identity)
|
||||
now = integer(self.clock())
|
||||
snapshot = await self.store.snapshot(identity)
|
||||
return replace(snapshot, plots=tuple(
|
||||
advance_plot(plot, now) for plot in snapshot.plots
|
||||
))
|
||||
|
||||
async def register(
|
||||
self, identity: Identity, name: str, operation_id: str,
|
||||
@@ -64,15 +69,22 @@ class FarmService:
|
||||
|
||||
def operation(repository, now):
|
||||
plant = self.catalogue.get(name)
|
||||
if not plant.enabled:
|
||||
raise FarmError("plant_disabled")
|
||||
if plant.crop_type != 1:
|
||||
raise FarmError("unsupported_crop_type")
|
||||
if not plant.purchasable:
|
||||
raise FarmError("plant_not_purchasable")
|
||||
if repository.account().level < plant.level:
|
||||
raise FarmError("level_required")
|
||||
cost = integer(plant.cost * count)
|
||||
repository.asset(plant.asset, -cost)
|
||||
repository.stock(name, seeds=count)
|
||||
repository.stock(plant, seeds=count)
|
||||
return self._result(
|
||||
repository, name=name, quantity=count, cost=cost,
|
||||
repository, name=plant.name, plant_id=plant.plant_id,
|
||||
quantity=count, cost=cost,
|
||||
asset=plant.asset.value,
|
||||
seeds=repository.item(name).seeds,
|
||||
seeds=repository.item(plant).seeds,
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
@@ -91,7 +103,11 @@ class FarmService:
|
||||
def operation(repository, now):
|
||||
repository.account()
|
||||
plant = self.catalogue.get(name)
|
||||
available = repository.item(name).seeds
|
||||
if not plant.enabled:
|
||||
raise FarmError("plant_disabled")
|
||||
if plant.crop_type != 1:
|
||||
raise FarmError("unsupported_crop_type")
|
||||
available = repository.item(plant).seeds
|
||||
empty = [p for p in repository.plots() if p.plant is None]
|
||||
amount = min(available, len(empty)) if count is None else count
|
||||
if amount == 0 or amount > available:
|
||||
@@ -99,15 +115,16 @@ class FarmService:
|
||||
if amount > len(empty):
|
||||
raise FarmError("insufficient_empty_plots")
|
||||
mature_at = integer(now + plant.growth_seconds)
|
||||
repository.stock(name, seeds=-amount)
|
||||
repository.stock(plant, seeds=-amount)
|
||||
for plot in empty[:amount]:
|
||||
repository.save_plot(replace(
|
||||
repository.save_plot(start_growth(replace(
|
||||
plot, plant=plant, mature_at=mature_at, harvest_count=0,
|
||||
))
|
||||
), now))
|
||||
return self._result(
|
||||
repository, name=name, quantity=amount,
|
||||
repository, name=plant.name, plant_id=plant.plant_id,
|
||||
quantity=amount,
|
||||
slots=[p.slot for p in empty[:amount]],
|
||||
seeds=repository.item(name).seeds,
|
||||
seeds=repository.item(plant).seeds,
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
@@ -121,6 +138,7 @@ class FarmService:
|
||||
harvested = []
|
||||
total_experience = 0
|
||||
for plot in repository.plots():
|
||||
plot = advance_plot(plot, now)
|
||||
plant = plot.plant
|
||||
if (
|
||||
plant is None or plot.withered or plot.mature_at is None
|
||||
@@ -128,22 +146,33 @@ class FarmService:
|
||||
):
|
||||
continue
|
||||
amount = boosted(plant.harvest, plot.yield_bonus)
|
||||
amount = amount * (100 - 5 * plot.maturity_pests) // 100
|
||||
experience = boosted(
|
||||
plant.experience, plot.experience_bonus,
|
||||
)
|
||||
repository.stock(plant.name, crops=amount)
|
||||
repository.stock(plant, crops=amount)
|
||||
total_experience = integer(total_experience + experience)
|
||||
count = plot.harvest_count + 1
|
||||
mature_at = (
|
||||
integer(now + plant.regrowth_seconds)
|
||||
if count < plant.crop else None
|
||||
)
|
||||
repository.save_plot(replace(
|
||||
updated = replace(
|
||||
plot, mature_at=mature_at, harvest_count=count,
|
||||
))
|
||||
pest_count=0, pest_spawned=0, growth_remaining=None,
|
||||
growth_updated_at=None, next_pest_at=None,
|
||||
maturity_pests=0,
|
||||
)
|
||||
repository.save_plot(
|
||||
start_growth(updated, now) if count < plant.crop
|
||||
else updated
|
||||
)
|
||||
harvested.append({
|
||||
"slot": plot.slot, "name": plant.name,
|
||||
"plant_id": plant.plant_id,
|
||||
"quantity": amount, "experience": experience,
|
||||
"pests": plot.maturity_pests,
|
||||
"yield_loss_percent": plot.maturity_pests * 5,
|
||||
})
|
||||
if not harvested:
|
||||
raise FarmError("nothing_to_harvest")
|
||||
@@ -167,6 +196,9 @@ class FarmService:
|
||||
for plot in withered:
|
||||
repository.save_plot(replace(
|
||||
plot, plant=None, mature_at=None, harvest_count=0,
|
||||
pest_count=0, pest_spawned=0, growth_remaining=None,
|
||||
growth_updated_at=None, next_pest_at=None,
|
||||
maturity_pests=0,
|
||||
))
|
||||
return self._result(
|
||||
repository, slots=[p.slot for p in withered],
|
||||
@@ -176,6 +208,28 @@ class FarmService:
|
||||
identity, operation_id, "eradicate", {}, operation,
|
||||
)
|
||||
|
||||
async def clear_pests(self, identity: Identity, operation_id: str):
|
||||
def operation(repository, now):
|
||||
repository.account()
|
||||
cleared = []
|
||||
for stored in repository.plots():
|
||||
plot = advance_plot(stored, now)
|
||||
if plot.pest_count == 0:
|
||||
continue
|
||||
cleared.append({"slot": plot.slot, "pests": plot.pest_count})
|
||||
updated = replace(plot, pest_count=0)
|
||||
repository.save_plot(advance_plot(updated, now))
|
||||
if not cleared:
|
||||
raise FarmError("nothing_to_clear")
|
||||
return self._result(
|
||||
repository, cleared=cleared,
|
||||
quantity=sum(item["pests"] for item in cleared),
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
identity, operation_id, "clear_pests", {}, operation,
|
||||
)
|
||||
|
||||
async def lock(
|
||||
self, identity: Identity, name: str, locked: bool,
|
||||
operation_id: str,
|
||||
@@ -186,9 +240,12 @@ class FarmService:
|
||||
|
||||
def operation(repository, now):
|
||||
repository.account()
|
||||
self.catalogue.get(name)
|
||||
repository.lock(name, locked)
|
||||
return self._result(repository, name=name, locked=locked)
|
||||
plant = self.catalogue.get(name)
|
||||
repository.lock(plant, locked)
|
||||
return self._result(
|
||||
repository, name=plant.name, plant_id=plant.plant_id,
|
||||
locked=locked,
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
identity, operation_id, "lock",
|
||||
@@ -209,16 +266,17 @@ class FarmService:
|
||||
def operation(repository, now):
|
||||
repository.account()
|
||||
if name is not None:
|
||||
item = repository.item(name)
|
||||
plant = self.catalogue.get(name)
|
||||
item = repository.item(plant)
|
||||
if item.locked:
|
||||
raise FarmError("plant_locked")
|
||||
amount = item.crops if count is None else count
|
||||
if amount <= 0 or amount > item.crops:
|
||||
raise FarmError("insufficient_stock")
|
||||
items = [(name, amount)]
|
||||
items = [(plant.plant_id, amount)]
|
||||
else:
|
||||
items = [
|
||||
(item.name, item.crops)
|
||||
(item.plant_id, item.crops)
|
||||
for item in repository.items()
|
||||
if not item.locked and item.crops > 0
|
||||
]
|
||||
@@ -226,11 +284,14 @@ class FarmService:
|
||||
raise FarmError("nothing_to_sell")
|
||||
proceeds = 0
|
||||
sold = []
|
||||
for plant_name, amount in items:
|
||||
plant = self.catalogue.get(plant_name)
|
||||
for plant_id, amount in items:
|
||||
plant = self.catalogue.get_by_id(plant_id)
|
||||
proceeds = integer(proceeds + plant.price * amount)
|
||||
repository.stock(plant_name, crops=-amount)
|
||||
sold.append({"name": plant_name, "quantity": amount})
|
||||
repository.stock(plant, crops=-amount)
|
||||
sold.append({
|
||||
"name": plant.name, "plant_id": plant.plant_id,
|
||||
"quantity": amount,
|
||||
})
|
||||
repository.asset(Asset.POINT, proceeds)
|
||||
return self._result(
|
||||
repository, sold=sold, proceeds=proceeds,
|
||||
|
||||
@@ -12,19 +12,29 @@ class Catalogue:
|
||||
items = tuple(plants)
|
||||
if not items or any(not isinstance(p, Plant) for p in items):
|
||||
raise FarmError("invalid_catalogue")
|
||||
names = {plant.name: plant for plant in items}
|
||||
if len(names) != len(items):
|
||||
raise FarmError("duplicate_plant")
|
||||
if len({p.plant_id for p in items}) != len(items):
|
||||
raise FarmError("duplicate_plant")
|
||||
self._plants = MappingProxyType(names)
|
||||
self._plants = MappingProxyType({p.plant_id: p for p in items})
|
||||
names = {}
|
||||
for plant in items:
|
||||
names.setdefault(plant.name, []).append(plant)
|
||||
self._names = MappingProxyType({k: tuple(v) for k, v in names.items()})
|
||||
|
||||
def get(self, name: str) -> Plant:
|
||||
text(name)
|
||||
try:
|
||||
return self._plants[name]
|
||||
except KeyError as exc:
|
||||
raise FarmError("unknown_plant") from exc
|
||||
if name.startswith("#") and name[1:].isascii() and name[1:].isdigit():
|
||||
return self.get_by_id(int(name[1:]))
|
||||
matches = self._names.get(name, ())
|
||||
if not matches:
|
||||
raise FarmError("unknown_plant")
|
||||
if len(matches) != 1:
|
||||
raise FarmError("ambiguous_plant")
|
||||
return matches[0]
|
||||
|
||||
def get_by_id(self, plant_id: int) -> Plant:
|
||||
if type(plant_id) is not int or plant_id not in self._plants:
|
||||
raise FarmError("unknown_plant")
|
||||
return self._plants[plant_id]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._plants.values())
|
||||
|
||||
@@ -91,8 +91,8 @@ class Identity:
|
||||
class Plant:
|
||||
"""播种时保存的不可变结算定义。"""
|
||||
|
||||
plant_id: int # 旧作物目录 ID。
|
||||
name: str # 唯一作物名称。
|
||||
plant_id: int # 稳定的本地作物 ID,不得重新分配。
|
||||
name: str # 显示名称;同名时使用本地编号区分。
|
||||
level: int # 购买等级门槛。
|
||||
buy: int # 普通种子农场币价格。
|
||||
is_vip: bool # 是否使用点券购买。
|
||||
@@ -103,11 +103,17 @@ class Plant:
|
||||
growth_seconds: int # 首次成熟所需秒数。
|
||||
crop: int # 最多可收获季数。
|
||||
regrowth_seconds: int # 兼容旧版的多季再生长秒数。
|
||||
purchasable: bool = True # 是否允许在种子商店购买。
|
||||
enabled: bool = True # 是否允许新购买和播种;不阻止存量结算。
|
||||
crop_type: int = 1 # 来源条目类型;当前仅允许类型 1 种植。
|
||||
|
||||
def __post_init__(self):
|
||||
text(self.name)
|
||||
if type(self.is_vip) is not bool:
|
||||
raise FarmError("invalid_catalogue")
|
||||
if type(self.purchasable) is not bool or type(self.enabled) is not bool:
|
||||
raise FarmError("invalid_catalogue")
|
||||
integer(self.crop_type, 1)
|
||||
for name in (
|
||||
"plant_id", "growth_seconds", "crop", "regrowth_seconds",
|
||||
"harvest",
|
||||
@@ -124,6 +130,10 @@ class Plant:
|
||||
def cost(self) -> int:
|
||||
return self.vip_buy if self.is_vip else self.buy
|
||||
|
||||
@property
|
||||
def buyable(self) -> bool:
|
||||
return self.enabled and self.purchasable and self.crop_type == 1
|
||||
|
||||
@classmethod
|
||||
def from_legacy(cls, row: dict[str, Any]) -> "Plant":
|
||||
try:
|
||||
@@ -134,6 +144,10 @@ class Plant:
|
||||
integer(row[key])
|
||||
if row["isVip"] not in (0, 1):
|
||||
raise FarmError("invalid_catalogue")
|
||||
for key in ("isBuy", "enabled"):
|
||||
value = row.get(key, 1)
|
||||
if type(value) is not int or value not in (0, 1):
|
||||
raise FarmError("invalid_catalogue")
|
||||
if not isinstance(row["phase"], str):
|
||||
raise FarmError("invalid_catalogue")
|
||||
parts = row["phase"].split(",")
|
||||
@@ -150,6 +164,9 @@ class Plant:
|
||||
bool(row["isVip"]), row["vipBuy"], row["experience"],
|
||||
row["harvest"], row["price"], row["time"] * 3600,
|
||||
row["crop"], sum(phases[1:]),
|
||||
purchasable=bool(row.get("isBuy", 1)),
|
||||
enabled=bool(row.get("enabled", 1)),
|
||||
crop_type=row.get("crop_type", 1),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, OverflowError) as exc:
|
||||
raise FarmError("invalid_catalogue") from exc
|
||||
@@ -177,6 +194,7 @@ class InventoryItem:
|
||||
seeds: int = 0 # 未播种种子数。
|
||||
crops: int = 0 # 已收获产物数。
|
||||
locked: bool = False # 是否禁止出售产物。
|
||||
plant_id: int = 0 # 稳定本地作物 ID;0 仅兼容旧快照构造。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -189,6 +207,29 @@ class Plot:
|
||||
harvest_count: int = 0 # 当前实例已收获季数。
|
||||
yield_bonus: int = 0 # 此地块产量加成百分比。
|
||||
experience_bonus: int = 0 # 此地块经验加成百分比。
|
||||
pest_count: int = 0 # 当前虫数,0~3;每只降低生长速度 5%。
|
||||
pest_spawned: int = 0 # 本季已发生的生虫事件数,除虫后不重放。
|
||||
growth_remaining: int | None = None # 剩余生长工作量,单位为百分之一秒。
|
||||
growth_updated_at: int | None = None # 上次工作量结算的 UTC 整数秒。
|
||||
next_pest_at: int | None = None # 下一次生虫时间,最多每季三次。
|
||||
maturity_pests: int = 0 # 成熟瞬间冻结的虫数,决定本季减产。
|
||||
|
||||
def __post_init__(self):
|
||||
integer(self.pest_count, 0, 3)
|
||||
integer(self.pest_spawned, self.pest_count, 3)
|
||||
integer(self.maturity_pests, 0, 3)
|
||||
for value in (self.growth_remaining, self.growth_updated_at,
|
||||
self.next_pest_at):
|
||||
if value is not None:
|
||||
integer(value)
|
||||
if (self.growth_remaining is None) != (
|
||||
self.growth_updated_at is None
|
||||
):
|
||||
raise FarmError("invalid_growth_state")
|
||||
if (self.growth_updated_at is not None
|
||||
and self.next_pest_at is not None
|
||||
and self.next_pest_at < self.growth_updated_at):
|
||||
raise FarmError("invalid_growth_state")
|
||||
|
||||
@property
|
||||
def withered(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""离线虫害时间结算;只投影状态,不读写数据库或使用随机数。"""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from .domain import Plot, integer
|
||||
|
||||
|
||||
def season_duration(plot: Plot) -> int:
|
||||
return (plot.plant.growth_seconds if plot.harvest_count == 0
|
||||
else plot.plant.regrowth_seconds)
|
||||
|
||||
|
||||
def spawn_interval(plot: Plot) -> int:
|
||||
return max(3600, (season_duration(plot) + 3) // 4)
|
||||
|
||||
|
||||
def start_growth(plot: Plot, now: int) -> Plot:
|
||||
duration = season_duration(plot)
|
||||
return advance_plot(replace(
|
||||
plot, mature_at=integer(now + duration), pest_count=0, pest_spawned=0,
|
||||
growth_remaining=integer(duration * 100), growth_updated_at=now,
|
||||
next_pest_at=integer(now + spawn_interval(plot)), maturity_pests=0,
|
||||
), now)
|
||||
|
||||
|
||||
def _advance(remaining, cursor, pests, spawned, next_at, interval, now):
|
||||
while remaining:
|
||||
rate = 100 - pests * 5
|
||||
finish = integer(cursor + (remaining + rate - 1) // rate)
|
||||
if finish <= now and (next_at is None or finish <= next_at):
|
||||
return 0, finish, pests, spawned, None, finish
|
||||
end = min(now, next_at) if next_at is not None else now
|
||||
remaining -= (end - cursor) * rate
|
||||
cursor = end
|
||||
if next_at is None or cursor < next_at:
|
||||
break
|
||||
pests += 1
|
||||
spawned += 1
|
||||
next_at = integer(next_at + interval) if spawned < 3 else None
|
||||
if cursor == now:
|
||||
break
|
||||
return remaining, cursor, pests, spawned, next_at, None
|
||||
|
||||
|
||||
def advance_plot(plot: Plot, now: int) -> Plot:
|
||||
integer(now)
|
||||
if (plot.plant is None or plot.withered
|
||||
or plot.growth_remaining is None or not plot.growth_remaining):
|
||||
return plot
|
||||
if now < plot.growth_updated_at:
|
||||
return plot
|
||||
interval = spawn_interval(plot)
|
||||
remaining, cursor, pests, spawned, next_at, finish = _advance(
|
||||
plot.growth_remaining, plot.growth_updated_at, plot.pest_count,
|
||||
plot.pest_spawned, plot.next_pest_at, interval, now,
|
||||
)
|
||||
if finish is None:
|
||||
finish = _advance(
|
||||
remaining, cursor, pests, spawned, next_at, interval,
|
||||
integer(cursor + (remaining + 84) // 85),
|
||||
)[-1]
|
||||
return replace(
|
||||
plot, mature_at=finish, pest_count=pests, pest_spawned=spawned,
|
||||
growth_remaining=remaining, growth_updated_at=cursor,
|
||||
next_pest_at=next_at,
|
||||
maturity_pests=pests if remaining == 0 else 0,
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from .domain import (
|
||||
Account, Asset, Identity, InventoryItem, Plot, Receipt, Snapshot,
|
||||
Account, Asset, Identity, InventoryItem, Plot, Plant, Receipt, Snapshot,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ class Repository(Protocol):
|
||||
def account(self) -> Account: ...
|
||||
def register(self, name: str) -> bool: ...
|
||||
def asset(self, asset: Asset, delta: int) -> None: ...
|
||||
def item(self, name: str) -> InventoryItem: ...
|
||||
def item(self, plant: Plant) -> InventoryItem: ...
|
||||
def items(self) -> tuple[InventoryItem, ...]: ...
|
||||
def stock(self, name: str, seeds: int = 0, crops: int = 0) -> None: ...
|
||||
def lock(self, name: str, locked: bool) -> None: ...
|
||||
def stock(self, plant: Plant, seeds: int = 0, crops: int = 0) -> None: ...
|
||||
def lock(self, plant: Plant, locked: bool) -> None: ...
|
||||
def plots(self) -> tuple[Plot, ...]: ...
|
||||
def save_plot(self, plot: Plot) -> None: ...
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ def receipt_text(receipt: Receipt) -> str:
|
||||
"register": "注册", "buy": "购买", "sow": "播种",
|
||||
"harvest": "收获", "eradicate": "铲除",
|
||||
"lock": "锁定设置", "sell": "出售",
|
||||
"clear_pests": "除虫",
|
||||
}
|
||||
data = receipt.data
|
||||
account = data["account"]
|
||||
@@ -18,6 +19,12 @@ def receipt_text(receipt: Receipt) -> str:
|
||||
lines.append(f"实际数量:{data['quantity']}")
|
||||
if "proceeds" in data:
|
||||
lines.append(f"本次收入:{data['proceeds']}")
|
||||
for item in data.get("harvested", ()):
|
||||
if item.get("yield_loss_percent", 0):
|
||||
lines.append(
|
||||
f"地块{item['slot']}:成熟时虫子{item['pests']}只,"
|
||||
f"本季减产{item['yield_loss_percent']}%"
|
||||
)
|
||||
lines.append(f"农场币:{account['point']}")
|
||||
lines.append(f"点券:{account['vip_point']}")
|
||||
lines.append(f"累计经验:{account['experience']}")
|
||||
@@ -34,11 +41,18 @@ def snapshot_text(snapshot: Snapshot) -> str:
|
||||
for item in snapshot.inventory:
|
||||
lock = "锁定" if item.locked else "未锁定"
|
||||
lines.append(
|
||||
f"{item.name}:种子{item.seeds},作物{item.crops},{lock}"
|
||||
f"{item.name}(#{item.plant_id}):种子{item.seeds},"
|
||||
f"作物{item.crops},{lock}"
|
||||
)
|
||||
for plot in snapshot.plots:
|
||||
state = "空地" if plot.plant is None else plot.plant.name
|
||||
if plot.withered:
|
||||
state += "(枯萎)"
|
||||
if plot.pest_count:
|
||||
state += f",虫子{plot.pest_count}只"
|
||||
if plot.growth_remaining:
|
||||
state += f",生长速度降低{plot.pest_count * 5}%"
|
||||
if plot.maturity_pests:
|
||||
state += f",本季已确定减产{plot.maturity_pests * 5}%"
|
||||
lines.append(f"地块{plot.slot}:{state}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -76,46 +76,52 @@ class SQLiteRepository:
|
||||
),
|
||||
)
|
||||
|
||||
def item(self, name: str) -> InventoryItem:
|
||||
def item(self, plant: Plant) -> InventoryItem:
|
||||
row = self._db.execute(
|
||||
"SELECT name, seeds, crops, locked FROM inventory "
|
||||
"WHERE identity=? AND name=?", (self._identity, name),
|
||||
"WHERE identity=? AND plant_id=?",
|
||||
(self._identity, plant.plant_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return InventoryItem(name)
|
||||
return InventoryItem(plant.name, plant_id=plant.plant_id)
|
||||
return InventoryItem(
|
||||
row["name"], row["seeds"], row["crops"], bool(row["locked"]),
|
||||
plant.name, row["seeds"], row["crops"], bool(row["locked"]),
|
||||
plant.plant_id,
|
||||
)
|
||||
|
||||
def items(self) -> tuple[InventoryItem, ...]:
|
||||
rows = self._db.execute(
|
||||
"SELECT name, seeds, crops, locked FROM inventory "
|
||||
"WHERE identity=? ORDER BY name", (self._identity,),
|
||||
"SELECT plant_id, name, seeds, crops, locked FROM inventory "
|
||||
"WHERE identity=? ORDER BY name, plant_id", (self._identity,),
|
||||
).fetchall()
|
||||
return tuple(
|
||||
InventoryItem(r["name"], r["seeds"], r["crops"], bool(r["locked"]))
|
||||
InventoryItem(
|
||||
r["name"], r["seeds"], r["crops"], bool(r["locked"]),
|
||||
r["plant_id"],
|
||||
)
|
||||
for r in rows
|
||||
)
|
||||
|
||||
def stock(self, name: str, seeds: int = 0, crops: int = 0) -> None:
|
||||
previous = self.item(name)
|
||||
def stock(self, plant: Plant, seeds: int = 0, crops: int = 0) -> None:
|
||||
previous = self.item(plant)
|
||||
new_seeds, new_crops = previous.seeds + seeds, previous.crops + crops
|
||||
if min(new_seeds, new_crops) < 0:
|
||||
raise FarmError("insufficient_stock")
|
||||
integer(new_seeds)
|
||||
integer(new_crops)
|
||||
self._db.execute(
|
||||
"INSERT INTO inventory(identity, name, seeds, crops) "
|
||||
"VALUES (?, ?, ?, ?) ON CONFLICT(identity, name) "
|
||||
"DO UPDATE SET seeds=excluded.seeds, crops=excluded.crops",
|
||||
(self._identity, name, new_seeds, new_crops),
|
||||
"INSERT INTO inventory(identity, plant_id, name, seeds, crops) "
|
||||
"VALUES (?, ?, ?, ?, ?) ON CONFLICT(identity, plant_id) "
|
||||
"DO UPDATE SET name=excluded.name, seeds=excluded.seeds, "
|
||||
"crops=excluded.crops",
|
||||
(self._identity, plant.plant_id, plant.name, new_seeds, new_crops),
|
||||
)
|
||||
|
||||
def lock(self, name: str, locked: bool) -> None:
|
||||
self.stock(name)
|
||||
def lock(self, plant: Plant, locked: bool) -> None:
|
||||
self.stock(plant)
|
||||
self._db.execute(
|
||||
"UPDATE inventory SET locked=? WHERE identity=? AND name=?",
|
||||
(int(locked), self._identity, name),
|
||||
"UPDATE inventory SET locked=? WHERE identity=? AND plant_id=?",
|
||||
(int(locked), self._identity, plant.plant_id),
|
||||
)
|
||||
|
||||
def plots(self) -> tuple[Plot, ...]:
|
||||
@@ -130,6 +136,9 @@ class SQLiteRepository:
|
||||
Plant(**json.loads(r["plant"])) if r["plant"] else None,
|
||||
r["mature_at"], r["harvest_count"],
|
||||
r["yield_bonus"], r["experience_bonus"],
|
||||
r["pest_count"], r["pest_spawned"], r["growth_remaining"],
|
||||
r["growth_updated_at"], r["next_pest_at"],
|
||||
r["maturity_pests"],
|
||||
)
|
||||
for r in rows
|
||||
)
|
||||
@@ -143,10 +152,15 @@ class SQLiteRepository:
|
||||
)
|
||||
cursor = self._db.execute(
|
||||
"UPDATE plots SET plant=?, mature_at=?, harvest_count=?, "
|
||||
"yield_bonus=?, experience_bonus=? WHERE identity=? AND slot=?",
|
||||
"yield_bonus=?, experience_bonus=?, pest_count=?, pest_spawned=?, "
|
||||
"growth_remaining=?, growth_updated_at=?, next_pest_at=?, "
|
||||
"maturity_pests=? WHERE identity=? AND slot=?",
|
||||
(
|
||||
data, plot.mature_at, plot.harvest_count, plot.yield_bonus,
|
||||
plot.experience_bonus, self._identity, plot.slot,
|
||||
plot.experience_bonus, plot.pest_count, plot.pest_spawned,
|
||||
plot.growth_remaining, plot.growth_updated_at,
|
||||
plot.next_pest_at, plot.maturity_pests,
|
||||
self._identity, plot.slot,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
|
||||
@@ -12,10 +12,26 @@ from .domain import (
|
||||
FarmError, Identity, Receipt, SchemaError, Snapshot, StorageError,
|
||||
)
|
||||
from .repository import SQLiteRepository
|
||||
from .catalogue import Catalogue
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
APPLICATION_ID = 0x4641524D
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
PEST_COLUMNS = {
|
||||
"pest_count": (
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK(pest_count BETWEEN 0 AND 3)"
|
||||
),
|
||||
"pest_spawned": (
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK(pest_spawned BETWEEN 0 AND 3)"
|
||||
),
|
||||
"growth_remaining": "INTEGER CHECK(growth_remaining>=0)",
|
||||
"growth_updated_at": "INTEGER",
|
||||
"next_pest_at": "INTEGER",
|
||||
"maturity_pests": (
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK(maturity_pests BETWEEN 0 AND 3)"
|
||||
),
|
||||
}
|
||||
|
||||
SCHEMA = (
|
||||
"""CREATE TABLE accounts(
|
||||
@@ -39,13 +55,14 @@ SCHEMA = (
|
||||
)""",
|
||||
"""CREATE TABLE inventory(
|
||||
identity TEXT NOT NULL REFERENCES accounts(identity),
|
||||
plant_id INTEGER NOT NULL CHECK(plant_id>0),
|
||||
name TEXT NOT NULL,
|
||||
seeds INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(typeof(seeds)='integer' AND seeds>=0),
|
||||
crops INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(typeof(crops)='integer' AND crops>=0),
|
||||
locked INTEGER NOT NULL DEFAULT 0 CHECK(locked IN (0,1)),
|
||||
PRIMARY KEY(identity, name)
|
||||
PRIMARY KEY(identity, plant_id)
|
||||
)""",
|
||||
"""CREATE TABLE plots(
|
||||
identity TEXT NOT NULL REFERENCES accounts(identity),
|
||||
@@ -55,6 +72,9 @@ SCHEMA = (
|
||||
harvest_count INTEGER NOT NULL DEFAULT 0 CHECK(harvest_count>=0),
|
||||
yield_bonus INTEGER NOT NULL DEFAULT 0,
|
||||
experience_bonus INTEGER NOT NULL DEFAULT 0,
|
||||
""" + ",\n ".join(
|
||||
f"{name} {definition}" for name, definition in PEST_COLUMNS.items()
|
||||
) + """,
|
||||
PRIMARY KEY(identity, slot)
|
||||
)""",
|
||||
"""CREATE TABLE ledger(
|
||||
@@ -108,7 +128,7 @@ class SQLiteStore:
|
||||
raise StorageError("sqlite_failure") from exc
|
||||
|
||||
@staticmethod
|
||||
def _validate(connection):
|
||||
def _validate(connection, expected_version=SCHEMA_VERSION):
|
||||
app_id = connection.execute("PRAGMA application_id").fetchone()[0]
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
tables = {
|
||||
@@ -118,20 +138,61 @@ class SQLiteStore:
|
||||
}
|
||||
expected = {"accounts", "operations", "inventory", "plots", "ledger"}
|
||||
if (
|
||||
app_id != APPLICATION_ID or version != SCHEMA_VERSION
|
||||
app_id != APPLICATION_ID or version != expected_version
|
||||
or tables != expected
|
||||
):
|
||||
raise SchemaError("unsupported_database_schema")
|
||||
|
||||
async def initialize(self) -> None:
|
||||
async def initialize(self, catalogue: Catalogue | None = None) -> None:
|
||||
async with self._lifecycle:
|
||||
await self._offload(self._initialize)
|
||||
await self._offload(lambda: self._initialize(catalogue))
|
||||
self._ready = True
|
||||
|
||||
def _initialize(self):
|
||||
@staticmethod
|
||||
def _migrate_inventory(connection, catalogue):
|
||||
names = {}
|
||||
for plant in catalogue:
|
||||
names.setdefault(plant.name, set()).add(plant.plant_id)
|
||||
for row in connection.execute(
|
||||
"SELECT plant FROM plots WHERE plant IS NOT NULL"
|
||||
):
|
||||
try:
|
||||
plant = json.loads(row[0])
|
||||
name, plant_id = plant["name"], plant["plant_id"]
|
||||
if (not isinstance(name, str) or type(plant_id) is not int
|
||||
or plant_id < 1):
|
||||
raise ValueError()
|
||||
names.setdefault(name, set()).add(plant_id)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise SchemaError("invalid_plot_snapshot") from exc
|
||||
rows = connection.execute("SELECT * FROM inventory").fetchall()
|
||||
migrated = []
|
||||
for row in rows:
|
||||
ids = names.get(row["name"], set())
|
||||
if len(ids) != 1:
|
||||
raise SchemaError("inventory_identity_unresolved")
|
||||
migrated.append((
|
||||
row["identity"], next(iter(ids)), row["name"],
|
||||
row["seeds"], row["crops"], row["locked"],
|
||||
))
|
||||
statement = next(
|
||||
sql for sql in SCHEMA if sql.startswith("CREATE TABLE inventory(")
|
||||
)
|
||||
connection.execute(statement.replace("inventory(", "inventory_v2(", 1))
|
||||
connection.executemany(
|
||||
"INSERT INTO inventory_v2 VALUES (?, ?, ?, ?, ?, ?)", migrated,
|
||||
)
|
||||
connection.execute("DROP TABLE inventory")
|
||||
connection.execute("ALTER TABLE inventory_v2 RENAME TO inventory")
|
||||
connection.execute("PRAGMA user_version=2")
|
||||
return len(rows)
|
||||
|
||||
def _initialize(self, catalogue=None):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
migrated_rows = None
|
||||
migrated_pests = False
|
||||
try:
|
||||
app = connection.execute("PRAGMA application_id").fetchone()[0]
|
||||
version = connection.execute(
|
||||
@@ -147,10 +208,37 @@ class SQLiteStore:
|
||||
f"PRAGMA application_id={APPLICATION_ID}"
|
||||
)
|
||||
connection.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||
elif app == APPLICATION_ID and version == 1:
|
||||
self._validate(connection, expected_version=1)
|
||||
migration_catalogue = (
|
||||
catalogue if catalogue is not None
|
||||
else Catalogue.bundled()
|
||||
)
|
||||
migrated_rows = self._migrate_inventory(
|
||||
connection,
|
||||
migration_catalogue,
|
||||
)
|
||||
version = 2
|
||||
if app == APPLICATION_ID and version == 2:
|
||||
self._validate(connection, expected_version=2)
|
||||
for name, definition in PEST_COLUMNS.items():
|
||||
connection.execute(
|
||||
f"ALTER TABLE plots ADD COLUMN {name} {definition}"
|
||||
)
|
||||
connection.execute("PRAGMA user_version=3")
|
||||
migrated_pests = True
|
||||
self._validate(connection)
|
||||
connection.commit()
|
||||
if migrated_rows is not None:
|
||||
LOGGER.info(
|
||||
"farm schema migrated version=3 inventory_rows=%s",
|
||||
migrated_rows,
|
||||
)
|
||||
elif migrated_pests:
|
||||
LOGGER.info("farm schema migrated version=3 pests=enabled")
|
||||
except BaseException:
|
||||
connection.rollback()
|
||||
LOGGER.warning("farm schema initialization rolled back")
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"mode": "full_canvas_bottom_center",
|
||||
"reference_soil_height": 79.5,
|
||||
"growth_scale": 0.75,
|
||||
"common_scale": 0.5,
|
||||
"wither_down": 8,
|
||||
"basis": "WebGLMainSceneLandController.SetCropTexture and mainscene RectTransform"
|
||||
}
|
||||
@@ -46,6 +46,13 @@
|
||||
92
|
||||
]
|
||||
},
|
||||
"background/frame/frame.png": {
|
||||
"sha256": "93148c78e15295b623a857ee016768531854696ef28557bb28e044e32a5ce6ac",
|
||||
"size": [
|
||||
230,
|
||||
230
|
||||
]
|
||||
},
|
||||
"background/ripe.png": {
|
||||
"sha256": "1c94af3e0b923defc85d200bd25538bf1ee9a2e744406266cdaf7103c1ea2cbd",
|
||||
"size": [
|
||||
@@ -53,6 +60,279 @@
|
||||
92
|
||||
]
|
||||
},
|
||||
"background/userInfo.png": {
|
||||
"sha256": "09e906c85d12491c297489ee6bcc31cd7f9e0c1527808c9c64d1783e8ae3cff3",
|
||||
"size": [
|
||||
580,
|
||||
275
|
||||
]
|
||||
},
|
||||
"pest/pest1.png": {
|
||||
"sha256": "1ad3f1bd4521ef462056b88070728853fc93de85457eac1903d8dd11dc175642",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest11.png": {
|
||||
"sha256": "ea87a9e6ede36b36d492d641abe9c881ce57aecf130ebb5e9928276588923a52",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest13.png": {
|
||||
"sha256": "063d58763c1c06bf5cf88e73b946dbe9d35dc52709057911247d29528bd80346",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest15.png": {
|
||||
"sha256": "2927464be009a916862457a9dd1cf272e2face4809c8489496b8ca63f1064036",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest17.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest19.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest21.png": {
|
||||
"sha256": "863e604de7a63a6bc82d240b72fc0d16964a3536557400fd7e9e4251179efc0b",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest23.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest25.png": {
|
||||
"sha256": "17752162847dd22b45b041072dde51d9761f24179682794c14db4fe3a21b7843",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest27.png": {
|
||||
"sha256": "5827f8338358373507351449a8e82063c1683c3a0c4caaadf6e679c20306f376",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest29.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest3.png": {
|
||||
"sha256": "a88d9a45472167ad34af98e4a3e6db0bc02c3b7f3c441aef44908a97c136e37f",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest31.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest33.png": {
|
||||
"sha256": "eca7d05f269a417553ae38da5289caea9a8f4ba5bbc592a9477bc5dfa6a6ec1d",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest35.png": {
|
||||
"sha256": "d26b7c64ed82266c23ac90b5a8e7fd5996b15a6a65a9d4893f0f4a01c1facb16",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest37.png": {
|
||||
"sha256": "bb92d5687eb73be0a8bf1d9ae8c2aac5e67dcab6e5184d19e212b5931705c6b5",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest39.png": {
|
||||
"sha256": "5827f8338358373507351449a8e82063c1683c3a0c4caaadf6e679c20306f376",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest41.png": {
|
||||
"sha256": "fa5b7979e0d905454ddd515751e53646614680e6d29f850bdea40957bc6fbeb1",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest43.png": {
|
||||
"sha256": "d7f1eea15102154c623d82cd148126f34e82ef40602d7cae78a6e3ee94970b93",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest45.png": {
|
||||
"sha256": "c84fbc9a11d7e4a0e7ddff301d4dd79f75cc1882c76776fa617d189eec8f5eb4",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest47.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest49.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest5.png": {
|
||||
"sha256": "5ce899cd3c42a7919dea0b43686f261a94e80af9d122337b3c01c01df920a9b0",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest51.png": {
|
||||
"sha256": "3eeb2ef89514543ff616a51db2ad4a54da6fa67d3e573f3cda2b63d376d91d04",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest53.png": {
|
||||
"sha256": "d823df2c2b1d9b721a2727e8e60f496aa7791942b20ff894569819b47dfe7ce4",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest55.png": {
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest57.png": {
|
||||
"sha256": "526eca9a538b3a6acd8f9ef9a358a8df98d2c6cf29667e647b6fb3df5110f5d6",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest59.png": {
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest61.png": {
|
||||
"sha256": "6603a10d50bd88bb1032fd22fe482b0c9ba433ba25967408d64e07b47a6c9b64",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest63.png": {
|
||||
"sha256": "226c96b60648787fd457d2f968c0eee7c610444f191e6c73fdd071fa6e95bf14",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest65.png": {
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest67.png": {
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest69.png": {
|
||||
"sha256": "cc1fb7b27adcdab8bbb3872ab28dab66aee832b02ed36e588f214e03d19cbc8b",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest7.png": {
|
||||
"sha256": "e600a52be8e2aeb9ba01ad3b595fe9badb05a1987c2ec98b8d8bd6fe6fd73a65",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest71.png": {
|
||||
"sha256": "5bd1ed7e9db82572119403fce50ff9c7276df33ad4288869aeac3fa5896652e6",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest73.png": {
|
||||
"sha256": "d0d22707ce214b6c78b74315daa33f93c97e5c20368a002a094cd12964437726",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest75.png": {
|
||||
"sha256": "51e1142e2f4dd489cf392b0b25d2d8c04c3801b6d149254e7b7df7c86155b5e2",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest9.png": {
|
||||
"sha256": "cefc8172fd7f0a6137dd5c67c43ba6b165ee37faf58893ff8f37dbff2cc08d22",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"plant/ATTomato/1.png": {
|
||||
"sha256": "48de2a54c2868852f70e981a0ef8228ad0bb09499e6c9e84057aaf82f16f7270",
|
||||
"size": [
|
||||
@@ -4714,8 +4994,97 @@
|
||||
180,
|
||||
120
|
||||
]
|
||||
},
|
||||
"webgl/seed.png": {
|
||||
"sha256": "353ff338fd5ab3f7812e9d2d7f98df11d0de4e04af6de7f9d5aa0249d40b3609",
|
||||
"size": [
|
||||
69,
|
||||
30
|
||||
]
|
||||
},
|
||||
"webgl/wither.png": {
|
||||
"sha256": "1c87f05e0b7bd8287926351e07746bc9e36371a860afaef0e4b00c51536dc472",
|
||||
"size": [
|
||||
209,
|
||||
92
|
||||
]
|
||||
}
|
||||
},
|
||||
"pest": {
|
||||
"display_size": [
|
||||
22,
|
||||
30
|
||||
],
|
||||
"frames": [
|
||||
"pest/pest1.png",
|
||||
"pest/pest3.png",
|
||||
"pest/pest5.png",
|
||||
"pest/pest7.png",
|
||||
"pest/pest9.png",
|
||||
"pest/pest11.png",
|
||||
"pest/pest13.png",
|
||||
"pest/pest15.png",
|
||||
"pest/pest17.png",
|
||||
"pest/pest19.png",
|
||||
"pest/pest21.png",
|
||||
"pest/pest23.png",
|
||||
"pest/pest25.png",
|
||||
"pest/pest27.png",
|
||||
"pest/pest29.png",
|
||||
"pest/pest31.png",
|
||||
"pest/pest33.png",
|
||||
"pest/pest35.png",
|
||||
"pest/pest37.png",
|
||||
"pest/pest39.png",
|
||||
"pest/pest41.png",
|
||||
"pest/pest43.png",
|
||||
"pest/pest45.png",
|
||||
"pest/pest47.png",
|
||||
"pest/pest49.png",
|
||||
"pest/pest51.png",
|
||||
"pest/pest53.png",
|
||||
"pest/pest55.png",
|
||||
"pest/pest57.png",
|
||||
"pest/pest59.png",
|
||||
"pest/pest61.png",
|
||||
"pest/pest63.png",
|
||||
"pest/pest65.png",
|
||||
"pest/pest67.png",
|
||||
"pest/pest69.png",
|
||||
"pest/pest71.png",
|
||||
"pest/pest73.png",
|
||||
"pest/pest75.png"
|
||||
],
|
||||
"loop": true,
|
||||
"pivot": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"positions": [
|
||||
[
|
||||
-26,
|
||||
13
|
||||
],
|
||||
[
|
||||
-5,
|
||||
42
|
||||
],
|
||||
[
|
||||
24,
|
||||
24
|
||||
]
|
||||
],
|
||||
"sample_rate": 24
|
||||
},
|
||||
"placement": {
|
||||
"basis": "WebGLMainSceneLandController.SetCropTexture and mainscene RectTransform",
|
||||
"common_scale": 0.5,
|
||||
"growth_scale": 0.75,
|
||||
"mode": "full_canvas_bottom_center",
|
||||
"reference_soil_height": 79.5,
|
||||
"schema": 2,
|
||||
"wither_down": 8
|
||||
},
|
||||
"plants": {
|
||||
"ATTomato": {
|
||||
"general": 1,
|
||||
|
||||
@@ -3,10 +3,24 @@
|
||||
import hashlib
|
||||
|
||||
from farm_core.domain import Identity, Snapshot, integer
|
||||
from farm_core.pests import advance_plot
|
||||
|
||||
from .models import PlotView, Scene
|
||||
|
||||
|
||||
def pest_frames(scope, plot, frames):
|
||||
available = list(frames)
|
||||
chosen = []
|
||||
for index in range(plot.pest_count):
|
||||
seed = (
|
||||
f"{scope}:{plot.slot}:{plot.plant.plant_id}:"
|
||||
f"{plot.harvest_count}:{index}"
|
||||
)
|
||||
number = int.from_bytes(hashlib.sha256(seed.encode()).digest(), "big")
|
||||
chosen.append(available.pop(number % len(available)))
|
||||
return tuple(chosen)
|
||||
|
||||
|
||||
def scene_from_snapshot(identity, snapshot, now, metadata):
|
||||
if not isinstance(identity, Identity) or not isinstance(snapshot, Snapshot):
|
||||
raise ValueError("Identity and snapshot are required")
|
||||
@@ -17,42 +31,46 @@ def scene_from_snapshot(identity, snapshot, now, metadata):
|
||||
):
|
||||
raise ValueError("Invalid plot slots")
|
||||
views = []
|
||||
scope = hashlib.sha256(identity.key.encode()).hexdigest()
|
||||
for plot in sorted(snapshot.plots, key=lambda p: p.slot):
|
||||
plot = advance_plot(plot, now)
|
||||
if plot.plant is None:
|
||||
views.append(PlotView(plot.slot, "empty"))
|
||||
continue
|
||||
if plot.withered:
|
||||
views.append(PlotView(
|
||||
plot.slot, "withered", "plant/basic/9.png", size=(150, 212),
|
||||
plot.slot, "withered", "webgl/wither.png",
|
||||
))
|
||||
continue
|
||||
integer(plot.mature_at)
|
||||
frames = pest_frames(scope, plot, metadata["pest"]["frames"])
|
||||
name = plot.plant.name
|
||||
info = metadata["plants"].get(name)
|
||||
if info is None:
|
||||
views.append(PlotView(plot.slot, "growing", "missing"))
|
||||
views.append(PlotView(
|
||||
plot.slot, "growing", "missing", pest_count=plot.pest_count,
|
||||
pest_frames=frames,
|
||||
))
|
||||
continue
|
||||
phases = tuple(dict.fromkeys(int(p) for p in info["phase"].split(",")))
|
||||
duration = (plot.plant.growth_seconds if plot.harvest_count == 0
|
||||
else plot.plant.regrowth_seconds)
|
||||
elapsed = max(0, now - (plot.mature_at - duration))
|
||||
work = (elapsed * 100 if plot.growth_remaining is None else
|
||||
duration * 100 - plot.growth_remaining)
|
||||
ripe = now >= plot.mature_at
|
||||
stage = len(phases) if ripe else sum(
|
||||
elapsed * phases[-1] >= threshold * duration
|
||||
work * phases[-1] >= threshold * duration * 100
|
||||
for threshold in phases
|
||||
)
|
||||
sprite = f"plant/{name}/{stage}.png"
|
||||
size = None
|
||||
if stage == 0:
|
||||
if info["general"]:
|
||||
sprite = "plant/basic/0.png"
|
||||
size = (max(1, 35 + info["offsetW"]),
|
||||
max(1, 58 + info["offsetH"]))
|
||||
sprite = "webgl/seed.png"
|
||||
views.append(PlotView(
|
||||
plot.slot, "ripe" if ripe else "growing", sprite, stage,
|
||||
(info["offsetX"], info["offsetY"]), size,
|
||||
pest_count=plot.pest_count,
|
||||
pest_frames=frames,
|
||||
))
|
||||
return Scene(
|
||||
hashlib.sha256(identity.key.encode()).hexdigest(),
|
||||
snapshot.account, tuple(views),
|
||||
scope, snapshot.account, tuple(views),
|
||||
)
|
||||
|
||||
@@ -52,8 +52,8 @@ class PlotView:
|
||||
state: str # empty/growing/ripe/withered。
|
||||
sprite: str = "" # 包内受控素材名,不来自用户路径。
|
||||
stage: int = 0 # 本季展示阶段。
|
||||
offset: tuple[int, int] = (0, 0) # 旧素材中心偏移。
|
||||
size: tuple[int, int] | None = None # 萌芽/枯萎原始尺寸覆盖。
|
||||
pest_count: int = 0 # 当前可见虫数;参与成图缓存键。
|
||||
pest_frames: tuple[str, ...] = () # 按虫序稳定选择的包内帧,参与缓存键。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -64,6 +64,7 @@ class FarmRenderer:
|
||||
started = perf_counter()
|
||||
canvas = self._base(scene, options)
|
||||
self._plants(canvas, scene, options)
|
||||
self._pests(canvas, scene, options)
|
||||
self._panel(canvas, scene, options, avatar)
|
||||
composed = perf_counter()
|
||||
content = canvas.encode(codec, options.quality, options.method)
|
||||
@@ -123,6 +124,8 @@ class FarmRenderer:
|
||||
def _plants(self, canvas, scene, options):
|
||||
s = lambda n: self._scale(n, options)
|
||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||
rules = self.assets.metadata["placement"]
|
||||
unit = soil_h / rules["reference_soil_height"]
|
||||
first_ripe = True
|
||||
for plot in scene.plots:
|
||||
if not plot.sprite:
|
||||
@@ -137,14 +140,22 @@ class FarmRenderer:
|
||||
font=font(self.font_path, max(10, s(48))),
|
||||
)
|
||||
else:
|
||||
size = plot.size or metadata["size"]
|
||||
factor = rules["common_scale"] if (
|
||||
plot.stage == 0 or plot.state == "withered"
|
||||
) else rules["growth_scale"]
|
||||
size = [n * factor * unit for n in metadata["size"]]
|
||||
plant = self.assets.image(
|
||||
plot.sprite, tuple(max(1, s(n)) for n in size),
|
||||
)
|
||||
offset_x, offset_y = plot.offset
|
||||
center_x = pos["x"] + soil_w / 2
|
||||
bottom = pos["y"] + soil_h
|
||||
if plot.stage == 0 or plot.state == "withered":
|
||||
bottom = pos["y"] + soil_h / 2
|
||||
if plot.state == "withered":
|
||||
bottom += rules["wither_down"] * unit
|
||||
xy = (
|
||||
s(pos["x"] + soil_w / 2 + offset_x) - plant.width // 2,
|
||||
s(pos["y"] + soil_h / 2 + offset_y) - plant.height // 2,
|
||||
round(center_x * options.width / 3072 - plant.width / 2),
|
||||
s(bottom) - plant.height,
|
||||
)
|
||||
canvas.paste(plant, xy)
|
||||
if plot.state == "ripe" and first_ripe:
|
||||
@@ -154,19 +165,36 @@ class FarmRenderer:
|
||||
canvas.paste(icon, (s(pos["x"] + 70), s(pos["y"] - 80)))
|
||||
first_ripe = False
|
||||
|
||||
def _pests(self, canvas, scene, options):
|
||||
s = lambda n: self._scale(n, options)
|
||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||
unit = soil_h / self.assets.metadata["placement"][
|
||||
"reference_soil_height"
|
||||
]
|
||||
config = self.assets.metadata["pest"]
|
||||
size = tuple(max(1, s(n * unit)) for n in config["display_size"])
|
||||
for plot in scene.plots:
|
||||
if plot.state not in ("growing", "ripe"):
|
||||
continue
|
||||
pos = self.assets.metadata["positions"][plot.slot - 1]
|
||||
for frame, (x, y) in zip(plot.pest_frames, config["positions"]):
|
||||
image = self.assets.image(frame, size)
|
||||
center_x = pos["x"] + soil_w / 2 + x * unit
|
||||
center_y = pos["y"] + soil_h / 2 - y * unit
|
||||
canvas.paste(image, (
|
||||
round(center_x * options.width / 3072 - image.width / 2),
|
||||
round(center_y * options.width / 3072 - image.height / 2),
|
||||
))
|
||||
|
||||
def _avatar(self, content, size):
|
||||
if content and len(content) <= MAX_AVATAR_BYTES:
|
||||
try:
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
if image.width * image.height > MAX_AVATAR_PIXELS:
|
||||
raise ValueError("Avatar dimensions exceed limit")
|
||||
image.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
avatar = Image.new("RGBA", size, "#cce3c1")
|
||||
avatar.alpha_composite(image.convert("RGBA"), (
|
||||
(size[0] - image.width) // 2,
|
||||
(size[1] - image.height) // 2,
|
||||
))
|
||||
return avatar
|
||||
return image.convert("RGBA").resize(
|
||||
size, Image.Resampling.LANCZOS,
|
||||
)
|
||||
except (UnidentifiedImageError, OSError, ValueError,
|
||||
Image.DecompressionBombError):
|
||||
logger.warning("farm render avatar rejected; using default")
|
||||
@@ -179,26 +207,44 @@ class FarmRenderer:
|
||||
|
||||
def _panel(self, canvas, scene, options, avatar):
|
||||
s = lambda n: self._scale(n, options)
|
||||
canvas.rectangle(
|
||||
tuple(s(n) for n in (90, 75, 1160, 425)),
|
||||
"#fff9e8", s(24),
|
||||
)
|
||||
canvas.paste(self._avatar(avatar, (s(170), s(170))), (s(125), s(125)))
|
||||
size = max(12, s(55))
|
||||
name = canvas.fit_text(
|
||||
scene.account.name, self.font_path, size, s(790),
|
||||
)
|
||||
canvas.text((s(330), s(105)), name, self.font_path, size, "#493525")
|
||||
account = scene.account
|
||||
lines = (
|
||||
f"等级 {account.level} 经验 {account.experience}",
|
||||
f"农场币 {account.point} 点券 {account.vip_point}",
|
||||
f"已解锁 {len(scene.plots)}/30 块地",
|
||||
)
|
||||
for index, line in enumerate(lines):
|
||||
size = max(10, s(43))
|
||||
text = canvas.fit_text(line, self.font_path, size, s(790))
|
||||
canvas.text(
|
||||
(s(330), s(195 + index * 65)), text,
|
||||
self.font_path, size, "#674d34",
|
||||
|
||||
def paste_asset(name, position):
|
||||
original = self.assets.metadata["images"][name]["size"]
|
||||
image = self.assets.image(
|
||||
name, tuple(max(1, s(n)) for n in original),
|
||||
)
|
||||
canvas.paste(image, tuple(s(n) for n in position))
|
||||
|
||||
paste_asset("background/userInfo.png", (100, 30))
|
||||
canvas.paste(
|
||||
self._avatar(avatar, (max(1, s(140)), max(1, s(150)))),
|
||||
(s(125), s(85)),
|
||||
)
|
||||
paste_asset("background/frame/frame.png", (75, 44))
|
||||
account = scene.account
|
||||
level = account.level
|
||||
current = account.experience - 200 * level * (level + 1) // 2
|
||||
next_total = 200 * (level + 1) * (level + 2) // 2
|
||||
progress = min(247, max(0, current * 247 // next_total))
|
||||
if progress:
|
||||
canvas.rectangle(
|
||||
tuple(s(n) for n in (292, 164, 292 + progress, 189)),
|
||||
(171, 194, 41),
|
||||
)
|
||||
lines = (
|
||||
(account.name, (300, 92), 24, (77, 35, 4), 330),
|
||||
(f"{current} / {next_total}", (365, 164), 20,
|
||||
(102, 120, 19), 165),
|
||||
(str(level), (570, 158), 32, (214, 111, 1), 60),
|
||||
(str(account.point), (330, 211), 24, (253, 253, 253), 100),
|
||||
(str(account.vip_point), (570, 211), 24, (253, 253, 253), 40),
|
||||
)
|
||||
for value, position, original_size, color, width in lines:
|
||||
size = max(1, s(original_size))
|
||||
text = canvas.fit_text(
|
||||
value, self.font_path, size, max(1, s(width)),
|
||||
)
|
||||
canvas.text(
|
||||
tuple(s(n) for n in position), text,
|
||||
self.font_path, size, color,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"source_url": "https://game.qqnc.qq.com/happyfarm/unity/Assets/AssetBundles/WebGL/mainscene_053483f584f07436ac0569e2f4f7ed01",
|
||||
"source_sha256": "3766696845401ddcc877b304db34c9c3ccf5f8e92cad1a47ddbf7defcedf6362",
|
||||
"extractor": "UnityPy 1.25.4 Sprite.image; full sprite rect checked",
|
||||
"images": {
|
||||
"pest/pest23.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest53.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "d823df2c2b1d9b721a2727e8e60f496aa7791942b20ff894569819b47dfe7ce4"
|
||||
},
|
||||
"pest/pest63.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "226c96b60648787fd457d2f968c0eee7c610444f191e6c73fdd071fa6e95bf14"
|
||||
},
|
||||
"pest/pest37.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "bb92d5687eb73be0a8bf1d9ae8c2aac5e67dcab6e5184d19e212b5931705c6b5"
|
||||
},
|
||||
"pest/pest75.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "51e1142e2f4dd489cf392b0b25d2d8c04c3801b6d149254e7b7df7c86155b5e2"
|
||||
},
|
||||
"pest/pest15.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "2927464be009a916862457a9dd1cf272e2face4809c8489496b8ca63f1064036"
|
||||
},
|
||||
"pest/pest13.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "063d58763c1c06bf5cf88e73b946dbe9d35dc52709057911247d29528bd80346"
|
||||
},
|
||||
"pest/pest41.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fa5b7979e0d905454ddd515751e53646614680e6d29f850bdea40957bc6fbeb1"
|
||||
},
|
||||
"pest/pest55.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
|
||||
},
|
||||
"pest/pest7.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "e600a52be8e2aeb9ba01ad3b595fe9badb05a1987c2ec98b8d8bd6fe6fd73a65"
|
||||
},
|
||||
"pest/pest35.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "d26b7c64ed82266c23ac90b5a8e7fd5996b15a6a65a9d4893f0f4a01c1facb16"
|
||||
},
|
||||
"pest/pest51.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "3eeb2ef89514543ff616a51db2ad4a54da6fa67d3e573f3cda2b63d376d91d04"
|
||||
},
|
||||
"pest/pest39.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "5827f8338358373507351449a8e82063c1683c3a0c4caaadf6e679c20306f376"
|
||||
},
|
||||
"pest/pest25.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "17752162847dd22b45b041072dde51d9761f24179682794c14db4fe3a21b7843"
|
||||
},
|
||||
"pest/pest57.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "526eca9a538b3a6acd8f9ef9a358a8df98d2c6cf29667e647b6fb3df5110f5d6"
|
||||
},
|
||||
"pest/pest1.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "1ad3f1bd4521ef462056b88070728853fc93de85457eac1903d8dd11dc175642"
|
||||
},
|
||||
"pest/pest69.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "cc1fb7b27adcdab8bbb3872ab28dab66aee832b02ed36e588f214e03d19cbc8b"
|
||||
},
|
||||
"pest/pest5.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "5ce899cd3c42a7919dea0b43686f261a94e80af9d122337b3c01c01df920a9b0"
|
||||
},
|
||||
"webgl/wither.png": {
|
||||
"size": [
|
||||
209,
|
||||
92
|
||||
],
|
||||
"sha256": "1c87f05e0b7bd8287926351e07746bc9e36371a860afaef0e4b00c51536dc472"
|
||||
},
|
||||
"pest/pest9.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "cefc8172fd7f0a6137dd5c67c43ba6b165ee37faf58893ff8f37dbff2cc08d22"
|
||||
},
|
||||
"pest/pest33.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "eca7d05f269a417553ae38da5289caea9a8f4ba5bbc592a9477bc5dfa6a6ec1d"
|
||||
},
|
||||
"pest/pest3.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "a88d9a45472167ad34af98e4a3e6db0bc02c3b7f3c441aef44908a97c136e37f"
|
||||
},
|
||||
"pest/pest47.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest67.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
|
||||
},
|
||||
"pest/pest65.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
|
||||
},
|
||||
"pest/pest29.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest31.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest59.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
|
||||
},
|
||||
"pest/pest43.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "d7f1eea15102154c623d82cd148126f34e82ef40602d7cae78a6e3ee94970b93"
|
||||
},
|
||||
"pest/pest21.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "863e604de7a63a6bc82d240b72fc0d16964a3536557400fd7e9e4251179efc0b"
|
||||
},
|
||||
"pest/pest11.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "ea87a9e6ede36b36d492d641abe9c881ce57aecf130ebb5e9928276588923a52"
|
||||
},
|
||||
"pest/pest17.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest45.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "c84fbc9a11d7e4a0e7ddff301d4dd79f75cc1882c76776fa617d189eec8f5eb4"
|
||||
},
|
||||
"pest/pest73.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "d0d22707ce214b6c78b74315daa33f93c97e5c20368a002a094cd12964437726"
|
||||
},
|
||||
"webgl/seed.png": {
|
||||
"size": [
|
||||
69,
|
||||
30
|
||||
],
|
||||
"sha256": "353ff338fd5ab3f7812e9d2d7f98df11d0de4e04af6de7f9d5aa0249d40b3609"
|
||||
},
|
||||
"pest/pest27.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "5827f8338358373507351449a8e82063c1683c3a0c4caaadf6e679c20306f376"
|
||||
},
|
||||
"pest/pest19.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest61.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "6603a10d50bd88bb1032fd22fe482b0c9ba433ba25967408d64e07b47a6c9b64"
|
||||
},
|
||||
"pest/pest49.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest71.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "5bd1ed7e9db82572119403fce50ff9c7276df33ad4288869aeac3fa5896652e6"
|
||||
}
|
||||
},
|
||||
"pest": {
|
||||
"frames": [
|
||||
"pest/pest1.png",
|
||||
"pest/pest3.png",
|
||||
"pest/pest5.png",
|
||||
"pest/pest7.png",
|
||||
"pest/pest9.png",
|
||||
"pest/pest11.png",
|
||||
"pest/pest13.png",
|
||||
"pest/pest15.png",
|
||||
"pest/pest17.png",
|
||||
"pest/pest19.png",
|
||||
"pest/pest21.png",
|
||||
"pest/pest23.png",
|
||||
"pest/pest25.png",
|
||||
"pest/pest27.png",
|
||||
"pest/pest29.png",
|
||||
"pest/pest31.png",
|
||||
"pest/pest33.png",
|
||||
"pest/pest35.png",
|
||||
"pest/pest37.png",
|
||||
"pest/pest39.png",
|
||||
"pest/pest41.png",
|
||||
"pest/pest43.png",
|
||||
"pest/pest45.png",
|
||||
"pest/pest47.png",
|
||||
"pest/pest49.png",
|
||||
"pest/pest51.png",
|
||||
"pest/pest53.png",
|
||||
"pest/pest55.png",
|
||||
"pest/pest57.png",
|
||||
"pest/pest59.png",
|
||||
"pest/pest61.png",
|
||||
"pest/pest63.png",
|
||||
"pest/pest65.png",
|
||||
"pest/pest67.png",
|
||||
"pest/pest69.png",
|
||||
"pest/pest71.png",
|
||||
"pest/pest73.png",
|
||||
"pest/pest75.png"
|
||||
],
|
||||
"sample_rate": 24,
|
||||
"loop": true,
|
||||
"display_size": [
|
||||
22,
|
||||
30
|
||||
],
|
||||
"pivot": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"positions": [
|
||||
[
|
||||
-26,
|
||||
13
|
||||
],
|
||||
[
|
||||
-5,
|
||||
42
|
||||
],
|
||||
[
|
||||
24,
|
||||
24
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 10 KiB |