151 lines
5.0 KiB
Python
151 lines
5.0 KiB
Python
"""CardNode -- visual representation of a single card.
|
|
|
|
One node per physical card. The node is *always* a child of the table root --
|
|
its parent never changes when the card moves between piles. Instead the
|
|
``GameState`` is the source of truth, and each frame the layout pass tells each
|
|
``CardNode`` its target ``(x, y, z, face_up)``. The node springs toward that
|
|
target, applies a slight tilt during motion, and renders a drop shadow.
|
|
|
|
Hit-testing uses a stateless AABB so the table can resolve "topmost card under
|
|
cursor" exactly like Balatro's HandHolder.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from simvx.core import Node2D, Sprite2D
|
|
from simvx.core.math.types import Vec2
|
|
|
|
from .card_textures import (
|
|
CARD_H,
|
|
CARD_W,
|
|
CardId,
|
|
get_card_back,
|
|
get_card_face,
|
|
get_shadow,
|
|
)
|
|
|
|
# Spring tuning -- crisp but smooth.
|
|
FOLLOW_SPEED = 22.0
|
|
ROT_TILT_SCALE = 0.06 # radians per pixel of x-velocity (clamped)
|
|
ROT_DAMP = 14.0
|
|
ROT_CLAMP = math.radians(15)
|
|
SHADOW_REST = Vec2(2, 8)
|
|
SHADOW_DRAG = Vec2(4, 18)
|
|
SHADOW_SPEED = 12.0
|
|
|
|
|
|
class CardNode(Node2D):
|
|
"""A draggable, springy visual for one card.
|
|
|
|
The owning ``TableNode`` calls :meth:`set_target` each frame and
|
|
:meth:`set_face` whenever the underlying ``CardState.face_up`` flips. The
|
|
node never mutates game state itself.
|
|
"""
|
|
|
|
def __init__(self, card_id: CardId, name: str | None = None) -> None:
|
|
super().__init__(name=name or f"Card({card_id})")
|
|
self.card_id = card_id
|
|
self._face_up = False
|
|
self._target_pos = Vec2(0, 0)
|
|
self._spring_pos = Vec2(0, 0)
|
|
self._velocity = Vec2(0, 0)
|
|
self._tilt = 0.0
|
|
self._shadow_offset = Vec2(SHADOW_REST.x, SHADOW_REST.y)
|
|
self._shadow_target = Vec2(SHADOW_REST.x, SHADOW_REST.y)
|
|
# Rendering ordering -- higher draws on top. Table sets this per frame.
|
|
self._depth = 0
|
|
self._is_dragging = False
|
|
self._aabb_half = Vec2(CARD_W * 0.5, CARD_H * 0.5)
|
|
|
|
# ------------------------------------------------------------ build
|
|
def on_ready(self) -> None:
|
|
self.shadow = self.add_child(
|
|
Sprite2D(
|
|
texture=get_shadow(),
|
|
width=CARD_W + 24,
|
|
height=CARD_H + 24,
|
|
colour=(0, 0, 0, 1.0),
|
|
name="Shadow",
|
|
)
|
|
)
|
|
self.face = self.add_child(
|
|
Sprite2D(
|
|
texture=get_card_back(),
|
|
width=CARD_W,
|
|
height=CARD_H,
|
|
name="Face",
|
|
)
|
|
)
|
|
self.set_face(self._face_up)
|
|
|
|
# ------------------------------------------------------------ public API
|
|
def set_target(self, pos: Vec2, depth: int = 0, snap: bool = False) -> None:
|
|
self._target_pos = pos
|
|
self._depth = depth
|
|
# Drive engine z-order from logical depth so render matches game state.
|
|
self.z_index = depth
|
|
if snap:
|
|
self._spring_pos = Vec2(pos.x, pos.y)
|
|
|
|
def set_face(self, face_up: bool) -> None:
|
|
if face_up == self._face_up and hasattr(self, "face"):
|
|
return
|
|
self._face_up = face_up
|
|
if hasattr(self, "face"):
|
|
self.face.texture = get_card_face(self.card_id) if face_up else get_card_back()
|
|
|
|
def begin_drag(self) -> None:
|
|
self._is_dragging = True
|
|
self._shadow_target = Vec2(SHADOW_DRAG.x, SHADOW_DRAG.y)
|
|
|
|
def end_drag(self) -> None:
|
|
self._is_dragging = False
|
|
self._shadow_target = Vec2(SHADOW_REST.x, SHADOW_REST.y)
|
|
|
|
@property
|
|
def face_up(self) -> bool:
|
|
return self._face_up
|
|
|
|
@property
|
|
def depth(self) -> int:
|
|
return self._depth
|
|
|
|
def contains(self, pos: Vec2) -> bool:
|
|
return abs(pos.x - self.position.x) <= self._aabb_half.x and abs(pos.y - self.position.y) <= self._aabb_half.y
|
|
|
|
# ------------------------------------------------------------ per-frame
|
|
def on_update(self, dt: float) -> None:
|
|
a = min(1.0, FOLLOW_SPEED * dt)
|
|
new_pos = Vec2(
|
|
self._spring_pos.x + (self._target_pos.x - self._spring_pos.x) * a,
|
|
self._spring_pos.y + (self._target_pos.y - self._spring_pos.y) * a,
|
|
)
|
|
self._velocity = Vec2(
|
|
(new_pos.x - self._spring_pos.x) / max(dt, 1e-3),
|
|
(new_pos.y - self._spring_pos.y) / max(dt, 1e-3),
|
|
)
|
|
self._spring_pos = new_pos
|
|
|
|
# Movement-driven tilt
|
|
target_tilt = max(-ROT_CLAMP, min(ROT_CLAMP, self._velocity.x * ROT_TILT_SCALE * 0.01))
|
|
b = min(1.0, ROT_DAMP * dt)
|
|
self._tilt += (target_tilt - self._tilt) * b
|
|
|
|
# Shadow spring
|
|
c = min(1.0, SHADOW_SPEED * dt)
|
|
self._shadow_offset = Vec2(
|
|
self._shadow_offset.x + (self._shadow_target.x - self._shadow_offset.x) * c,
|
|
self._shadow_offset.y + (self._shadow_target.y - self._shadow_offset.y) * c,
|
|
)
|
|
|
|
self.position = self._spring_pos
|
|
self.rotation = self._tilt
|
|
if hasattr(self, "shadow"):
|
|
self.shadow.position = self._shadow_offset
|
|
self.face.position = Vec2(0, 0)
|
|
|
|
|
|
__all__ = ["CardNode"]
|