character.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. import os
  2. from copy import deepcopy
  3. from typing import Type
  4. import pygame
  5. import jogai
  6. from jogai import _events, logger, settings, utils
  7. from jogai._events import _get_keys
  8. from jogai._globals import _window
  9. from jogai.translations import gettext as _
  10. class Velocity:
  11. x = 0
  12. y = 0
  13. def __repr__(self):
  14. return f'{self.x}, {self.y}'
  15. class Character(pygame.sprite.Sprite):
  16. """
  17. A videogame character with a bunch of actions.
  18. """
  19. _predefined_key_actions_allowed = [
  20. _('backward'),
  21. _('decrease'),
  22. _('down'),
  23. _('forward'),
  24. _('increase'),
  25. _('jump'),
  26. _('up'),
  27. ]
  28. _predefined_attributes_allowed = [
  29. _('height'),
  30. _('keys'),
  31. _('position'),
  32. _('speed'),
  33. _('width'),
  34. ]
  35. def __init__(
  36. self,
  37. image_filename: str,
  38. name: str = '',
  39. ):
  40. if not isinstance(name, str):
  41. raise TypeError(_('The character name must be a string.'))
  42. self.scale_factor = 1.0
  43. self.velocity = Velocity()
  44. self.predefined_keys = {}
  45. self.image_filename = image_filename
  46. self.image = None
  47. self._original_image = None
  48. self._original_rect = None
  49. self.rect = None
  50. self.name = name
  51. self.x = 0
  52. self.y = 0
  53. self.is_jumping = False
  54. self.jump_force = settings.DEFAULT_JUMP_FORCE
  55. self._jump_counter = self.jump_force
  56. self.angle = 0
  57. self.flipped = {'horizontal': False, 'vertical': False}
  58. super().__init__()
  59. self.load(image_filename)
  60. def __repr__(self):
  61. return f'{_("Character")}: {self.name}'
  62. def _load_data(self, data: dict):
  63. """
  64. Load character attributes from data schema.
  65. Only declarated attributes in predefined_attributes_allowed are allowed.
  66. Parameters:
  67. data: input schema
  68. """
  69. if data is None:
  70. data = {}
  71. if not isinstance(data, dict):
  72. raise TypeError(_('The data input must be a dict.'))
  73. for attribute in data.keys():
  74. # utils._convert_function_arguments_to_string()
  75. if attribute in self._predefined_attributes_allowed:
  76. eval(f'self.set_{attribute}(data["{attribute}"])')
  77. @property
  78. def height(self) -> int:
  79. return self.rect.height
  80. @property
  81. def width(self) -> int:
  82. return self.rect.width
  83. @property
  84. def speed(self) -> float:
  85. return (abs(self.velocity.x) + abs(self.velocity.y)) / 2
  86. def _eval_predefined_function(
  87. self, function: str, arguments: str | list | dict
  88. ):
  89. if not isinstance(function, str):
  90. raise TypeError(_('The function name must be a string.'))
  91. processed_arguments = utils._convert_function_arguments_to_string(
  92. arguments
  93. )
  94. eval(f'self.{function}({processed_arguments})')
  95. def move(
  96. self,
  97. x: int | None = None,
  98. y: int | None = None,
  99. key: str = '',
  100. relative: bool = True,
  101. ):
  102. """
  103. Move the character the number of pixels indicated in the x and y parameters
  104. Parameters:
  105. x: Number of pixels to move on the abscissa axis
  106. y: Number of pixels to move on the ordinate axis
  107. key: Optional key to perform the action
  108. relative: True for a incremental movement, False for an absolute movement
  109. Examples:
  110. >>> protagonist = Character('protagonist.png')
  111. >>> protagonist.move(5, 3)
  112. >>> protagonist.move()
  113. """
  114. if x is None:
  115. x = self.velocity.x
  116. if y is None:
  117. y = self.velocity.y
  118. if not isinstance(x, int) or not isinstance(y, int):
  119. raise TypeError(_('Amount to move ​​must be an integer.'))
  120. if not isinstance(key, str):
  121. raise TypeError(_('Key pressed must be a string.'))
  122. if not key or _get_keys()[utils.eval_key(key)]:
  123. if relative:
  124. self.x += x
  125. self.y += y
  126. self.velocity.x = abs(self.velocity.x)
  127. if x < 0:
  128. self.velocity.x *= -1
  129. self.velocity.y = abs(self.velocity.y)
  130. if y < 0:
  131. self.velocity.y *= -1
  132. else:
  133. self.x = x
  134. self.y = y
  135. def move_to(self, x: int, y: int, key: str = ''):
  136. """
  137. Move the character to (x, y) position.
  138. Parameters:
  139. x: coordinate in horizontal axis
  140. y: coordinate in vertical axis
  141. key: Optional key to perform the action
  142. Examples:
  143. >>> protagonist = Character('protagonist.png')
  144. >>> protagonist.move_to(0, 0)
  145. """
  146. self.move(x, y, key, relative=False)
  147. def backward(self, amount: int | None = None, key: str = ''):
  148. """
  149. Move the character in the negative direction of the abscissa.
  150. Parameters:
  151. amount: Number of pixels to move
  152. key: Optional key to perform the action
  153. Examples:
  154. >>> protagonist = Character('protagonist.png')
  155. >>> protagonist.backward(3)
  156. >>> protagonist.backward()
  157. """
  158. if amount is None:
  159. amount = abs(self.velocity.x)
  160. if not isinstance(amount, int):
  161. raise TypeError(_('The amount to go up must be an integer.'))
  162. self.move(-amount, 0, key)
  163. def down(self, amount: int | None = None, key: str = ''):
  164. """
  165. Move the character in the positive direction of the ordinate.
  166. Parameters:
  167. amount: Number of pixels to move
  168. key: Optional key to perform the action
  169. Examples:
  170. >>> protagonist = Character('protagonist.png')
  171. >>> protagonist.down(3)
  172. >>> protagonist.down()
  173. """
  174. if amount is None:
  175. amount = abs(self.velocity.y)
  176. if not isinstance(amount, int):
  177. raise TypeError(_('The amount to go down must be an integer.'))
  178. if not self.is_jumping:
  179. self.move(0, amount, key)
  180. def forward(self, amount: int | None = None, key: str = ''):
  181. """
  182. Move the character in the positive direction of the abscissa.
  183. Parameters:
  184. amount: Number of pixels to move
  185. key: Optional key to perform the action
  186. Examples:
  187. >>> protagonist = Character('protagonist.png')
  188. >>> protagonist.forward(3)
  189. >>> protagonist.forward()
  190. """
  191. if amount is None:
  192. amount = abs(self.velocity.x)
  193. if not isinstance(amount, int):
  194. raise TypeError(_('The amount to go forward must be an integer.'))
  195. self.move(amount, 0, key)
  196. def up(self, amount: int | None = None, key: str = ''):
  197. """
  198. Move the character in the negative direction of the ordinate.
  199. Parameters:
  200. amount: Number of pixels to move
  201. key: Optional key to perform the action
  202. Examples:
  203. >>> protagonist = Character('protagonist.png')
  204. >>> protagonist.up(3)
  205. >>> protagonist.up()
  206. """
  207. if amount is None:
  208. amount = abs(self.velocity.y)
  209. if not isinstance(amount, int):
  210. raise TypeError(_('The amount to go up must be an integer.'))
  211. if not self.is_jumping:
  212. self.move(0, -amount, key)
  213. def jump(self, force: float | None = None, key: str = ''):
  214. """
  215. Jump the character.
  216. Parameters:
  217. force: Jump force
  218. key: Optional key to perform the action
  219. Examples:
  220. >>> protagonist = Character('protagonist.png')
  221. >>> protagonist.jump()
  222. """
  223. if force is None:
  224. force = settings.DEFAULT_JUMP_FORCE
  225. if isinstance(force, int):
  226. force = float(force)
  227. if not isinstance(force, float):
  228. raise TypeError(_('The jump force must be a float number.'))
  229. if force < 0:
  230. raise ValueError(_('The jump force must be a non-negative float.'))
  231. if not isinstance(key, str):
  232. raise TypeError(_('Key pressed must be a string.'))
  233. self.jump_force = force
  234. self.jump_counter = force
  235. if not self.is_jumping and (
  236. not key or _get_keys()[utils.eval_key(key)]
  237. ):
  238. self.is_jumping = True
  239. self.velocity.y = -abs(self.velocity.y)
  240. def rotate(self, increment: int = 1):
  241. """
  242. Rotate the character clockwise.
  243. Parameters:
  244. increment: Amount to be rotated
  245. Examples:
  246. >>> protagonist = Character('protagonist.png')
  247. >>> protagonist.rotate(90)
  248. """
  249. # TODO: rotate from the center
  250. if not isinstance(increment, int):
  251. raise TypeError(_('The rotation increment must be an integer.'))
  252. self.angle -= increment
  253. self.set_angle()
  254. def decrease(self, decrement: float | int = 0.001, key: str = ''):
  255. """
  256. Decreases the size of the character.
  257. Parameters:
  258. decrement: Amount to increase to the scale factor
  259. key: Optional key to perform the action
  260. Examples:
  261. >>> protagonist = Character('protagonist.png')
  262. >>> protagonist.decrease(0.1)
  263. """
  264. if isinstance(decrement, int):
  265. decrement = float(decrement)
  266. if not isinstance(decrement, float):
  267. raise TypeError(_('The decrement must be a float number.'))
  268. self.increase(-decrement, key)
  269. def increase(self, increment: float | int = 0.001, key: str = ''):
  270. """
  271. Increases the size of the character.
  272. Parameters:
  273. increment: Amount to increase to the scale factor
  274. key: Optional key to perform the action
  275. Examples:
  276. >>> protagonist = Character('protagonist.png')
  277. >>> protagonist.increase(0.1)
  278. """
  279. if isinstance(increment, int):
  280. increment = float(increment)
  281. if not isinstance(increment, float):
  282. raise TypeError(_('The increment must be a float number.'))
  283. if not key or _get_keys()[utils.eval_key(key)]:
  284. if self.scale_factor + increment < 0:
  285. self.scale_to(0)
  286. else:
  287. self.scale_to(self.scale_factor + increment)
  288. def scale_to(self, scale_factor: float = 1.0):
  289. """
  290. Scale the character according to the scale factor.
  291. Parameters:
  292. scale_factor: Factor to scale
  293. Examples:
  294. >>> protagonist = Character('protagonist.png')
  295. >>> protagonist.scale_to(2)
  296. """
  297. if not isinstance(scale_factor, float) and not isinstance(
  298. scale_factor, int
  299. ):
  300. raise TypeError(
  301. _('The scale factor must be a positive number (float or int).')
  302. )
  303. if scale_factor < 0:
  304. raise ValueError(
  305. _(
  306. 'The scale factor must be a non-negative number (float or int).'
  307. )
  308. )
  309. self.image = pygame.transform.scale(
  310. self._original_image,
  311. (
  312. int(self._original_rect.width * scale_factor),
  313. int(self._original_rect.height * scale_factor),
  314. ),
  315. )
  316. self.scale_factor = scale_factor
  317. self.rect = self.image.get_rect()
  318. def flip(self, horizontal: bool = True, vertical: bool = True):
  319. """
  320. Flip the character on horizontal and/or vertical axis.
  321. Parameters:
  322. horizontal: Flipped on horizontal axis
  323. vertical: Flipped on vertical axis
  324. Examples:
  325. >>> protagonist = Character('protagonist.png')
  326. >>> protagonist.flip(True, False)
  327. """
  328. if not isinstance(horizontal, bool) or not isinstance(vertical, bool):
  329. raise TypeError(
  330. _('Both the horizontal and vertical axis must be booleans.')
  331. )
  332. self.image = pygame.transform.flip(
  333. self.image,
  334. self.flipped['horizontal'] ^ horizontal,
  335. self.flipped['vertical'] ^ vertical,
  336. )
  337. self.flipped['horizontal'] = horizontal
  338. self.flipped['vertical'] = vertical
  339. def set_angle(self, angle: int | None = None):
  340. """
  341. Set the character rotation.
  342. Parameters:
  343. angle: Angle to be rotated.
  344. Examples:
  345. >>> protagonist = Character('protagonist.png')
  346. >>> protagonist.set_angle(90)
  347. """
  348. # TODO: rotate from the center
  349. if not isinstance(angle, int) and angle is not None:
  350. raise TypeError(_('The rotation angle must be an integer.'))
  351. if angle is not None:
  352. self.angle = 360 - angle
  353. self.image = pygame.transform.rotozoom(
  354. self._original_image, self.angle, self.scale_factor
  355. )
  356. def set_height(self, height: int):
  357. """
  358. Set the character height.
  359. Parameters:
  360. height: The character height in pixels
  361. Examples:
  362. >>> protagonist = Character('protagonist.png')
  363. >>> protagonist.set_height(100)
  364. """
  365. if not isinstance(height, int):
  366. raise TypeError(_('Height must be a not negative integer.'))
  367. if height < 1 or self._original_rect.height < 1:
  368. raise ValueError(_('Height must be a not negative integer.'))
  369. self.scale_factor = height / self._original_rect.height
  370. self.image = pygame.transform.scale(
  371. self._original_image,
  372. (int(self._original_rect.width * self.scale_factor), height),
  373. )
  374. self.rect = self.image.get_rect()
  375. def set_keys(self, predefined_keys: dict):
  376. """
  377. Set the character predefined keys to perform actions.
  378. Only declarated actions in _predefined_key_actions_allowed are allowed.
  379. Parameters:
  380. predefined_keys: The predefined keys with the associated action.
  381. Examples:
  382. >>> protagonist = Character('protagonist.png')
  383. >>> protagonist.set_keys({'forward': 'd'})
  384. """
  385. if not isinstance(predefined_keys, dict):
  386. raise TypeError(
  387. _('The predefined keys must be a dict. No keys are loaded.')
  388. )
  389. filtered_predefined_keys = {}
  390. for action, key in predefined_keys.items():
  391. if action in self._predefined_key_actions_allowed:
  392. filtered_predefined_keys.update({action: key})
  393. self.predefined_keys.update(filtered_predefined_keys)
  394. def set_position(self, position: tuple | list, key: str = ''):
  395. """
  396. Set the character position as tuple. To use as separate coordinates call move_to().
  397. Parameters:
  398. position: A tuple with abscissa and ordinate coordinates
  399. key: Optional key to perform the action
  400. Examples:
  401. >>> protagonist = Character('protagonist.png')
  402. >>> protagonist.set_position((0, 0))
  403. """
  404. if not isinstance(position, tuple) and not isinstance(position, list):
  405. raise TypeError(_('The position must be a tuple or list (x, y).'))
  406. if len(position) != 2:
  407. raise ValueError(_('The position has two coordinates (x, y).'))
  408. self.move(position[0], position[1], key, relative=False)
  409. def set_speed(self, speed: int):
  410. """
  411. Set the character speed.
  412. Parameters:
  413. speed: The character speed
  414. Examples:
  415. >>> protagonist = Character('protagonist.png')
  416. >>> protagonist.set_speed(10)
  417. """
  418. if not isinstance(speed, int):
  419. raise TypeError(_('The speed must be an integer.'))
  420. self.velocity.x = speed
  421. self.velocity.y = speed
  422. def set_width(self, width: int):
  423. """
  424. Set the character width.
  425. Parameters:
  426. width: the character width in pixels
  427. Examples:
  428. >>> protagonist = Character('protagonist.png')
  429. >>> protagonist.set_width(100)
  430. """
  431. if not isinstance(width, int):
  432. raise TypeError(_('Width must be a not negative integer.'))
  433. if width < 1 or self._original_rect.width < 1:
  434. raise ValueError(_('Width must be a not negative integer.'))
  435. self.scale_factor = width / self._original_rect.width
  436. self.image = pygame.transform.scale(
  437. self._original_image,
  438. (width, int(self._original_rect.height * self.scale_factor)),
  439. )
  440. self.rect = self.image.get_rect()
  441. def touch(
  442. self,
  443. other_characters: Type['Character'] | Type['jogai.group.Group'] | list,
  444. key: str = '',
  445. ) -> bool:
  446. """
  447. Detect if one character is touching another(s).
  448. Parameters:
  449. other_characters: character or list of characters to check
  450. Returns:
  451. True, if it is touching; False, otherwise.
  452. Examples:
  453. >>> protagonist = Character('protagonist.png')
  454. >>> secondary = Character('secondary.png')
  455. >>> protagonist.touch(secondary)
  456. True
  457. """
  458. if isinstance(other_characters, Character):
  459. other_characters = [other_characters]
  460. if not isinstance(other_characters, list) and not isinstance(
  461. other_characters, jogai.group.Group
  462. ):
  463. raise TypeError(
  464. _(
  465. 'The instances to be touched must be a character or a list of characters.'
  466. )
  467. )
  468. if isinstance(other_characters, list) and not all(
  469. map(lambda x: isinstance(x, Character), other_characters)
  470. ):
  471. raise ValueError(
  472. _('All instances to be touched must be characters.')
  473. )
  474. if not isinstance(key, str):
  475. raise TypeError(_('Key pressed must be a string.'))
  476. for other_character in other_characters:
  477. if not key or _get_keys()[utils.eval_key(key)]:
  478. if self.rect.colliderect(other_character.rect):
  479. return True
  480. return False
  481. def collide(
  482. self,
  483. other_characters: Type['Character'] | Type['jogai.group.Group'] | list,
  484. ):
  485. ...
  486. def load(self, filename: str):
  487. """
  488. >>> protagonist = Character('protagonist.png')
  489. >>> protagonist.load('secondary.png')
  490. """
  491. self._original_image, self._original_rect = utils._load_image(
  492. settings.CHARACTERS_DIR, filename
  493. )
  494. self.image = self._original_image
  495. self._original_rect = self._original_image.get_rect()
  496. self.rect = self.image.get_rect()
  497. self.scale_factor = 1
  498. self.image_filename = filename
  499. def copy(self):
  500. """
  501. Create a copy of this character keeping size and position.
  502. Examples:
  503. >>> protagonist = Character('protagonist.png')
  504. >>> other = protagonist.copy()
  505. """
  506. new_character = Character(self.image_filename)
  507. new_character.set_height(self.height)
  508. new_character.set_width(self.width)
  509. new_character.set_position((self.x, self.y))
  510. return new_character
  511. def paint(self):
  512. """
  513. Paint the character in the main surface (window).
  514. Examples:
  515. >>> protagonist = Character('protagonist.png')
  516. >>> protagonist.paint()
  517. """
  518. if self.image:
  519. _window.blit(self.image, self.rect)
  520. def _update_jump(self):
  521. """
  522. Calculate and update the character position during the jump.
  523. """
  524. if self.is_jumping:
  525. if self._jump_counter == 0:
  526. self.velocity.y = abs(self.velocity.y)
  527. if self._jump_counter >= -self.jump_force:
  528. self.move(
  529. 0,
  530. -int((self._jump_counter * abs(self._jump_counter)) * 0.5),
  531. )
  532. self._jump_counter -= 1
  533. else:
  534. self._jump_counter = settings.DEFAULT_JUMP_FORCE
  535. self.is_jumping = False
  536. def _update_rect_position(
  537. self, vg_scene: Type['scene.Scene'], tracking: bool = False
  538. ):
  539. """
  540. Calculate and update the position where the character must be painted.
  541. Only one character can be tracked.
  542. Parameters:
  543. vg_scene: The scene information.
  544. tracking: True if the character must be followed, False otherwise.
  545. """
  546. # TODO: check vg_scene type
  547. if not isinstance(tracking, bool):
  548. raise TypeError(_('The tracking parameter must be a boolean.'))
  549. if not vg_scene:
  550. return None
  551. if tracking:
  552. if self.x < settings.WINDOW_WIDTH // 2:
  553. self.rect.x = self.x
  554. elif (
  555. settings.WINDOW_WIDTH // 2
  556. < self.x
  557. < vg_scene.width - settings.WINDOW_WIDTH
  558. ):
  559. self.rect.x = settings.WINDOW_WIDTH // 2
  560. elif self.x > vg_scene.width - settings.WINDOW_WIDTH:
  561. self.rect.x = settings.WINDOW_WIDTH * 3 / 2 - (
  562. -self.x % vg_scene.width
  563. )
  564. self.rect.y = self.y
  565. else:
  566. self.rect.x = self.x + vg_scene.x
  567. self.rect.y = self.y
  568. def update(self):
  569. for action, key in self.predefined_keys.items():
  570. eval(f'self.{action}(key="{key}")')
  571. self._update_jump()