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

445 lines
16 KiB

  1. # vim: nosmartindent autoindent
  2. import json
  3. import math
  4. import random
  5. import subprocess
  6. import sys
  7. import time
  8. from datetime import datetime, timedelta
  9. import emoji
  10. import ephem
  11. import pygame
  12. import pygame.gfxdraw
  13. import pytchat
  14. from playsound import playsound
  15. from pygame.locals import *
  16. from aques import Aques
  17. from common_const import *
  18. from common_module import CommonModule
  19. from mode import Mode
  20. from talk import Talk
  21. from youtube import *
  22. class Main:
  23. kita_x: float = CWindow.WIDTH / 2
  24. kita_y: float = 1000000.
  25. kita_arg: float = 0.
  26. jojoko_x: float = CWindow.WIDTH / 2
  27. jojoko_y: float = 1000000.
  28. jojoko_arg: float = 0.
  29. @classmethod
  30. def main (
  31. cls,
  32. argv: list,
  33. argc: int) \
  34. -> None:
  35. mode = Mode.NIZIKA
  36. match (argc > 1) and argv[1]:
  37. case '-g':
  38. mode = Mode.GOATOH
  39. case '-w':
  40. mode = Mode.DOUBLE
  41. nizika_mode: bool = mode == Mode.NIZIKA
  42. goatoh_mode: bool = mode == Mode.GOATOH
  43. double_mode: bool = mode == Mode.DOUBLE
  44. print (mode)
  45. # ウィンドゥの初期化
  46. pygame.init ()
  47. screen: pygame.Surface = pygame.display.set_mode (
  48. (CWindow.WIDTH, CWindow.HEIGHT))
  49. # 大月ヨヨコの観測値
  50. observer = ephem.Observer ()
  51. observer.lat, observer.lon = '35', '139'
  52. # き太く陽オブジェクト
  53. sun = ephem.Sun ()
  54. # 大月ヨヨコ・オブジェクト
  55. moon = ephem.Moon ()
  56. # 吹き出し
  57. balloon = pygame.transform.scale (pygame.image.load ('talking.png'),
  58. (CWindow.WIDTH, 384))
  59. if goatoh_mode:
  60. balloon = pygame.transform.flip (balloon, False, True)
  61. # 背景(昼)
  62. bg_day: pygame.Surface = pygame.transform.scale (
  63. pygame.image.load ('bg.jpg'),
  64. (CWindow.WIDTH, CWindow.HEIGHT))
  65. # 背景(夕方)
  66. bg_evening: pygame.Surface = pygame.transform.scale (
  67. pygame.image.load ('bg-evening.jpg'),
  68. (CWindow.WIDTH, CWindow.HEIGHT))
  69. # 背景(夜)
  70. bg_night: pygame.Surface = pygame.transform.scale (
  71. pygame.image.load ('bg-night.jpg'),
  72. (CWindow.WIDTH, CWindow.HEIGHT))
  73. # 背景の草
  74. bg_grass: pygame.Surface = pygame.transform.scale (
  75. pygame.image.load ('bg-grass.png'),
  76. (CWindow.WIDTH, CWindow.HEIGHT))
  77. # き太く陽
  78. kita: pygame.Surface = pygame.transform.scale (
  79. pygame.image.load ('sun.png'), (200, 200))
  80. # 大月ヨヨコ
  81. jojoko: pygame.Surface = pygame.transform.scale (
  82. pygame.image.load ('moon.png'), (200, 200))
  83. # 音声再生器の初期化
  84. pygame.mixer.init (frequency = 44100)
  85. # ニジカの “ぬ゛ぅ゛ぅ゛ぅ゛ん゛”
  86. noon = pygame.mixer.Sound ('noon.wav')
  87. # ゴートうの “ムムムム”
  88. mumumumu = pygame.mixer.Sound ('mumumumu.wav')
  89. # ゴートうの “クサタベテル!!”
  90. kusa = pygame.mixer.Sound ('kusa.wav')
  91. # YouTube Chat オブジェクト
  92. live_chat = pytchat.create (video_id = YOUTUBE_ID)
  93. # デバッグ・メシジのフォント
  94. system_font = pygame.font.SysFont ('notosanscjkjp', 24, bold = True)
  95. # 視聴者コメントのフォント
  96. user_font = pygame.font.SysFont ('notosanscjkjp', 32, italic = True)
  97. # ニジカのフォント
  98. nizika_font = pygame.font.SysFont ('07nikumarufont', 50)
  99. # Youtube Chat から取得したコメントたち
  100. chat_items: list = []
  101. # 会話の履歴
  102. histories: list = []
  103. while True:
  104. # 観測地の日づけ更新
  105. observer.date: datetime = datetime.now ().date ()
  106. # 日の出開始
  107. sunrise_start: datetime = (
  108. (ephem.localtime (observer.previous_rising (sun))
  109. - timedelta (minutes = 30)))
  110. # 日の出終了
  111. sunrise_end: datetime = sunrise_start + timedelta (hours = 1)
  112. # 日の入開始
  113. sunset_start: datetime = (
  114. (ephem.localtime (observer.next_setting (sun))
  115. - timedelta (minutes = 30)))
  116. # 日の入終了
  117. sunset_end: datetime = sunset_start + timedelta (hours = 1)
  118. # 時刻つき観測地
  119. observer_with_time: ephem.Observer = observer
  120. observer_with_time.date = datetime.now () - timedelta (hours = 9)
  121. # 日の角度
  122. sun.compute (observer_with_time)
  123. sun_alt: float = math.degrees (sun.alt)
  124. sun_az: float = math.degrees (sun.az)
  125. # 月の角度
  126. moon.compute (observer_with_time)
  127. moon_alt: float = math.degrees (moon.alt)
  128. moon_az: float = math.degrees (moon.az)
  129. # 月齢
  130. new_moon_dt: datetime = ephem.localtime (
  131. ephem.previous_new_moon (observer_with_time.date))
  132. moon_days_old: float = (
  133. (datetime.now () - new_moon_dt).total_seconds ()
  134. / 60 / 60 / 24)
  135. # 背景描画
  136. cls.draw_bg (screen, bg_day, bg_evening, bg_night, bg_grass,
  137. kita, jojoko,
  138. sunrise_start, sunrise_end, sunset_start, sunset_end,
  139. sun_alt, sun_az, moon_alt, moon_az, moon_days_old)
  140. # 左上に時刻表示
  141. for i in range (4):
  142. screen.blit (
  143. system_font.render (str (datetime.now ()), True, (0, 0, 0)),
  144. (i % 2, i // 2 * 2))
  145. if live_chat.is_alive ():
  146. # Chat オブジェクトが有効
  147. # Chat 取得
  148. chat_items: list = live_chat.get ().items
  149. if chat_items:
  150. # 溜まってゐる Chat からランダムに 1 つ抽出
  151. chat_item = random.choice (chat_items)
  152. # 投稿者情報を辞書化
  153. chat_item.author = chat_item.author.__dict__
  154. # 絵文字を復元
  155. chat_item.message = emoji.emojize (chat_item.message)
  156. message: str = chat_item.message
  157. if nizika_mode:
  158. goatoh_talking = False
  159. if goatoh_mode:
  160. goatoh_talking = True
  161. if double_mode:
  162. goatoh_talking: bool = random.random () < .5
  163. while True:
  164. # ChatGPT API を呼出し,返答を取得
  165. answer: str = Talk.main (message, chat_item.author['name'], histories, goatoh_talking).replace ('\n', ' ')
  166. # 履歴に追加
  167. histories = (histories
  168. + [{'role': 'user', 'content': message},
  169. {'role': 'assistant', 'content': answer}])[(-12):]
  170. # ログ書込み
  171. with open ('log.txt', 'a') as f:
  172. f.write (f'{datetime.now ()}\t'
  173. + f'{json.dumps (chat_item.__dict__)}\t'
  174. + f'{answer}\n')
  175. # 吹出し描画(ニジカは上,ゴートうは下)
  176. if nizika_mode:
  177. screen.blit (balloon, (0, 0))
  178. if goatoh_mode:
  179. screen.blit (balloon, (0, 384))
  180. if double_mode:
  181. screen.blit (pygame.transform.flip (
  182. balloon,
  183. not goatoh_talking,
  184. False),
  185. (0, 0))
  186. # 視聴者コメント描画
  187. screen.blit (
  188. user_font.render (
  189. '> ' + (message
  190. if (CommonModule.len_by_full (
  191. message)
  192. <= 21)
  193. else (CommonModule.mid_by_full (
  194. message, 0, 19.5)
  195. + '...')),
  196. True,
  197. (0, 0, 0)),
  198. (120, 70 + 384) if goatoh_mode else (120 + (64 if (double_mode and not goatoh_talking) else 0), 70))
  199. # ニジカの返答描画
  200. screen.blit (
  201. nizika_font.render (
  202. (answer
  203. if CommonModule.len_by_full (answer) <= 16
  204. else CommonModule.mid_by_full (answer, 0, 16)),
  205. True,
  206. (192, 0, 0)),
  207. (100, 150 + 384) if goatoh_mode else (100 + (64 if (double_mode and not goatoh_talking) else 0), 150))
  208. if CommonModule.len_by_full (answer) > 16:
  209. screen.blit (
  210. nizika_font.render (
  211. (CommonModule.mid_by_full (answer, 16, 16)
  212. if CommonModule.len_by_full (answer) <= 32
  213. else (CommonModule.mid_by_full (
  214. answer, 16, 14.5)
  215. + '...')),
  216. True,
  217. (192, 0, 0)),
  218. (100, 200 + 384) if goatoh_mode else (100 + (64 if (double_mode and not goatoh_talking) else 0), 200))
  219. pygame.display.update ()
  220. # 鳴く.
  221. if goatoh_talking:
  222. if random.random () < .1:
  223. kusa.play ()
  224. else:
  225. mumumumu.play ()
  226. else:
  227. noon.play ()
  228. time.sleep (1.5)
  229. # 返答の読上げを WAV ディタとして生成,取得
  230. try:
  231. wav: bytearray | None = Aques.main (answer, goatoh_talking)
  232. except:
  233. wav: None = None
  234. # 読上げを再生
  235. if wav is not None:
  236. with open ('./nizika_talking.wav', 'wb') as f:
  237. f.write (wav)
  238. playsound ('./nizika_talking.wav')
  239. time.sleep (1)
  240. if not double_mode or random.random () < .5:
  241. break
  242. cls.draw_bg (screen, bg_day, bg_evening, bg_night,
  243. bg_grass, kita, jojoko,
  244. sunrise_start, sunrise_end,
  245. sunset_start, sunset_end,
  246. sun_alt, sun_az, moon_alt, moon_az,
  247. moon_days_old)
  248. chat_item.author = {'name': 'ゴートうひとり' if goatoh_talking else '伊地知ニジカ',
  249. 'id': '',
  250. 'imageUrl': './favicon-goatoh.ico' if goatoh_talking else './favicon.ico'}
  251. chat_item.message = histories.pop (-1)['content']
  252. message = chat_item.message
  253. goatoh_talking = not goatoh_talking
  254. else:
  255. # Chat オブジェクトが無効
  256. # 再生成
  257. live_chat = pytchat.create (video_id = YOUTUBE_ID)
  258. pygame.display.update ()
  259. for event in pygame.event.get ():
  260. if event.type == QUIT:
  261. pygame.quit ()
  262. sys.exit ()
  263. @classmethod
  264. def draw_bg (
  265. cls,
  266. screen: pygame.Surface,
  267. bg_day: pygame.Surface,
  268. bg_evening: pygame.Surface,
  269. bg_night: pygame.Surface,
  270. bg_grass: pygame.Surface,
  271. kita_original: pygame.Surface,
  272. jojoko_original: pygame.Surface,
  273. sunrise_start: datetime,
  274. sunrise_end: datetime,
  275. sunset_start: datetime,
  276. sunset_end: datetime,
  277. sun_alt: float,
  278. sun_az: float,
  279. moon_alt: float,
  280. moon_az: float,
  281. moon_days_old: float) \
  282. -> None:
  283. sunrise_centre: datetime = (
  284. sunrise_start + (sunrise_end - sunrise_start) / 2)
  285. sunset_centre: datetime = (
  286. sunset_start + (sunset_end - sunset_start) / 2)
  287. jojoko: pygame.Surface = cls.get_jojoko (jojoko_original,
  288. moon_days_old, moon_alt, moon_az)
  289. x = CWindow.WIDTH * (sun_az - 80) / 120
  290. y = ((CWindow.HEIGHT / 2)
  291. - (CWindow.HEIGHT / 2 + 100) * math.sin (math.radians (sun_alt)) / math.sin ( math.radians (60)))
  292. arg = math.degrees (math.atan2 (y - cls.kita_y, x - cls.kita_x))
  293. cls.kita_x = x
  294. cls.kita_y = y
  295. if abs (arg - cls.kita_arg) > 3:
  296. cls.kita_arg = arg
  297. kita: pygame.Surface = pygame.transform.rotate (kita_original, -(90 + cls.kita_arg))
  298. dt: datetime = datetime.now ()
  299. if sunrise_centre <= dt < sunset_centre:
  300. screen.blit (bg_day, (0, 0))
  301. else:
  302. screen.blit (bg_night, (0, 0))
  303. if sunrise_start <= dt < sunrise_end:
  304. bg_evening.set_alpha (255 - ((abs (dt - sunrise_centre) * 510)
  305. / (sunrise_end - sunrise_centre)))
  306. elif sunset_start <= dt < sunset_end:
  307. bg_evening.set_alpha (255 - ((abs (dt - sunset_centre) * 510)
  308. / (sunset_end - sunset_centre)))
  309. else:
  310. bg_evening.set_alpha (0)
  311. if sunrise_start <= dt < sunset_end:
  312. jojoko.set_alpha (255 - 255 / 15 * abs (moon_days_old - 15))
  313. else:
  314. jojoko.set_alpha (255)
  315. screen.blit (bg_evening, (0, 0))
  316. if (moon_az < 220) and (-10 <= moon_alt):
  317. screen.blit (jojoko, jojoko.get_rect (center = (cls.jojoko_x, cls.jojoko_y)))
  318. screen.blit (bg_grass, (0, 0))
  319. if (sun_az < 220) and (-10 <= sun_alt):
  320. screen.blit (kita, kita.get_rect (center = (cls.kita_x, cls.kita_y)))
  321. screen.blit (bg_grass, (0, 0))
  322. @classmethod
  323. def get_jojoko (
  324. cls,
  325. jojoko_original: pygame.Surface,
  326. moon_days_old: float,
  327. moon_alt: float,
  328. moon_az: float) \
  329. -> pygame.Surface:
  330. jojoko: pygame.Surface = jojoko_original.copy ()
  331. jojoko.set_colorkey ((0, 255, 0))
  332. for i in range (200):
  333. if 1 <= moon_days_old < 15:
  334. pygame.gfxdraw.bezier (jojoko, ((0, 100 + i), (100, 180 * moon_days_old / 7 - 80 + i), (200, 100 + i)), 3, (0, 255, 0))
  335. elif moon_days_old < 16:
  336. pass
  337. elif moon_days_old < 30:
  338. pygame.gfxdraw.bezier (jojoko, ((0, 100 - i), (100, 180 * (moon_days_old - 15) / 7 - 80 - i), (200, 100 - i)), 3, (0, 255, 0))
  339. else:
  340. jojoko.fill ((0, 255, 0))
  341. x = CWindow.WIDTH * (moon_az - 80) / 120
  342. y = ((CWindow.HEIGHT / 2)
  343. - (CWindow.HEIGHT / 2 + 100) * math.sin (math.radians (moon_alt)) / math.sin (math.radians (60)))
  344. arg = math.degrees (math.atan2 (y - cls.jojoko_y, x - cls.jojoko_x))
  345. cls.jojoko_x = x
  346. cls.jojoko_y = y
  347. if abs (arg - cls.jojoko_arg) > 3:
  348. cls.jojoko_arg = arg
  349. return pygame.transform.rotate (jojoko, -(90 + cls.jojoko_arg))
  350. if __name__ == '__main__':
  351. Main.main (sys.argv, len (sys.argv))