伊地知ニジカ放送局だぬ゛ん゛. https://www.youtube.com/@deerjika
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

822 lines
22 KiB

  1. from __future__ import annotations
  2. import math
  3. import os
  4. import random
  5. import sys
  6. import wave
  7. from datetime import datetime, timedelta
  8. from enum import Enum, auto
  9. from typing import Callable, TypedDict
  10. import cv2
  11. import emoji
  12. import ephem
  13. import pygame
  14. import pygame.gfxdraw
  15. import pytchat
  16. import requests
  17. from cv2 import VideoCapture
  18. from ephem import Moon, Observer, Sun
  19. from pygame import Rect, Surface
  20. from pygame.font import Font
  21. from pygame.mixer import Sound
  22. from pygame.time import Clock
  23. from pytchat.core.pytchat import PytchatCore
  24. from pytchat.processors.default.processor import Chat
  25. from aques import Aques
  26. from common_module import CommonModule
  27. from nizika_ai.config import DB
  28. from nizika_ai.consts import (AnswerType, Character, GPTModel, Platform,
  29. QueryType)
  30. from nizika_ai.models import Answer, AnsweredFlag, Query, User
  31. pygame.init ()
  32. FPS = 30
  33. SYSTEM_FONT = pygame.font.SysFont ('notosanscjkjp', 24, bold = True)
  34. USER_FONT = pygame.font.SysFont ('notosanscjkjp', 32, italic = True)
  35. DEERJIKA_FONT = pygame.font.SysFont ('07nikumarufont', 50)
  36. def main (
  37. ) -> None:
  38. game = Game ()
  39. Bg (game)
  40. balloon = Balloon (game)
  41. deerjika = Deerjika (game, DeerjikaPattern.RELAXED,
  42. x = CWindow.WIDTH * 3 / 4,
  43. y = CWindow.HEIGHT - 120,
  44. balloon = balloon)
  45. CurrentTime (game, SYSTEM_FONT)
  46. try:
  47. broadcast = Broadcast (os.environ['BROADCAST_CODE'])
  48. except Exception:
  49. pass
  50. while True:
  51. for event in pygame.event.get ():
  52. if event.type == pygame.QUIT:
  53. pygame.quit ()
  54. sys.exit ()
  55. if not balloon.enabled:
  56. try:
  57. DB.begin_transaction ()
  58. answer_flags = (AnsweredFlag.where ('platform', Platform.YOUTUBE.value)
  59. .where ('answered', False)
  60. .get ())
  61. if answer_flags:
  62. answer_flag = random.choice (answer_flags)
  63. answer = Answer.find (answer_flag.answer_id)
  64. if answer.answer_type == AnswerType.YOUTUBE_REPLY.value:
  65. query = Query.find (answer.query_id)
  66. deerjika.talk (query.content, answer.content)
  67. answer_flag.answered = True
  68. answer_flag.save ()
  69. DB.commit ()
  70. add_query (broadcast)
  71. except Exception:
  72. pass
  73. game.redraw ()
  74. class Bg:
  75. """
  76. 背景オブゼクト管理用クラス
  77. Attributes:
  78. base (BgBase): 最背面
  79. grass (BgGrass): 草原部分
  80. jojoko (Jojoko): 大月ヨヨコ
  81. kita (KitaSun): き太く陽
  82. """
  83. base: BgBase
  84. grass: BgGrass
  85. jojoko: Jojoko
  86. kita: KitaSun
  87. def __init__ (
  88. self,
  89. game: Game,
  90. ):
  91. self.base = BgBase (game)
  92. self.jojoko = Jojoko (game)
  93. self.kita = KitaSun (game)
  94. self.grass = BgGrass (game)
  95. class DeerjikaPattern (Enum):
  96. """
  97. ニジカの状態
  98. Members:
  99. NORMAL: 通常
  100. RELAXED: 足パタパタ
  101. SLEEPING: 寝ニジカ
  102. DANCING: ダンシング・ニジカ
  103. """
  104. NORMAL = auto ()
  105. RELAXED = auto ()
  106. SLEEPING = auto ()
  107. DANCING = auto ()
  108. class Direction (Enum):
  109. """
  110. クリーチャの向き
  111. Members:
  112. LEFT: 左向き
  113. RIGHT: 右向き
  114. """
  115. LEFT = auto ()
  116. RIGHT = auto ()
  117. class Game:
  118. """
  119. ゲーム・クラス
  120. Attributes:
  121. clock (Clock): Clock オブゼクト
  122. frame (int): フレーム・カウンタ
  123. last_answered_at (datetime): 最後に回答した時刻
  124. now (datetime): 基準日時
  125. objects (list[GameObject]): 再描画するクラスのリスト
  126. screen (Surface): 基底スクリーン
  127. sky (Sky): 天体情報
  128. """
  129. bgm: Sound
  130. clock: Clock
  131. fps: float
  132. frame: int
  133. last_answered_at: datetime
  134. now: datetime
  135. objects: list[GameObject]
  136. screen: Surface
  137. sky: Sky
  138. def __init__ (
  139. self,
  140. ):
  141. self.now = datetime.now ()
  142. self.screen = pygame.display.set_mode ((CWindow.WIDTH, CWindow.HEIGHT))
  143. self.clock = Clock ()
  144. self.fps = FPS
  145. self.frame = 0
  146. self.objects = []
  147. self.bgm = Sound ('assets/bgm.mp3')
  148. self.bgm.set_volume (.15)
  149. self.bgm.play (loops = -1)
  150. self._create_sky ()
  151. def redraw (
  152. self,
  153. ) -> None:
  154. self.now = datetime.now ()
  155. self.sky.observer.date = self.now - timedelta (hours = 9)
  156. for obj in sorted (self.objects, key = lambda obj: obj.layer):
  157. if obj.enabled:
  158. obj.redraw ()
  159. pygame.display.update ()
  160. delta_time = self.clock.tick (FPS) / 1000
  161. self.fps = 1 / delta_time
  162. if delta_time > 1 / FPS:
  163. for _ in range (int (FPS * delta_time) - 1):
  164. for obj in self.objects:
  165. if obj.enabled:
  166. obj.update ()
  167. def _create_sky (
  168. self,
  169. ) -> None:
  170. self.sky = Sky ()
  171. self.sky.observer = Observer ()
  172. self.sky.observer.lat = '35'
  173. self.sky.observer.lon = '139'
  174. class GameObject:
  175. """
  176. 各ゲーム・オブゼクトの基底クラス
  177. Attributes:
  178. arg (float): 回転角度 (rad)
  179. ax (float): X 軸に対する加速度 (px/frame^2)
  180. ay (float): y 軸に対する加速度 (px/frame^2)
  181. enabled (bool): オブゼクトの表示可否
  182. frame (int): フレーム・カウンタ
  183. game (Game): ゲーム基盤
  184. height (int): 高さ (px)
  185. vx (float): x 軸に対する速度 (px/frame)
  186. vy (float): y 軸に対する速度 (px/frame)
  187. width (int): 幅 (px)
  188. x (float): X 座標 (px)
  189. y (float): Y 座標 (px)
  190. """
  191. arg: float = 0
  192. ax: float = 0
  193. ay: float = 0
  194. enabled: bool = True
  195. frame: int
  196. game: Game
  197. height: int
  198. layer: float
  199. vx: float = 0
  200. vy: float = 0
  201. width: int
  202. x: float
  203. y: float
  204. def __init__ (
  205. self,
  206. game: Game,
  207. layer: float | None = None,
  208. enabled: bool = True,
  209. x: float = 0,
  210. y: float = 0,
  211. ):
  212. self.game = game
  213. self.enabled = enabled
  214. self.frame = 0
  215. if layer is None:
  216. if self.game.objects:
  217. layer = max (obj.layer for obj in self.game.objects) + 10
  218. else:
  219. layer = 0
  220. self.layer = layer
  221. self.x = x
  222. self.y = y
  223. self.game.objects.append (self)
  224. def redraw (
  225. self,
  226. ) -> None:
  227. self.update ()
  228. def update (
  229. self,
  230. ) -> None:
  231. self.x += self.vx
  232. self.y += self.vy
  233. self.vx += self.ax
  234. self.vy += self.ay
  235. self.frame += 1
  236. class BgBase (GameObject):
  237. """
  238. 背景
  239. Attributes:
  240. surface (Surface): 背景 Surface
  241. """
  242. bg: Surface
  243. bg_evening: Surface
  244. bg_grass: Surface
  245. bg_night: Surface
  246. surface: Surface
  247. def __init__ (
  248. self,
  249. game: Game,
  250. ):
  251. super ().__init__ (game)
  252. self.bg = pygame.image.load ('assets/bg.jpg')
  253. self.bg_evening = pygame.image.load ('assets/bg-evening.jpg')
  254. self.bg_grass = pygame.image.load ('assets/bg-grass.png')
  255. self.bg_night = pygame.image.load ('assets/bg-night.jpg')
  256. self.surface = pygame.transform.scale (self.bg, (CWindow.WIDTH, CWindow.HEIGHT))
  257. def redraw (
  258. self,
  259. ) -> None:
  260. self.game.screen.blit (self.surface, (self.x, self.y))
  261. super ().redraw ()
  262. class BgGrass (GameObject):
  263. """
  264. 背景の草原部分
  265. Attributes:
  266. surface (Surface): 草原 Surface
  267. """
  268. surface: Surface
  269. def __init__ (
  270. self,
  271. game: Game,
  272. ):
  273. super ().__init__ (game)
  274. self.game = game
  275. self.surface = pygame.image.load ('assets/bg-grass.png')
  276. self.surface = pygame.transform.scale (self.surface, (CWindow.WIDTH, CWindow.HEIGHT))
  277. def redraw (
  278. self,
  279. ) -> None:
  280. self.game.screen.blit (self.surface, (self.x, self.y))
  281. super ().redraw ()
  282. class Creature (GameObject):
  283. sound: Sound
  284. def bell (
  285. self,
  286. ) -> None:
  287. self.sound.play ()
  288. class Deerjika (Creature):
  289. """
  290. 伊地知ニジカ
  291. Attributes:
  292. height (int): 高さ (px)
  293. scale (float): 拡大率
  294. surfaces (list[Surface]): ニジカの各フレームを Surface にしたリスト
  295. width (int): 幅 (px)
  296. """
  297. FPS = 30
  298. height: int
  299. scale: float = .8
  300. surfaces: list[Surface]
  301. width: int
  302. talking: bool = False
  303. wav: bytearray | None = None
  304. balloon: Balloon
  305. def __init__ (
  306. self,
  307. game: Game,
  308. pattern: DeerjikaPattern = DeerjikaPattern.NORMAL,
  309. direction: Direction = Direction.LEFT,
  310. layer: float | None = None,
  311. x: float = 0,
  312. y: float = 0,
  313. balloon: Balloon | None = None,
  314. ):
  315. if balloon is None:
  316. raise Exception
  317. super ().__init__ (game, layer, x = x, y = y)
  318. self.pattern = pattern
  319. self.direction = direction
  320. self.balloon = balloon
  321. match pattern:
  322. case DeerjikaPattern.NORMAL:
  323. ...
  324. case DeerjikaPattern.RELAXED:
  325. match direction:
  326. case Direction.LEFT:
  327. self.width = 1280
  328. self.height = 720
  329. surface = pygame.image.load ('assets/deerjika_relax_left.png')
  330. self.surfaces = []
  331. for x in range (0, surface.get_width (), self.width):
  332. self.surfaces.append (
  333. surface.subsurface (x, 0, self.width, self.height))
  334. case Direction.RIGHT:
  335. ...
  336. self.sound = Sound ('assets/noon.wav')
  337. def redraw (
  338. self,
  339. ) -> None:
  340. surface = pygame.transform.scale (self.surfaces[self.frame * self.FPS // FPS
  341. % len (self.surfaces)],
  342. (self.width * self.scale, self.height * self.scale))
  343. self.game.screen.blit (surface, surface.get_rect (center = (self.x, self.y)))
  344. super ().redraw ()
  345. def update (
  346. self,
  347. ) -> None:
  348. if (not self.balloon.enabled) and self.talking:
  349. self.talking = False
  350. if (self.balloon.enabled and self.balloon.frame >= FPS * 1.5
  351. and not self.talking):
  352. self.read_out ()
  353. super ().update ()
  354. def talk (
  355. self,
  356. query: str,
  357. answer: str,
  358. ) -> None:
  359. self.bell ()
  360. self._create_wav (answer)
  361. length = 300
  362. if self.wav is not None:
  363. with wave.open ('./nizika_talking.wav', 'rb') as f:
  364. length = int (FPS * (f.getnframes () / f.getframerate () + 4))
  365. self.balloon.talk (query, answer, length = length)
  366. def read_out (
  367. self,
  368. ) -> None:
  369. Sound ('./nizika_talking.wav').play ()
  370. self.talking = True
  371. def _create_wav (
  372. self,
  373. message: str,
  374. ) -> None:
  375. try:
  376. self.wav = Aques.main (message, False)
  377. except:
  378. self.wav = None
  379. if self.wav is None:
  380. return
  381. with open ('./nizika_talking.wav', 'wb') as f:
  382. f.write (self.wav)
  383. class CurrentTime (GameObject):
  384. """
  385. 現在日時表示
  386. Attributes:
  387. font (Font): フォント
  388. """
  389. font: Font
  390. def __init__ (
  391. self,
  392. game: Game,
  393. font: Font,
  394. ):
  395. super ().__init__ (game)
  396. self.font = font
  397. def redraw (
  398. self,
  399. ) -> None:
  400. for i in range (4):
  401. self.game.screen.blit (
  402. self.font.render (f"{ self.game.now } { self.game.fps } fps", True, (0, 0, 0)),
  403. (i % 2, i // 2 * 2))
  404. super ().redraw ()
  405. class Balloon (GameObject):
  406. """
  407. 吹出し
  408. Attributes:
  409. answer (str): 回答テキスト
  410. image_url (str, None): 画像 URL
  411. length (int): 表示する時間 (frame)
  412. query (str): 質問テキスト
  413. surface (Surface): 吹出し Surface
  414. x_flip (bool): 左右反転フラグ
  415. y_flip (bool): 上下反転フラグ
  416. """
  417. answer: str = ''
  418. image_url: str | None = None
  419. length: int = 300
  420. query: str = ''
  421. surface: Surface
  422. x_flip: bool = False
  423. y_flip: bool = False
  424. def __init__ (
  425. self,
  426. game: Game,
  427. x_flip: bool = False,
  428. y_flip: bool = False,
  429. ):
  430. super ().__init__ (game, enabled = False)
  431. self.x_flip = x_flip
  432. self.y_flip = y_flip
  433. self.surface = pygame.transform.scale (pygame.image.load ('assets/balloon.png'),
  434. (CWindow.WIDTH, CWindow.HEIGHT / 2))
  435. self.surface = pygame.transform.flip (self.surface, self.x_flip, self.y_flip)
  436. def redraw (
  437. self,
  438. ) -> None:
  439. if self.frame >= self.length:
  440. self.enabled = False
  441. self.game.last_answered_at = self.game.now
  442. return
  443. query = self.query
  444. if CommonModule.len_by_full (query) > 21:
  445. query = CommonModule.mid_by_full (query, 0, 19.5) + '...'
  446. answer = Surface ((800, ((CommonModule.len_by_full (self.answer) - 1) // 16 + 1) * 50),
  447. pygame.SRCALPHA)
  448. for i in range (int (CommonModule.len_by_full (self.answer) - 1) // 16 + 1):
  449. answer.blit (DEERJIKA_FONT.render (
  450. CommonModule.mid_by_full (self.answer, 16 * i, 16), True, (192, 0, 0)),
  451. (0, 50 * i))
  452. surface = self.surface.copy ()
  453. surface.blit (USER_FONT.render ('>' + query, True, (0, 0, 0)), (120, 70))
  454. y: int
  455. if self.frame < 30:
  456. y = 0
  457. elif self.frame >= self.length - 90:
  458. y = answer.get_height () - 100
  459. else:
  460. y = int ((answer.get_height () - 100) * (self.frame - 30) / (self.length - 120))
  461. surface.blit (answer, (100, 150), Rect (0, y, 800, 100))
  462. self.game.screen.blit (surface, (0, 0))
  463. super ().redraw ()
  464. def talk (
  465. self,
  466. query: str,
  467. answer: str,
  468. image_url: str | None = None,
  469. length: int = 300,
  470. ) -> None:
  471. self.query = query
  472. self.answer = answer
  473. self.image_url = image_url
  474. self.length = length
  475. self.frame = 0
  476. self.enabled = True
  477. class KitaSun (GameObject):
  478. """
  479. き太く陽
  480. Attributes:
  481. sun (Sun): ephem の太陽オブゼクト
  482. surface (Surface): き太く陽 Surface
  483. """
  484. alt: float
  485. az: float
  486. sun: Sun
  487. surface: Surface
  488. def __init__ (
  489. self,
  490. game: Game,
  491. ):
  492. super ().__init__ (game)
  493. self.surface = pygame.transform.scale (pygame.image.load ('assets/sun.png'), (200, 200))
  494. self.sun = Sun ()
  495. def redraw (
  496. self,
  497. ) -> None:
  498. surface = pygame.transform.rotate (self.surface, -(90 + math.degrees (self.arg)))
  499. self.game.screen.blit (surface, surface.get_rect (center = (self.x, self.y)))
  500. super ().redraw ()
  501. def update (
  502. self,
  503. ) -> None:
  504. self.sun.compute (self.game.sky.observer)
  505. self.alt = self.sun.alt
  506. self.az = self.sun.az
  507. if abs (self.new_arg - self.arg) > math.radians (15):
  508. self.arg = self.new_arg
  509. self.x = self.new_x
  510. self.y = self.new_y
  511. super ().update ()
  512. @property
  513. def new_x (
  514. self,
  515. ) -> float:
  516. return CWindow.WIDTH * (math.degrees (self.az) - 80) / 120
  517. @property
  518. def new_y (
  519. self,
  520. ) -> float:
  521. return ((CWindow.HEIGHT / 2)
  522. - ((CWindow.HEIGHT / 2 + 100) * math.sin (self.alt)
  523. / math.sin (math.radians (60))))
  524. @property
  525. def new_arg (
  526. self,
  527. ) -> float:
  528. return math.atan2 (self.new_y - self.y, self.new_x - self.x)
  529. class Jojoko (GameObject):
  530. """
  531. 大月ヨヨコ
  532. Attributes:
  533. base (Surface): 満月ヨヨコ Surface
  534. moon (Moon): ephem の月オブゼクト
  535. surface (Surface): 缺けたヨヨコ
  536. """
  537. alt: float
  538. az: float
  539. base: Surface
  540. moon: Moon
  541. surface: Surface
  542. def __init__ (
  543. self,
  544. game: Game,
  545. ):
  546. super ().__init__ (game)
  547. self.base = pygame.transform.scale (pygame.image.load ('assets/moon.png'), (200, 200))
  548. self.moon = Moon ()
  549. self.surface = self._get_surface ()
  550. def redraw (
  551. self,
  552. ) -> None:
  553. if self.frame % (FPS * 3600) == 0:
  554. self.surface = self._get_surface ()
  555. surface = pygame.transform.rotate (self.surface, -(90 + math.degrees (self.arg)))
  556. surface.set_colorkey ((0, 255, 0))
  557. self.game.screen.blit (surface, surface.get_rect (center = (self.x, self.y)))
  558. super ().redraw ()
  559. def update (
  560. self,
  561. ) -> None:
  562. self.moon.compute (self.game.sky.observer)
  563. self.alt = self.moon.alt
  564. self.az = self.moon.az
  565. if abs (self.new_arg - self.arg) > math.radians (15):
  566. self.arg = self.new_arg
  567. self.x = self.new_x
  568. self.y = self.new_y
  569. super ().update ()
  570. @property
  571. def phase (
  572. self,
  573. ) -> float:
  574. dt: datetime = ephem.localtime (ephem.previous_new_moon (self.game.sky.observer.date))
  575. return (self.game.now - dt).total_seconds () / 60 / 60 / 24
  576. def _get_surface (
  577. self,
  578. ) -> Surface:
  579. """
  580. ヨヨコを月齢に応じて缺かす.
  581. Returns:
  582. Surface: 缺けたヨヨコ
  583. """
  584. jojoko = self.base.copy ()
  585. for i in range (200):
  586. if 1 <= self.phase < 15:
  587. pygame.gfxdraw.bezier (jojoko, ((0, 100 + i), (100, 180 * self.phase / 7 - 80 + i), (200, 100 + i)), 3, (0, 255, 0))
  588. elif self.phase < 16:
  589. pass
  590. elif self.phase < 30:
  591. pygame.gfxdraw.bezier (jojoko, ((0, 100 - i), (100, 180 * (self.phase - 15) / 7 - 80 - i), (200, 100 - i)), 3, (0, 255, 0))
  592. else:
  593. jojoko.fill ((0, 255, 0))
  594. return jojoko
  595. @property
  596. def new_x (
  597. self,
  598. ) -> float:
  599. return CWindow.WIDTH * (math.degrees (self.az) - 80) / 120
  600. @property
  601. def new_y (
  602. self,
  603. ) -> float:
  604. return ((CWindow.HEIGHT / 2)
  605. - ((CWindow.HEIGHT / 2 + 100) * math.sin (self.alt)
  606. / math.sin (math.radians (60))))
  607. @property
  608. def new_arg (
  609. self,
  610. ) -> float:
  611. return math.atan2 (self.new_y - self.y, self.new_x - self.x)
  612. class Sky:
  613. """
  614. 天体に関する情報を保持するクラス
  615. Attributes:
  616. observer (Observer): 観測値
  617. """
  618. observer: Observer
  619. class CWindow:
  620. """
  621. ウィンドゥに関する定数クラス
  622. Attributes:
  623. WIDTH (int): ウィンドゥ幅
  624. HEIGHT (int): ウィンドゥ高さ
  625. """
  626. WIDTH = 1024
  627. HEIGHT = 768
  628. def get_surfaces_from_video (
  629. video_path: str,
  630. ) -> list[Surface]:
  631. cap = VideoCapture (video_path)
  632. if not cap.isOpened ():
  633. return []
  634. fps = cap.get (cv2.CAP_PROP_FPS)
  635. surfaces: list[Surface] = []
  636. while cap.isOpened ():
  637. (ret, frame) = cap.read ()
  638. if not ret:
  639. break
  640. frame = cv2.cvtColor (frame, cv2.COLOR_BGR2RGB)
  641. frame_surface = pygame.surfarray.make_surface (frame)
  642. frame_surface = pygame.transform.rotate (frame_surface, -90)
  643. surfaces.append (pygame.transform.flip (frame_surface, True, False))
  644. cap.release ()
  645. return surfaces
  646. class Broadcast:
  647. chat: PytchatCore
  648. def __init__ (
  649. self,
  650. broadcast_code,
  651. ):
  652. self.chat = pytchat.create (broadcast_code)
  653. def fetch_chat (
  654. self,
  655. ) -> Chat | None:
  656. if not self.chat.is_alive ():
  657. return None
  658. chats = self.chat.get ().items
  659. if not chats:
  660. return None
  661. print (f"{ datetime.now () }: { chats }")
  662. return random.choice (chats)
  663. def fetch_bytes_from_url (
  664. url: str,
  665. ) -> bytes | None:
  666. res = requests.get (url, timeout = 60)
  667. if res.status_code != 200:
  668. return None
  669. return res.content
  670. def add_query (
  671. broadcast: Broadcast,
  672. ) -> None:
  673. chat = broadcast.fetch_chat ()
  674. if chat is None:
  675. return
  676. DB.begin_transaction ()
  677. chat.message = emoji.emojize (chat.message)
  678. message: str = chat.message
  679. user = (User.where ('platform', Platform.YOUTUBE.value)
  680. .where ('code', chat.author.channelId)
  681. .first ())
  682. if user is None:
  683. user = User ()
  684. user.platform = Platform.YOUTUBE.value
  685. user.code = chat.author.channelId
  686. user.name = chat.author.name
  687. user.icon = fetch_bytes_from_url (chat.author.imageUrl)
  688. user.save ()
  689. query = Query ()
  690. query.user_id = user.id
  691. query.target_character = Character.DEERJIKA.value
  692. query.content = chat.message
  693. query.query_type = QueryType.YOUTUBE_COMMENT.value
  694. query.model = GPTModel.GPT3_TURBO.value
  695. query.sent_at = datetime.now ()
  696. query.answered = False
  697. query.save ()
  698. DB.commit ()
  699. if __name__ == '__main__':
  700. main ()