Files
simvx/examples/ports/solitaire/nodes/card_node.py
T

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"]