fixed # 修复作物资源位置不对的历史遗留BUG

add # 新增作物会生虫的逻辑
This commit is contained in:
2026-10-19 17:25:25 +08:00
parent 24ae6102c0
commit 15646525cb
80 changed files with 793505 additions and 226 deletions
+84 -23
View File
@@ -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,
+18 -8
View File
@@ -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())
+43 -2
View File
@@ -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:
+67
View File
@@ -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,
)
+4 -4
View File
@@ -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: ...
+15 -1
View File
@@ -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)
+33 -19
View File
@@ -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:
+95 -7
View File
@@ -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:
Binary file not shown.
+9
View File
@@ -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"
}
+369
View File
@@ -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,
+29 -11
View File
@@ -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),
)
+2 -2
View File
@@ -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)
+79 -33
View File
@@ -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,
)
+354
View File
@@ -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
]
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB