Files
UltraChat/jrpg/island_novel.py
2026-03-17 00:16:49 +05:00

547 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
🏝️ ОСТРОВ ДВУХ СЕРДЕЦ — визуальная новелла
Запуск: pip install pygame && python island_novel.py
"""
import pygame
import sys
import math
import random
pygame.init()
W, H = 1024, 600
screen = pygame.display.set_mode((W, H), pygame.SCALED | pygame.RESIZABLE)
pygame.display.set_caption("🏝️ Остров двух сердец")
clock = pygame.time.Clock()
# ── Шрифты ──────────────────────────────────────────────────────────────────
def load_font(size, bold=False):
for name in ["Segoe UI", "Arial Unicode MS", "DejaVu Sans", "FreeSans", "Liberation Sans"]:
try:
return pygame.font.SysFont(name, size, bold=bold)
except:
pass
return pygame.font.Font(None, size)
FONT_BIG = load_font(30, bold=True)
FONT_MED = load_font(22)
FONT_SMALL = load_font(18)
FONT_NAME = load_font(24, bold=True)
# ── Цвета ────────────────────────────────────────────────────────────────────
SKY_DAWN = [(255,180,100), (255,120,80), (180,80,120)]
SKY_DAY = [(100,180,255), (50,130,230), (30,80,180)]
SKY_DUSK = [(255,120,60), (200,80,120), (100,40,100)]
SKY_NIGHT = [(10,10,40), (20,20,80), (5,5,20)]
SAND_COL = (240, 220, 160)
WATER_COL = (60, 160, 220)
PALM_TRUNK = (120, 80, 40)
PALM_LEAF = (40, 160, 60)
# ── Рисование фонов ──────────────────────────────────────────────────────────
def draw_gradient(surface, colors, rect=None):
if rect is None:
rect = surface.get_rect()
x, y, w, h = rect
c1, c2, c3 = colors
for i in range(h):
t = i / h
if t < 0.5:
r = c1[0] + (c2[0]-c1[0]) * t*2
g = c1[1] + (c2[1]-c1[1]) * t*2
b = c1[2] + (c2[2]-c1[2]) * t*2
else:
r = c2[0] + (c3[0]-c2[0]) * (t-0.5)*2
g = c2[1] + (c3[1]-c2[1]) * (t-0.5)*2
b = c2[2] + (c3[2]-c2[2]) * (t-0.5)*2
pygame.draw.line(surface, (int(r),int(g),int(b)), (x,y+i),(x+w,y+i))
def draw_stars(surface, n=80):
rng = random.Random(42)
for _ in range(n):
x = rng.randint(0, W)
y = rng.randint(0, H//2)
r = rng.randint(1,2)
pygame.draw.circle(surface, (255,255,220), (x,y), r)
def draw_sun(surface, time_of_day):
if time_of_day == "morning":
pos, col = (150, 120), (255,230,100)
elif time_of_day == "day":
pos, col = (512, 80), (255,255,180)
elif time_of_day == "evening":
pos, col = (870, 130), (255,160,60)
else:
pos, col = None, None
if pos:
pygame.draw.circle(surface, col, pos, 45)
pygame.draw.circle(surface, (255,255,255,80), pos, 55, 3)
def draw_moon(surface):
pygame.draw.circle(surface, (230,230,200), (800,100), 35)
pygame.draw.circle(surface, (10,10,40), (815,90), 30)
def draw_palm(surface, x, y, seed=0):
rng = random.Random(seed)
# ствол
points = [(x,y),(x-8,y-40),(x-5,y-90),(x,y-150),(x+5,y-180)]
pygame.draw.lines(surface, PALM_TRUNK, False, points, 10)
# листья
for angle in range(0, 360, 60):
rad = math.radians(angle + rng.randint(-20,20))
lx = x + int(80*math.cos(rad))
ly = y - 185 + int(50*math.sin(rad))
pygame.draw.line(surface, PALM_LEAF, (x, y-185), (lx,ly), 6)
# скругление
pygame.draw.circle(surface, PALM_LEAF, (lx,ly), 8)
def draw_waves(surface, t):
for i in range(5):
oy = 30*i
for x in range(0, W, 4):
y = int(H*0.62 + 8 + oy + 6*math.sin((x/60 + t*0.5 + i)*1.2))
pygame.draw.circle(surface, (100,200,240,120), (x,y), 2)
def build_background(time_of_day):
surf = pygame.Surface((W,H))
# небо
sky = {"morning":SKY_DAWN,"day":SKY_DAY,"evening":SKY_DUSK,"night":SKY_NIGHT}[time_of_day]
draw_gradient(surf, sky)
if time_of_day == "night":
draw_stars(surf)
draw_moon(surf)
else:
draw_sun(surf, time_of_day)
# вода
pygame.draw.rect(surf, WATER_COL, (0, int(H*0.6), W, int(H*0.4)))
# песок
pygame.draw.ellipse(surf, SAND_COL, (50, int(H*0.55), W-100, int(H*0.35)))
# пальмы
draw_palm(surf, 120, int(H*0.75), seed=1)
draw_palm(surf, W-180, int(H*0.73), seed=2)
draw_palm(surf, 400, int(H*0.78), seed=3)
return surf
# Кэшируем фоны
BG = {tod: build_background(tod) for tod in ["morning","day","evening","night"]}
# ── Персонажи ────────────────────────────────────────────────────────────────
def draw_anime_girl(surface, x, y, palette, name, emotion="neutral", flip=False):
"""Рисует простую анимешную девочку программно."""
s = pygame.Surface((160,300), pygame.SRCALPHA)
hair_col, dress_col, skin_col, eye_col = palette
# тело / платье
pygame.draw.ellipse(s, dress_col, (35,130,90,140))
# руки
pygame.draw.ellipse(s, skin_col, (10,140,30,70))
pygame.draw.ellipse(s, skin_col, (120,140,30,70))
# шея
pygame.draw.rect(s, skin_col, (67,105,26,35))
# голова
pygame.draw.ellipse(s, skin_col, (30,20,100,100))
# волосы
pygame.draw.ellipse(s, hair_col, (25,10,110,70))
pygame.draw.ellipse(s, hair_col, (15,30,40,80)) # левая прядь
pygame.draw.ellipse(s, hair_col, (105,30,40,80)) # правая прядь
pygame.draw.rect(s, hair_col, (25,10,110,40))
# глаза
eye_y = 65
for ex in [55,95]:
pygame.draw.ellipse(s, (255,255,255), (ex-12,eye_y,24,16))
pygame.draw.ellipse(s, eye_col, (ex-8,eye_y+2,16,12))
pygame.draw.circle(s, (0,0,0), (ex,eye_y+8), 5)
pygame.draw.circle(s, (255,255,255), (ex+3,eye_y+4), 2)
# брови
brow_y = eye_y - 8
if emotion == "happy":
pygame.draw.arc(s, hair_col, (45,brow_y,20,10), 0, math.pi, 2)
pygame.draw.arc(s, hair_col, (90,brow_y,20,10), 0, math.pi, 2)
elif emotion == "sad":
pygame.draw.arc(s, hair_col, (45,brow_y,20,10), math.pi, 2*math.pi, 2)
pygame.draw.arc(s, hair_col, (90,brow_y,20,10), math.pi, 2*math.pi, 2)
else:
pygame.draw.line(s, hair_col, (45,brow_y),(65,brow_y+2), 2)
pygame.draw.line(s, hair_col, (90,brow_y),(110,brow_y+2), 2)
# рот
mouth_y = 95
if emotion == "happy":
pygame.draw.arc(s, (200,80,80), (60,mouth_y,30,16), math.pi, 2*math.pi, 2)
elif emotion == "sad":
pygame.draw.arc(s, (200,80,80), (60,mouth_y,30,16), 0, math.pi, 2)
else:
pygame.draw.line(s, (200,80,80), (65,mouth_y+8),(90,mouth_y+8), 2)
# ушки
pygame.draw.circle(s, skin_col, (30,75), 10)
pygame.draw.circle(s, skin_col, (130,75), 10)
# ноги
pygame.draw.rect(s, skin_col, (55,255,20,40))
pygame.draw.rect(s, skin_col, (90,255,20,40))
if flip:
s = pygame.transform.flip(s, True, False)
surface.blit(s, (x-80, y-280))
# Палитры персонажей
SAKURA_PALETTE = (
(255,150,180), # розовые волосы
(220,160,220), # фиолетовое платье
(255,220,195), # кожа
(255,100,180), # розовые глаза
)
YUKI_PALETTE = (
(100,180,255), # голубые волосы
(180,220,255), # голубое платье
(255,220,195),
(60,140,255), # синие глаза
)
# ── Диалоги ──────────────────────────────────────────────────────────────────
STORY = [
# (day, time_of_day, speaker, emotion, text, points_sakura, points_yuki)
# ДЕНЬ 1
("День 1","morning","narrator","neutral",
"Волны выбросили тебя на песчаный берег. Рядом — две незнакомки.",0,0),
("День 1","morning","Сакура","happy",
"Эй! Ты жив? Я Сакура! Не переживай, я умею разводить костёр!",0,0),
("День 1","morning","Юки","neutral",
"Я Юки. Надо составить план выживания. Паниковать бесполезно.",0,0),
("День 1","morning","narrator","neutral",
"Ты решаешь, кому помочь первой: собрать дрова или найти пресную воду.",0,0),
# выбор
("День 1","morning","CHOICE","neutral","[1] Помочь Сакуре с костром / [2] Помочь Юки с водой",0,0),
("День 1","day","narrator","neutral",
"Полдень. Солнце жжёт нещадно. Вы нашли кокосы.",0,0),
("День 1","day","Сакура","happy",
"УРА! Кокосы! Давай устроим пикник прямо здесь! Я так рада!",0,0),
("День 1","day","Юки","neutral",
"Кокосовое молоко восполнит электролиты. Рационально разделим поровну.",0,0),
("День 1","day","CHOICE","neutral","[1] Потанцевать с Сакурой / [2] Вместе составить карту острова с Юки",0,0),
("День 1","evening","narrator","neutral",
"Вечер. Костёр потрескивает. Звёзды зажигаются одна за другой.",0,0),
("День 1","evening","Сакура","happy",
"Смотри, как красиво! Знаешь... я рада, что ты рядом.",0,0),
("День 1","evening","Юки","neutral",
"По созвездиям можно определить стороны света. Это пригодится.",0,0),
("День 1","evening","CHOICE","neutral","[1] Смотреть на звёзды с Сакурой / [2] Учиться навигации у Юки",0,0),
("День 1","night","narrator","neutral",
"Ночь. Все устали. Ты засыпаешь под шум волн...",0,0),
# ДЕНЬ 2
("День 2","morning","narrator","neutral",
"Утро второго дня. Тебя будит Сакура — она уже приготовила фрукты!",0,0),
("День 2","morning","Сакура","happy",
"Доброе утро! Я нашла манго и бананы! Ешь, пожалуйста!",0,0),
("День 2","morning","Юки","neutral",
"Я слышала шум вертолёта ночью. Нужно сделать сигнальный знак на пляже.",0,0),
("День 2","morning","CHOICE","neutral","[1] Помочь Сакуре украсить лагерь / [2] Помочь Юки сделать SOS-знак",0,0),
("День 2","day","narrator","neutral",
"День. Начался дождь. Пришлось укрываться вместе в пальмовом шалаше.",0,0),
("День 2","day","Сакура","happy",
"Ахаха, так тесно! Это как приключение в аниме! Мне нравится!",0,0),
("День 2","day","Юки","neutral",
"Дождевую воду можно собирать. Я уже поставила листья как воронки.",0,0),
("День 2","day","CHOICE","neutral","[1] Рассказать Сакуре смешную историю / [2] Помочь Юки с системой сбора воды",0,0),
("День 2","evening","narrator","neutral",
"Вечер. Дождь стих. Все вышли на берег смотреть на закат.",0,0),
("День 2","evening","Сакура","happy",
"Правда ведь... я хотела бы остаться здесь чуть подольше...",0,0),
("День 2","evening","Юки","neutral",
"Завтра мы должны попробовать подать сигнал. Но сегодня... спасибо, что ты здесь.",0,0),
("День 2","evening","CHOICE","neutral","[1] Взять Сакуру за руку / [2] Сесть рядом с Юки",0,0),
("День 2","night","narrator","neutral",
"Ночь. Ты долго смотришь в звёздное небо, думая о них обеих...",0,0),
# ДЕНЬ 3
("День 3","morning","narrator","neutral",
"Третье утро. Вдали виден силуэт корабля!",0,0),
("День 3","morning","Юки","happy",
"Корабль! Мой сигнальный знак сработал! Нас спасут!",0,0),
("День 3","morning","Сакура","sad",
"Ура... хотя мне немного грустно. Это были лучшие дни в моей жизни.",0,0),
("День 3","morning","CHOICE","neutral","[1] Обнять Сакуру / [2] Сказать Юки, что она умница",0,0),
("День 3","day","narrator","neutral",
"Корабль приближается. Осталось несколько часов. Вы готовите плот.",0,0),
("День 3","day","Сакура","neutral",
"Эй... что будет, когда мы вернёмся? Мы ведь увидимся снова?",0,0),
("День 3","day","Юки","neutral",
"Я хочу сказать тебе кое-что важное, пока есть время...",0,0),
("День 3","day","CHOICE","neutral","[1] Пообещать Сакуре встретиться / [2] Выслушать Юки",0,0),
("День 3","evening","narrator","neutral",
"Корабль у берега. Это последний вечер на острове.",0,0),
("День 3","evening","CHOICE","neutral",
"[1] Попрощаться со всеми / [2] Остаться с Сакурой / [3] Остаться с Юки",0,0),
]
# Очки за каждый выбор: (delta_sakura, delta_yuki)
CHOICE_POINTS = {
# day1 morning
0: {1:(3,0), 2:(0,3)},
# day1 day
1: {1:(3,0), 2:(0,3)},
# day1 evening
2: {1:(3,0), 2:(0,3)},
# day2 morning
3: {1:(3,0), 2:(0,3)},
# day2 day
4: {1:(3,0), 2:(0,3)},
# day2 evening
5: {1:(3,0), 2:(0,3)},
# day3 morning
6: {1:(3,0), 2:(0,3)},
# day3 day
7: {1:(3,0), 2:(0,3)},
# day3 evening — финальный выбор
8: {1:(0,0), 2:(5,0), 3:(0,5)},
}
# ── Текстовая обёртка ─────────────────────────────────────────────────────────
def wrap_text(text, font, max_w):
words = text.split()
lines, line = [], ""
for w in words:
test = (line + " " + w).strip()
if font.size(test)[0] <= max_w:
line = test
else:
if line: lines.append(line)
line = w
if line: lines.append(line)
return lines
# ── UI-компоненты ─────────────────────────────────────────────────────────────
def draw_textbox(surface, speaker, text, palette=None):
box = pygame.Rect(30, H-170, W-60, 150)
s = pygame.Surface((box.w, box.h), pygame.SRCALPHA)
s.fill((0,0,0,180))
surface.blit(s, (box.x, box.y))
pygame.draw.rect(surface, (200,200,200), box, 2, border_radius=8)
name_col = (255,200,100)
if speaker == "Сакура":
name_col = SAKURA_PALETTE[3]
elif speaker == "Юки":
name_col = YUKI_PALETTE[3]
elif speaker == "narrator":
name_col = (180,180,180)
disp_name = "" if speaker in ("narrator","CHOICE") else speaker
if disp_name:
ns = FONT_NAME.render(disp_name, True, name_col)
surface.blit(ns, (box.x+16, box.y+8))
lines = wrap_text(text, FONT_MED, box.w-30)
for i,l in enumerate(lines[:5]):
ts = FONT_MED.render(l, True, (255,255,255))
surface.blit(ts, (box.x+16, box.y+38+i*26))
def draw_hud(surface, ps, py):
# полоски очков
pygame.draw.rect(surface, (0,0,0,120), (10,10,220,60), border_radius=6)
pygame.draw.rect(surface, SAKURA_PALETTE[3], (80,18, min(ps*8,140),18), border_radius=4)
pygame.draw.rect(surface, YUKI_PALETTE[3], (80,42, min(py*8,140),18), border_radius=4)
surface.blit(FONT_SMALL.render(f"Сакура {ps:2d}", True, SAKURA_PALETTE[3]), (12,18))
surface.blit(FONT_SMALL.render(f"Юки {py:2d}", True, YUKI_PALETTE[3]), (12,42))
def draw_choice_buttons(surface, choices, hover):
bw, bh = 360, 44
start_y = H - 170 + 50
rects = []
for i, ch in enumerate(choices):
bx = W//2 - bw//2
by = start_y + i*(bh+10)
col = (80,130,200) if hover==i else (40,60,100)
pygame.draw.rect(surface, col, (bx,by,bw,bh), border_radius=8)
pygame.draw.rect(surface, (200,200,220),(bx,by,bw,bh), 2, border_radius=8)
ts = FONT_MED.render(ch, True, (255,255,255))
surface.blit(ts, (bx + bw//2 - ts.get_width()//2, by+bh//2-ts.get_height()//2))
rects.append(pygame.Rect(bx,by,bw,bh))
return rects
# ── Экран концовки ────────────────────────────────────────────────────────────
def ending_screen(surface, ps, py, last_choice):
surface.blit(BG["day"], (0,0))
if last_choice == 1: # уплываем одни
title = "Конец: Одиночное плавание"
desc = ("Ты помахал им рукой с борта корабля. Остров скрылся за горизонтом.\n"
"В душе — тёплые воспоминания о трёх необычных днях.\n"
"Может, судьба ещё сведёт вас снова...")
col = (200,200,220)
elif last_choice == 2 or (ps > py):
title = "Конец: Сердце Сакуры 🌸"
desc = ("Сакура прыгнула тебе на шею прямо на пирсе!\n"
"«Я знала! Я знала, что ты выберешь меня!»\n"
"Юки улыбнулась и тихо пожелала вам счастья.")
col = SAKURA_PALETTE[3]
draw_anime_girl(surface, 300, 420, SAKURA_PALETTE, "Сакура", "happy")
elif last_choice == 3 or (py > ps):
title = "Конец: Звезда Юки ❄️"
desc = ("Юки остановила тебя у трапа и тихо сказала:\n"
"«Я... хотела бы видеть тебя снова. Очень.»\n"
"Её щёки стали розовыми. Это было дороже всех слов.")
col = YUKI_PALETTE[3]
draw_anime_girl(surface, 700, 420, YUKI_PALETTE, "Юки", "happy", flip=True)
else:
title = "Конец: Три сердца"
desc = ("Вы все трое не смогли расстаться.\n"
"Решили вместе путешествовать на арендованной яхте.\n"
"Остров остался позади, а впереди — бесконечное море.")
col = (255,255,200)
draw_anime_girl(surface, 280, 420, SAKURA_PALETTE, "Сакура", "happy")
draw_anime_girl(surface, 720, 420, YUKI_PALETTE, "Юки", "happy", flip=True)
overlay = pygame.Surface((W, H), pygame.SRCALPHA)
overlay.fill((0,0,0,100))
surface.blit(overlay, (0,0))
ts = FONT_BIG.render(title, True, col)
surface.blit(ts, (W//2 - ts.get_width()//2, 60))
for i, line in enumerate(desc.split("\n")):
ls = FONT_MED.render(line, True, (255,255,255))
surface.blit(ls, (W//2 - ls.get_width()//2, 150 + i*35))
hint = FONT_SMALL.render("Нажми любую клавишу для выхода", True, (180,180,180))
surface.blit(hint, (W//2 - hint.get_width()//2, H-40))
score_s = FONT_SMALL.render(f"Очки: Сакура {ps} | Юки {py}", True, (200,200,200))
surface.blit(score_s, (W//2 - score_s.get_width()//2, H-65))
pygame.display.flip()
waiting = True
while waiting:
for e in pygame.event.get():
if e.type == pygame.QUIT: pygame.quit(); sys.exit()
if e.type in (pygame.KEYDOWN, pygame.MOUSEBUTTONDOWN):
waiting = False
# ── Главный цикл ──────────────────────────────────────────────────────────────
def main():
ps, py = 0, 0 # очки
scene_idx = 0
choice_idx = 0 # счётчик выборов
wave_t = 0
hover = -1
last_final_choice = 1
# фильтруем сцены по индексу, собираем выборы
scene_list = []
for entry in STORY:
scene_list.append(entry)
current = 0
choice_mode = False
choice_options = []
choice_rects = []
def get_bg(tod):
return BG.get(tod, BG["day"])
def parse_choices(text):
parts = [p.strip() for p in text.split("/")]
result = []
for p in parts:
if p.startswith("["):
bracket_end = p.index("]")
result.append(p[bracket_end+1:].strip())
else:
result.append(p)
return result
running = True
while running and current < len(scene_list):
dt = clock.tick(60) / 1000
wave_t += dt
scene = scene_list[current]
day_label, tod, speaker, emotion, text, _, _ = scene
bg = get_bg(tod)
# events
for e in pygame.event.get():
if e.type == pygame.QUIT:
pygame.quit(); sys.exit()
if e.type == pygame.MOUSEMOTION and choice_mode:
hover = -1
for i,r in enumerate(choice_rects):
if r.collidepoint(e.pos): hover = i
if e.type == pygame.MOUSEBUTTONDOWN and e.button==1:
if choice_mode:
for i,r in enumerate(choice_rects):
if r.collidepoint(e.pos):
chosen = i+1
if choice_idx in CHOICE_POINTS:
dp = CHOICE_POINTS[choice_idx].get(chosen,(0,0))
ps += dp[0]; py += dp[1]
# для финального выбора запомним
if choice_idx == 8:
last_final_choice = chosen
choice_idx += 1
choice_mode = False
current += 1
break
else:
current += 1
if e.type == pygame.KEYDOWN:
if not choice_mode:
current += 1
# draw
screen.blit(bg, (0,0))
# анимация волн поверх воды
draw_waves(screen, wave_t)
# персонажи
if speaker != "narrator" and speaker != "CHOICE":
if speaker == "Сакура":
draw_anime_girl(screen, 280, H-175, SAKURA_PALETTE, "Сакура", emotion)
elif speaker == "Юки":
draw_anime_girl(screen, W-280, H-175, YUKI_PALETTE, "Юки", emotion, flip=True)
# всегда рисуем обоих в фоне (маленьких)
if speaker == "narrator" or speaker == "CHOICE":
# рисуем обоих немного прозрачно
tmp = pygame.Surface((W,H), pygame.SRCALPHA)
draw_anime_girl(tmp, 220, H-175, SAKURA_PALETTE, "Сакура", "neutral")
draw_anime_girl(tmp, W-220, H-175, YUKI_PALETTE, "Юки", "neutral", flip=True)
tmp.set_alpha(120)
screen.blit(tmp, (0,0))
# день-лейбл
day_surf = FONT_BIG.render(day_label, True, (255,255,220))
screen.blit(day_surf, (W//2 - day_surf.get_width()//2, 12))
# HUD
draw_hud(screen, ps, py)
if speaker == "CHOICE":
choice_mode = True
choice_options = parse_choices(text)
choice_rects = draw_choice_buttons(screen, choice_options, hover)
else:
draw_textbox(screen, speaker, text)
hint = FONT_SMALL.render("[ Кликни или нажми любую клавишу ]", True, (160,160,160))
screen.blit(hint, (W//2-hint.get_width()//2, H-22))
choice_mode = False
pygame.display.flip()
# Концовка
ending_screen(screen, ps, py, last_final_choice)
pygame.quit()
if __name__ == "__main__":
main()