伊地知ニジカ放送局だぬ゛ん゛. 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.

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