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

738 lines
20 KiB

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