X commited on
Commit
d1913ca
·
verified ·
1 Parent(s): b0468e2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +277 -813
app.py CHANGED
@@ -1,823 +1,287 @@
1
- """
2
- AI PLATFORMER + NEURAL CHATBOT (FROM SCRATCH)
3
- Fixed jump physics, larger player, AABB collision, rich graphics
4
- Custom PyTorch NLP model - no external NLP libraries
5
- """
6
-
7
- import os, json, random, threading, logging, time, re, math
8
- from collections import deque, Counter
9
- from dataclasses import dataclass
10
- from typing import Dict, List, Optional, Tuple
11
  import numpy as np
12
  import torch
13
  import torch.nn as nn
14
  import torch.optim as optim
15
- from flask import Flask, jsonify, request, render_template_string
16
-
17
- logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
18
- logger = logging.getLogger(__name__)
19
-
20
-
21
- @dataclass
22
- class Cfg:
23
- W: int = 80; H: int = 20; GROUND: int = 17; CHUNK: int = 30
24
- SAFE: int = 15; VIEW: int = 40
25
- GRAV: float = 0.45 # Fixed: was 0.35
26
- JUMP: float = -3.8 # Fixed: was -6.5 (~3.6 tiles high now)
27
- SPEED: float = 0.35
28
- STATE: int = 40 * 40; ACTS: int = 4; MEM: int = 10000
29
- BATCH: int = 64; GAMMA: float = 0.99; LR: float = 5e-4
30
- EPS_DEC: float = 0.995; PORT: int = 7860
31
- MODEL: str = "dqn_model.pth"; CHAT: str = "chat_data.json"
32
- VOCAB_MAX: int = 2000; EMB_DIM: int = 64; HIDDEN: int = 128
33
- CHAT_LR: float = 1e-3; CHAT_EPOCHS: int = 80; SIM_THRESH: float = 0.5
34
-
35
- C = Cfg()
36
-
37
-
38
- # ============================================================================
39
- # CUSTOM TOKENIZER (From Scratch)
40
- # ============================================================================
41
-
42
- class Tokenizer:
43
- PAD = "<PAD>"; UNK = "<UNK>"
44
-
45
- def __init__(self, max_vocab: int = 2000):
46
- self.max_vocab = max_vocab
47
- self.word2idx: Dict[str, int] = {self.PAD: 0, self.UNK: 1}
48
- self.idx2word: Dict[int, str] = {0: self.PAD, 1: self.UNK}
49
- self.frozen = False
50
-
51
- def _tokenize(self, text: str) -> List[str]:
52
- text = text.lower().strip()
53
- text = re.sub(r'[^\w\sа-яё]', ' ', text)
54
- words = text.split()
55
- tokens = []
56
- for w in words:
57
- tokens.append(w)
58
- if len(w) > 2:
59
- tokens.extend([w[i:i+2] for i in range(len(w)-1)])
60
- return tokens
61
-
62
- def build_vocab(self, texts: List[str]):
63
- counter = Counter()
64
- for t in texts:
65
- counter.update(self._tokenize(t))
66
- most_common = counter.most_common(self.max_vocab - 2)
67
- for word, _ in most_common:
68
- idx = len(self.word2idx)
69
- self.word2idx[word] = idx
70
- self.idx2word[idx] = word
71
- self.frozen = True
72
- logger.info(f"📝 Vocab built: {len(self.word2idx)} tokens")
73
-
74
- def encode(self, text: str, max_len: int = 32) -> List[int]:
75
- tokens = self._tokenize(text)[:max_len]
76
- ids = [self.word2idx.get(t, 1) for t in tokens]
77
- ids += [0] * (max_len - len(ids))
78
- return ids
79
-
80
- @property
81
- def vocab_size(self) -> int:
82
- return len(self.word2idx)
83
-
84
-
85
- # ============================================================================
86
- # NEURAL CHAT MODEL (From Scratch)
87
- # ============================================================================
88
-
89
- class ChatEncoder(nn.Module):
90
- def __init__(self, vocab_size: int, emb_dim: int, hidden: int, out_dim: int):
91
- super().__init__()
92
- self.embedding = nn.Embedding(vocab_size, emb_dim, padding_idx=0)
93
- self.fc1 = nn.Linear(emb_dim, hidden)
94
- self.fc2 = nn.Linear(hidden, hidden)
95
- self.fc3 = nn.Linear(hidden, out_dim)
96
- self.relu = nn.ReLU()
97
- self.dropout = nn.Dropout(0.2)
98
- self.ln1 = nn.LayerNorm(hidden)
99
- self.ln2 = nn.LayerNorm(hidden)
100
-
101
- def forward(self, x):
102
- emb = self.embedding(x)
103
- mask = (x != 0).unsqueeze(-1).float()
104
- pooled = (emb * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
105
- h = self.ln1(self.fc1(pooled))
106
- h = self.relu(h)
107
- h = self.dropout(h)
108
- h = self.ln2(self.fc2(h))
109
- h = self.relu(h)
110
- h = self.dropout(h)
111
- out = self.fc3(h)
112
- return nn.functional.normalize(out, p=2, dim=-1)
113
-
114
-
115
- class NeuralChat:
116
- SEQ_LEN = 32
117
- OUT_DIM = 64
118
-
119
- def __init__(self):
120
- self.tokenizer = Tokenizer(C.VOCAB_MAX)
121
- self.data: Dict[str, str] = {}
122
- self.questions: List[str] = []
123
- self.q_embeddings: Optional[torch.Tensor] = None
124
- self.model: Optional[ChatEncoder] = None
125
- self.device = torch.device('cpu')
126
- self._load_data()
127
- self._build_and_train()
128
- logger.info(f"✅ Neural chat ready. {len(self.data)} entries.")
129
-
130
- def _load_data(self):
131
- if os.path.exists(C.CHAT):
132
- try:
133
- with open(C.CHAT, 'r', encoding='utf-8') as f:
134
- self.data = json.load(f)
135
- except Exception as e:
136
- logger.warning(f"⚠️ Chat load failed: {e}")
137
- if not self.data:
138
- self.data = {
139
- "как играть": "Стрелки ⬅️➡️ для движения, ⬆️/Пробел для прыжка",
140
- "что делает нейросеть": "DQN учится играть методом проб и ошибок, получая награду за монеты",
141
- "как обучить бота": "Напиши /data вопрос|ответ чтобы добавить знание",
142
- "какой алгоритм": "Deep Q-Network с replay buffer и target network",
143
- "зачем эпсилон": "Epsilon-greedy: чем выше ε, тем больше случайных действий для исследования",
144
- "как сбросить уровень": "Нажми 🔄 Новый уровень",
145
- "что такое dqn": "Deep Q-Network предсказывает ценность каждого действия в состоянии",
146
- "сколько нейронов": "1600→256→256→128→4 (вход=40x40 сетка)",
147
- "привет": "Привет! Я нейросетевой чат-бот платформера 🧠",
148
- "как работает чат": "Я использую кастомную нейросеть на PyTorch с эмбеддингами и косинусным сходством",
149
- "кто тебя создал": "Я написан с нуля на PyTorch без внешних NLP библиотек",
150
- }
151
- self._save_data()
152
-
153
- def _save_data(self):
154
- with open(C.CHAT, 'w', encoding='utf-8') as f:
155
- json.dump(self.data, f, ensure_ascii=False, indent=2)
156
-
157
- def _build_and_train(self):
158
- self.questions = list(self.data.keys())
159
- if not self.questions:
160
- return
161
- self.tokenizer.build_vocab(self.questions)
162
- self.model = ChatEncoder(
163
- vocab_size=self.tokenizer.vocab_size,
164
- emb_dim=C.EMB_DIM, hidden=C.HIDDEN, out_dim=self.OUT_DIM
165
- ).to(self.device)
166
- self._train_model()
167
- self._update_index()
168
-
169
- def _train_model(self):
170
- if len(self.questions) < 2:
171
- logger.warning("⚠️ Too few entries to train")
172
- return
173
- optimizer = optim.Adam(self.model.parameters(), lr=C.CHAT_LR)
174
- q_ids = torch.LongTensor([
175
- self.tokenizer.encode(q, self.SEQ_LEN) for q in self.questions
176
- ]).to(self.device)
177
- n = len(self.questions)
178
- best_loss = float('inf')
179
- logger.info(f"🧠 Training chat NN: {n} samples, {C.CHAT_EPOCHS} epochs...")
180
- for epoch in range(C.CHAT_EPOCHS):
181
- total_loss = 0.0; num_pairs = 0
182
- indices = list(range(n)); random.shuffle(indices)
183
- for i in indices:
184
- anchor = q_ids[i:i+1]
185
- positive = q_ids[i:i+1]
186
- neg_idx = random.choice([j for j in range(n) if j != i])
187
- negative = q_ids[neg_idx:neg_idx+1]
188
- emb_a = self.model(anchor)
189
- emb_p = self.model(positive)
190
- emb_n = self.model(negative)
191
- pos_sim = nn.functional.cosine_similarity(emb_a, emb_p)
192
- neg_sim = nn.functional.cosine_similarity(emb_a, emb_n)
193
- loss = torch.relu(0.3 - pos_sim + neg_sim).mean()
194
- optimizer.zero_grad(); loss.backward(); optimizer.step()
195
- total_loss += loss.item(); num_pairs += 1
196
- avg_loss = total_loss / max(num_pairs, 1)
197
- if avg_loss < best_loss: best_loss = avg_loss
198
- if (epoch + 1) % 20 == 0:
199
- logger.info(f" Epoch {epoch+1}/{C.CHAT_EPOCHS}, loss={avg_loss:.4f}")
200
- logger.info(f"✅ Chat training complete. Best loss: {best_loss:.4f}")
201
-
202
- def _update_index(self):
203
- if not self.questions or self.model is None:
204
- self.q_embeddings = None; return
205
- self.model.eval()
206
- with torch.no_grad():
207
- ids = torch.LongTensor([
208
- self.tokenizer.encode(q, self.SEQ_LEN) for q in self.questions
209
- ]).to(self.device)
210
- self.q_embeddings = self.model(ids)
211
- self.model.train()
212
-
213
- def ask(self, query: str) -> str:
214
- if not self.questions or self.q_embeddings is None:
215
- return "🤖 База пуста. Обучи меня: /data вопрос|ответ"
216
- self.model.eval()
217
- with torch.no_grad():
218
- q_id = torch.LongTensor([self.tokenizer.encode(query, self.SEQ_LEN)]).to(self.device)
219
- q_emb = self.model(q_id)
220
- sims = nn.functional.cosine_similarity(q_emb, self.q_embeddings)
221
- best_idx = torch.argmax(sims).item()
222
- best_score = sims[best_idx].item()
223
- self.model.train()
224
- if best_score >= C.SIM_THRESH:
225
- return f"{self.data[self.questions[best_idx]]} (🧠 {int(best_score*100)}%)"
226
- return f"🤖 Не знаю «{query}». Научи: /data {query}|ответ"
227
-
228
- def teach(self, question: str, answer: str) -> str:
229
- question = question.strip().lower(); answer = answer.strip()
230
- if not question or not answer: return "❌ Формат: /data вопрос|ответ"
231
- is_new = question not in self.data
232
- self.data[question] = answer; self._save_data()
233
- self.questions = list(self.data.keys())
234
- self.tokenizer = Tokenizer(C.VOCAB_MAX)
235
- self.tokenizer.build_vocab(self.questions)
236
- self.model = ChatEncoder(
237
- vocab_size=self.tokenizer.vocab_size,
238
- emb_dim=C.EMB_DIM, hidden=C.HIDDEN, out_dim=self.OUT_DIM
239
- ).to(self.device)
240
- self._train_model(); self._update_index()
241
- action = "Добавлено" if is_new else "Обновлено"
242
- return f"✅ {action}: «{question}» → «{answer}». Модель переобучена."
243
-
244
- @property
245
- def stats(self) -> dict:
246
- params = sum(p.numel() for p in self.model.parameters()) if self.model else 0
247
- return {'entries': len(self.data), 'vocab': self.tokenizer.vocab_size,
248
- 'params': params, 'emb_dim': self.OUT_DIM}
249
-
250
-
251
- # ============================================================================
252
- # GAME ENGINE (Fixed Physics + Larger Player)
253
- # ============================================================================
254
-
255
- class Engine:
256
- PW = 0.8 # Player width — FIXED: was 0.6
257
- PH = 0.95 # Player height — FIXED: was 0.9
258
-
259
- def __init__(self, seed=None):
260
- self.seed = seed or random.randint(0, 999999)
261
- self.reset()
262
-
263
  def reset(self):
264
- self.px, self.py = 5.0, float(C.GROUND)
265
- self.vx, self.vy = 0.0, 0.0
266
- self.grounded = True; self.alive = True
267
- self.score = 0; self.coins = 0; self.step_n = 0
268
- self.chunks: Dict[int, dict] = {}
269
- self.obs: List[dict] = []; self.enemies: List[dict] = []; self.coin_list: List[dict] = []
270
- self._load_chunks()
271
- return self.get_state()
272
-
273
- def _gen_chunk(self, cid: int) -> dict:
274
- rng = random.Random((cid * 1337 + self.seed) % 999999)
275
- bx = cid * C.CHUNK; obs, ens, cns = [], [], []
276
- diff = max(1.0, abs(cid) * 0.1); safe = bx < C.SAFE
277
- if not safe:
278
- for _ in range(rng.randint(3, 6) + int(diff)):
279
- x = bx + rng.randint(5, 25); h = rng.randint(1, 3 + int(diff * 0.5)); w = rng.randint(1, 3)
280
- obs.append({'x': x, 'y': C.GROUND - h, 'w': w, 'h': h, 'pit': False})
281
- for _ in range(rng.randint(1, 2)):
282
- x = bx + rng.randint(10, 20)
283
- obs.append({'x': x, 'y': C.GROUND + 1, 'w': rng.randint(2, 4), 'h': 1, 'pit': True})
284
- for _ in range(rng.randint(1, 2)):
285
- x = bx + rng.randint(10, 20)
286
- ens.append({'x': x, 'y': C.GROUND - 1, 'type': rng.choice(['walker', 'jumper']),
287
- 'dir': rng.choice([-1, 1]), 'spd': 0.3 + rng.random() * 0.3,
288
- 'rng': rng.randint(3, 8), 'ox': x})
289
- for _ in range(rng.randint(5, 10) + int(diff)):
290
- cns.append({'x': bx + rng.randint(2, 28), 'y': rng.randint(5, C.GROUND - 2), 'collected': False})
291
- return {'obs': obs, 'ens': ens, 'cns': cns}
292
-
293
- def _load_chunks(self):
294
- cc = int(self.px // C.CHUNK)
295
- for i in range(cc - 1, cc + 3):
296
- if i not in self.chunks: self.chunks[i] = self._gen_chunk(i)
297
- vl, vr = self.px - C.W / 2, self.px + C.W / 2
298
- self.obs, self.enemies, self.coin_list = [], [], []
299
- for i in range(cc - 1, cc + 3):
300
- ch = self.chunks.get(i, {})
301
- self.obs.extend([o for o in ch.get('obs', []) if vl <= o['x'] <= vr])
302
- self.enemies.extend([e for e in ch.get('ens', []) if vl <= e['x'] <= vr])
303
- self.coin_list.extend([c for c in ch.get('cns', []) if not c['collected'] and vl <= c['x'] <= vr])
304
-
305
- def _aabb(self, ax, ay, aw, ah, bx, by, bw, bh):
306
- return ax < bx + bw and ax + aw > bx and ay < by + bh and ay + ah > by
307
-
308
- def get_state(self):
309
- s = np.zeros((C.VIEW, C.VIEW), dtype=np.float32); h = C.VIEW // 2
310
- px, py = int(round(self.px)), int(round(self.py)); s[h, h] = 1.0
311
- for o in self.obs:
312
- dx, dy = int(round(o['x'])) - px, int(round(o['y'])) - py
313
- v = -1.0 if o.get('pit') else 0.8
314
- for ww in range(o.get('w', 1)):
315
- for hh in range(o.get('h', 1)):
316
- sx, sy = h + dx + ww, h + dy + hh
317
- if 0 <= sx < C.VIEW and 0 <= sy < C.VIEW: s[sy, sx] = v
318
- for e in self.enemies:
319
- dx, dy = int(round(e['x'])) - px, int(round(e['y'])) - py
320
- if 0 <= h + dx < C.VIEW and 0 <= h + dy < C.VIEW: s[h + dy, h + dx] = 0.7
321
- for c in self.coin_list:
322
- dx, dy = int(round(c['x'])) - px, int(round(c['y'])) - py
323
- if 0 <= h + dx < C.VIEW and 0 <= h + dy < C.VIEW: s[h + dy, h + dx] = 0.3
324
- return s.flatten()
325
-
326
  def step(self, action: int):
327
- sound = None
328
- # Input
329
- self.vx = 0.0
330
- if action == 1: self.vx = -C.SPEED
331
- elif action == 2: self.vx = C.SPEED
332
- if action == 3 and self.grounded:
333
- self.vy = C.JUMP; self.grounded = False; sound = 'jump'
334
-
335
- # X axis movement + collision
336
- self.px += self.vx
337
- for o in self.obs:
338
- if o.get('pit'): continue
339
- if self._aabb(self.px, self.py, self.PW, self.PH, o['x'], o['y'], o['w'], o['h']):
340
- if self.vx > 0: self.px = o['x'] - self.PW
341
- elif self.vx < 0: self.px = o['x'] + o['w']
342
- self.vx = 0
343
-
344
- # Y axis movement + collision
345
- self.vy += C.GRAV; self.py += self.vy; self.grounded = False
346
- if self.py >= C.GROUND:
347
- self.py = C.GROUND; self.vy = 0.0; self.grounded = True
348
- for o in self.obs:
349
- if o.get('pit'): continue
350
- if self._aabb(self.px, self.py, self.PW, self.PH, o['x'], o['y'], o['w'], o['h']):
351
- if self.vy > 0:
352
- self.py = o['y'] - self.PH; self.vy = 0.0; self.grounded = True
353
- elif self.vy < 0:
354
- self.py = o['y'] + o['h']; self.vy = 0.0
355
-
356
- # Death checks
357
- if self.py > C.H + 2:
358
- self.alive = False; return self.get_state(), -50.0, True, 'die'
359
- for o in self.obs:
360
- if o.get('pit') and o['x'] <= self.px + self.PW / 2 <= o['x'] + o['w'] and self.py >= C.GROUND:
361
- self.alive = False; return self.get_state(), -50.0, True, 'die'
362
- for e in self.enemies:
363
- if self._aabb(self.px, self.py, self.PW, self.PH, e['x'] - 0.3, e['y'] - 0.3, 0.6, 0.6):
364
- self.alive = False; return self.get_state(), -50.0, True, 'die'
365
-
366
- # Coins
367
- got = 0
368
- for c in self.coin_list:
369
- if not c['collected'] and self._aabb(self.px, self.py, self.PW, self.PH, c['x'] - 0.3, c['y'] - 0.3, 0.6, 0.6):
370
- c['collected'] = True; got += 1
371
- if got: self.coins += got; self.score += got * 10; sound = 'coin'
372
-
373
- # Update enemies
374
- t = time.time()
375
- for e in self.enemies:
376
- if e['type'] == 'walker':
377
- e['x'] += e['spd'] * e['dir']
378
- if abs(e['x'] - e['ox']) > e['rng']: e['dir'] *= -1
379
- else:
380
- e['y'] = (C.GROUND - 1) + np.sin(t * e['spd'] * 3) * 0.5
381
-
382
- self.score += 1; self.step_n += 1; self._load_chunks()
383
- done = self.step_n > 3000
384
- return self.get_state(), 1.0 + got * 5.0, done, sound
385
-
386
- def world_data(self):
387
- return {
388
- 'player': [round(self.px, 2), round(self.py, 2)],
389
- 'obstacles': self.obs, 'entities': self.enemies,
390
- 'coins': [c for c in self.coin_list if not c['collected']],
391
- 'ground': C.GROUND, 'score': self.score,
392
- 'coins_collected': self.coins, 'alive': self.alive
393
  }
394
-
395
-
396
- # ============================================================================
397
- # DQN AGENT
398
- # ============================================================================
399
-
400
- class Net(nn.Module):
401
- def __init__(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  super().__init__()
403
- self.net = nn.Sequential(
404
- nn.Linear(C.STATE, 256), nn.ReLU(),
405
- nn.Linear(256, 256), nn.ReLU(),
406
- nn.Linear(256, 128), nn.ReLU(),
407
- nn.Linear(128, C.ACTS)
 
 
 
 
408
  )
409
- def forward(self, x): return self.net(x)
410
-
411
- class Agent:
412
- def __init__(self):
413
- self.dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
414
- self.model = Net().to(self.dev)
415
- self.target = Net().to(self.dev)
416
- self.target.load_state_dict(self.model.state_dict())
417
- self.opt = optim.Adam(self.model.parameters(), lr=C.LR)
418
- self.crit = nn.MSELoss()
419
- self.mem = deque(maxlen=C.MEM)
420
- self.eps = 1.0; self.steps = 0; self.best = 0; self.training = False
421
- if os.path.exists(C.MODEL):
422
- try:
423
- self.model.load_state_dict(torch.load(C.MODEL, map_location=self.dev))
424
- self.target.load_state_dict(self.model.state_dict())
425
- logger.info("✅ DQN loaded")
426
- except Exception as e: logger.warning(f"⚠️ DQN load failed: {e}")
427
-
428
- def act(self, s):
429
- if random.random() <= self.eps: return random.randrange(C.ACTS)
430
  with torch.no_grad():
431
- return torch.argmax(self.model(torch.FloatTensor(s).unsqueeze(0).to(self.dev))).item()
432
-
433
- def remember(self, s, a, r, ns, d): self.mem.append((s, a, r, ns, d))
434
-
435
- def replay(self):
436
- if len(self.mem) < C.BATCH: return
437
- b = random.sample(self.mem, C.BATCH)
438
- st = torch.FloatTensor([x[0] for x in b]).to(self.dev)
439
- ac = torch.LongTensor([x[1] for x in b]).to(self.dev)
440
- rw = torch.FloatTensor([x[2] for x in b]).to(self.dev)
441
- ns = torch.FloatTensor([x[3] for x in b]).to(self.dev)
442
- dn = torch.FloatTensor([x[4] for x in b]).to(self.dev)
443
- q = self.model(st).gather(1, ac.unsqueeze(1)).squeeze()
444
- nq = self.target(ns).max(1)[0].detach()
445
- tgt = rw + C.GAMMA * nq * (1 - dn)
446
- loss = self.crit(q, tgt)
447
- self.opt.zero_grad(); loss.backward(); self.opt.step()
448
- if self.eps > 0.01: self.eps *= C.EPS_DEC
449
- self.steps += 1
450
- if self.steps % 100 == 0: self.target.load_state_dict(self.model.state_dict())
451
-
452
- def train_ep(self):
453
- env = Engine(); s = env.reset(); tr = 0.0; d = False; n = 0
454
- while not d and n < 500:
455
- a = self.act(s); ns, r, d, _ = env.step(a)
456
- self.remember(s, a, r, ns, d); self.replay()
457
- s = ns; tr += r; n += 1
458
- if tr > self.best: self.best = tr; self.save()
459
- return tr
460
-
461
- def save(self): torch.save(self.model.state_dict(), C.MODEL)
462
-
463
-
464
- # ============================================================================
465
- # GLOBAL STATE
466
- # ============================================================================
467
-
468
- agent = Agent()
469
- chat = NeuralChat()
470
- seed = random.randint(0, 999999)
471
- ai_env = Engine(seed)
472
- pl_env = Engine(seed)
473
- is_training = False
474
-
475
-
476
- # ============================================================================
477
- # HTML (Rich Graphics + Larger Player Rendering)
478
- # ============================================================================
479
-
480
- HTML = """
481
- <!DOCTYPE html>
482
- <html lang="ru">
483
- <head>
484
- <meta charset="UTF-8">
485
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
486
- <title>🧠 AI Platformer + Neural Chat</title>
487
- <style>
488
- *{margin:0;padding:0;box-sizing:border-box}
489
- body{background:#0d1117;color:#eee;font-family:'Segoe UI',sans-serif;display:flex;justify-content:center;padding:20px;min-height:100vh}
490
- .wrap{max-width:1100px;width:100%}
491
- h1{text-align:center;padding:15px 0;background:linear-gradient(135deg,#ff6b6b,#4ecdc4);-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-size:2.2em}
492
- .sub{text-align:center;color:#888;margin-bottom:15px}
493
- .row{display:flex;gap:20px;flex-wrap:wrap}
494
- .box{flex:1;min-width:320px;background:#161b22;border-radius:16px;padding:15px;box-shadow:0 8px 32px rgba(0,0,0,.5);border:1px solid #30363d}
495
- .box h3{text-align:center;margin-bottom:10px;color:#c9d1d9}
496
- canvas{width:100%;aspect-ratio:4/1;border-radius:8px;display:block;image-rendering:pixelated;background:#0d1117}
497
- .ctrl{display:flex;justify-content:center;gap:12px;margin:15px 0;flex-wrap:wrap}
498
- .ctrl button{padding:12px 30px;font-size:1.1em;border:none;border-radius:10px;cursor:pointer;font-weight:bold;transition:all .15s;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5)}
499
- .ctrl button:hover{transform:scale(1.05);filter:brightness(1.2)}
500
- .ctrl button:active{transform:scale(.93)}
501
- .bl,.br{background:linear-gradient(135deg,#ff6b6b,#ee5a24)}
502
- .bj{background:linear-gradient(135deg,#4ecdc4,#2ecc71);padding:12px 45px}
503
- .brs{background:linear-gradient(135deg,#a29bfe,#6c5ce7)}
504
- .stats{background:#161b22;border-radius:12px;padding:12px 20px;margin:10px 0;display:flex;justify-content:space-around;flex-wrap:wrap;gap:10px;font-size:1.1em;border:1px solid #30363d}
505
- .stats span{color:#ff6b6b;font-weight:bold}
506
- .tabs{display:flex;gap:10px;margin:15px 0;flex-wrap:wrap}
507
- .tab{padding:10px 22px;background:#161b22;border-radius:10px;cursor:pointer;border:2px solid #30363d;transition:all .3s;color:#c9d1d9}
508
- .tab:hover{border-color:#ff6b6b}
509
- .tab.active{border-color:#ff6b6b;background:#1c2333}
510
- .tc{background:#161b22;border-radius:12px;padding:20px;min-height:200px;border:1px solid #30363d}
511
- .ca{display:flex;gap:10px;margin-top:10px}
512
- .ca input{flex:1;padding:10px;border-radius:8px;border:1px solid #30363d;background:#0d1117;color:#eee;font-size:1em}
513
- .ca button{padding:10px 25px;background:#ff6b6b;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:bold}
514
- .cm{max-height:200px;overflow-y:auto;padding:5px}
515
- .cm div{padding:6px 12px;margin:3px 0;border-radius:6px;background:#0d1117}
516
- .cm .u{border-left:3px solid #ff6b6b}
517
- .cm .b{border-left:3px solid #4ecdc4}
518
- .hidden{display:none}
519
- .nn-info{font-size:0.85em;color:#888;margin-top:8px;padding:8px;background:#0d1117;border-radius:6px}
520
- </style>
521
- </head>
522
- <body>
523
- <div class="wrap">
524
- <h1>🧠 AI vs Player Platformer</h1>
525
- <p class="sub">🤖 Нейросеть слева 🎮 Ты справа (⬅️ ➡️ ⬆️) | 💬 Чат = нейросеть с нуля</p>
526
- <div class="row">
527
- <div class="box"><h3>🤖 Нейросеть</h3><canvas id="ac"></canvas></div>
528
- <div class="box"><h3>🎮 Ты</h3><canvas id="pc"></canvas></div>
529
- </div>
530
- <div class="stats">
531
- <div>🤖 ИИ: <span id="as">0</span></div>
532
- <div>🎮 Ты: <span id="ps">0</span></div>
533
- <div>🪙 Монет: <span id="cc">0</span></div>
534
- <div>🧠 ε: <span id="ep">1.00</span></div>
535
- <div>🏆 Рекорд: <span id="bs">0</span></div>
536
- </div>
537
- <div class="ctrl">
538
- <button class="bl" id="bL">⬅️ Влево</button>
539
- <button class="bj" id="bJ">⬆️ ПРЫЖОК</button>
540
- <button class="br" id="bR">➡️ Вправо</button>
541
- <button class="brs" id="bReset">🔄 Новый уровень</button>
542
- </div>
543
- <div class="tabs">
544
- <div class="tab active" data-tab="chat">💬 Нейро-чат</div>
545
- <div class="tab" data-tab="train">🧠 Тренировка DQN</div>
546
- <div class="tab" data-tab="stats">📊 Статистика</div>
547
- </div>
548
- <div class="tc">
549
- <div id="chatTab">
550
- <div class="cm" id="msgs">
551
- <div class="b">🧠 Привет! Я нейросетевой чат, написанный с нуля на PyTorch.</div>
552
- <div class="b">Команды: /data вопрос|ответ, /stats, /train</div>
553
- <div class="b">Спрашивай что угодно — я ищу по смыслу, не по словам!</div>
554
- </div>
555
- <div class="ca"><input id="ci" placeholder="Задай вопрос или /data вопрос|ответ..." onkeydown="if(event.key==='Enter')sendChat()"><button onclick="sendChat()">➤</button></div>
556
- <div class="nn-info" id="nnInfo">Загрузка модели...</div>
557
- </div>
558
- <div id="trainTab" class="hidden">
559
- <h3>🧠 Тренировка DQN</h3><p>Deep Q-Network (256→256→128)</p>
560
- <button onclick="startTrain()" style="padding:12px 35px;background:linear-gradient(135deg,#ff6b6b,#ee5a24);color:#fff;border:none;border-radius:10px;font-size:1.1em;cursor:pointer;margin-top:10px">🚀 Запустить</button>
561
- <div id="ts" style="margin-top:10px;color:#888">⏸ Остановлена</div>
562
- </div>
563
- <div id="statsTab" class="hidden"><h3>📊 Статистика</h3><div id="sc">Загрузка...</div></div>
564
- </div>
565
- </div>
566
- <script>
567
- function initC(id){const c=document.getElementById(id);c.width=800;c.height=200;return c.getContext('2d')}
568
- const aC=initC('ac'),pC=initC('pc');
569
- let pA=0;
570
-
571
- function draw(ctx,d,show){
572
- const W=ctx.canvas.width,H=ctx.canvas.height,cW=W/80,cH=H/20;
573
- ctx.clearRect(0,0,W,H);
574
-
575
- // Sky
576
- const sg=ctx.createLinearGradient(0,0,0,H);
577
- sg.addColorStop(0,'#0f0c29');sg.addColorStop(0.5,'#302b63');sg.addColorStop(1,'#24243e');
578
- ctx.fillStyle=sg;ctx.fillRect(0,0,W,H);
579
-
580
- // Stars
581
- ctx.fillStyle='rgba(255,255,255,0.3)';
582
- for(let i=0;i<30;i++){const sx=(i*137+d.player[0]*0.1)%W,sy=(i*97)%((d.ground-2)*cH);ctx.fillRect(sx,sy,2,2)}
583
-
584
- const cam=Math.max(0,d.player[0]-40);
585
- function toS(wx,wy){return[(wx-cam)*cW,wy*cH]}
586
-
587
- // Ground
588
- const gy=d.ground*cH;
589
- const gg=ctx.createLinearGradient(0,gy,0,H);
590
- gg.addColorStop(0,'#4a7c59');gg.addColorStop(0.15,'#3d6b4e');gg.addColorStop(0.5,'#5c4033');gg.addColorStop(1,'#3e2723');
591
- ctx.fillStyle=gg;ctx.fillRect(0,gy,W,H-gy);
592
- ctx.fillStyle='#6abf69';ctx.fillRect(0,gy,W,cH*0.3);
593
- ctx.fillStyle='#81c784';for(let gx=0;gx<W;gx+=8)ctx.fillRect(gx,gy-cH*0.1,4,cH*0.15);
594
-
595
- // Obstacles
596
- for(const o of d.obstacles){
597
- const[x,y]=toS(o.x,o.y);
598
- if(o.pit){
599
- const pg=ctx.createLinearGradient(0,y-cH,0,y+cH);
600
- pg.addColorStop(0,'#1a1a2e');pg.addColorStop(1,'#000');
601
- ctx.fillStyle=pg;ctx.fillRect(x,y-cH,o.w*cW,cH*2);
602
- ctx.fillStyle='#ff4444';ctx.fillRect(x,y-cH*0.5,o.w*cW,2);
603
- }else{
604
- const bg=ctx.createLinearGradient(x,y,x,y+o.h*cH);
605
- bg.addColorStop(0,'#8d6e63');bg.addColorStop(1,'#6d4c41');
606
- ctx.fillStyle=bg;ctx.fillRect(x,y,o.w*cW,o.h*cH);
607
- ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=1;
608
- for(let by=0;by<o.h;by++){
609
- const yy=y+by*cH;ctx.beginPath();ctx.moveTo(x,yy);ctx.lineTo(x+o.w*cW,yy);ctx.stroke();
610
- const off=(by%2)*cW*0.5;
611
- for(let bx=off;bx<o.w*cW;bx+=cW){ctx.beginPath();ctx.moveTo(x+bx,yy);ctx.lineTo(x+bx,yy+cH);ctx.stroke()}
612
- }
613
- ctx.fillStyle='rgba(255,255,255,0.15)';ctx.fillRect(x,y,o.w*cW,cH*0.15);
614
- ctx.fillStyle='rgba(0,0,0,0.3)';ctx.fillRect(x+o.w*cW,y,3,o.h*cH);
615
- }
616
- }
617
-
618
- // Enemies
619
- const t=Date.now()/200;
620
- for(const e of d.entities){
621
- const[x,y]=toS(e.x,e.y);const bounce=Math.sin(t+e.x)*2;
622
- ctx.save();ctx.translate(x+cW/2,y+cH/2+bounce);
623
- const eg=ctx.createRadialGradient(0,0,2,0,0,cH/2);
624
- eg.addColorStop(0,'#ff6b6b');eg.addColorStop(1,'#c0392b');
625
- ctx.fillStyle=eg;ctx.beginPath();ctx.arc(0,0,cH/2.5,0,Math.PI*2);ctx.fill();
626
- ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(-4,-3,3,0,Math.PI*2);ctx.arc(4,-3,3,0,Math.PI*2);ctx.fill();
627
- ctx.fillStyle='#000';const ex=e.dir*2;
628
- ctx.beginPath();ctx.arc(-4+ex,-3,1.5,0,Math.PI*2);ctx.arc(4+ex,-3,1.5,0,Math.PI*2);ctx.fill();
629
- ctx.shadowColor='#ff6b6b';ctx.shadowBlur=10;
630
- ctx.strokeStyle='#ff6b6b';ctx.lineWidth=1;ctx.beginPath();ctx.arc(0,0,cH/2.2,0,Math.PI*2);ctx.stroke();
631
- ctx.restore();
632
- }
633
-
634
- // Coins
635
- for(const c of d.coins){
636
- const[x,y]=toS(c.x,c.y);const pulse=1+Math.sin(t*2+c.x)*0.15;
637
- ctx.save();ctx.translate(x+cW/2,y+cH/2);ctx.scale(pulse,pulse);
638
- const cg=ctx.createRadialGradient(-2,-2,1,0,0,cH/3);
639
- cg.addColorStop(0,'#fff9c4');cg.addColorStop(0.5,'#ffd700');cg.addColorStop(1,'#f9a825');
640
- ctx.fillStyle=cg;ctx.beginPath();ctx.arc(0,0,cH/3,0,Math.PI*2);ctx.fill();
641
- ctx.shadowColor='#ffd700';ctx.shadowBlur=12;
642
- ctx.strokeStyle='#ffeb3b';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(0,0,cH/3,0,Math.PI*2);ctx.stroke();
643
- ctx.fillStyle='rgba(255,255,255,0.8)';ctx.beginPath();ctx.arc(-3,-3,2,0,Math.PI*2);ctx.fill();
644
- ctx.restore();
645
- }
646
-
647
- // PLAYER — FIXED: larger, more visible
648
- if(show&&d.alive){
649
- const[px,py]=toS(d.player[0],d.player[1]);
650
- const pw=cW*0.85, ph=cH*0.95;
651
- const ox=px+(cW-pw)/2, oy=py+(cH-ph)/2;
652
- ctx.save();
653
- ctx.shadowColor='#00ff88';ctx.shadowBlur=25;
654
- const pg=ctx.createLinearGradient(ox,oy,ox+pw,oy+ph);
655
- pg.addColorStop(0,'#00ff88');pg.addColorStop(0.5,'#00e676');pg.addColorStop(1,'#00b894');
656
- ctx.fillStyle=pg;ctx.fillRect(ox,oy,pw,ph);
657
- ctx.shadowBlur=0;
658
- ctx.strokeStyle='#b9f6ca';ctx.lineWidth=2;ctx.strokeRect(ox,oy,pw,ph);
659
- // Eyes
660
- ctx.fillStyle='#fff';
661
- ctx.fillRect(ox+pw*0.15,oy+ph*0.2,pw*0.25,ph*0.22);
662
- ctx.fillRect(ox+pw*0.6,oy+ph*0.2,pw*0.25,ph*0.22);
663
- ctx.fillStyle='#0d1117';
664
- ctx.fillRect(ox+pw*0.22,oy+ph*0.27,pw*0.12,ph*0.1);
665
- ctx.fillRect(ox+pw*0.67,oy+ph*0.27,pw*0.12,ph*0.1);
666
- // Mouth
667
- ctx.fillStyle='#0d1117';
668
- ctx.fillRect(ox+pw*0.3,oy+ph*0.6,pw*0.4,ph*0.08);
669
- ctx.restore();
670
- }
671
- }
672
-
673
- async function update(){
674
- try{
675
- const r=await fetch('/step',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:pA})});
676
- const d=await r.json();
677
- draw(aC,d.ai,true);draw(pC,d.player,d.player.alive);
678
- document.getElementById('as').textContent=d.ai.score;
679
- document.getElementById('ps').textContent=d.player.score;
680
- document.getElementById('cc').textContent=d.player.coins_collected;
681
- document.getElementById('ep').textContent=d.epsilon.toFixed(3);
682
- document.getElementById('bs').textContent=d.best_score;
683
- }catch(e){}
684
- }
685
-
686
- const sA=v=>{pA=v};
687
- document.getElementById('bL').onmousedown=()=>sA(1);document.getElementById('bL').onmouseup=()=>sA(0);
688
- document.getElementById('bR').onmousedown=()=>sA(2);document.getElementById('bR').onmouseup=()=>sA(0);
689
- document.getElementById('bJ').onmousedown=()=>sA(3);document.getElementById('bJ').onmouseup=()=>sA(0);
690
- document.addEventListener('keydown',e=>{
691
- if(e.key==='ArrowLeft'){e.preventDefault();sA(1)}
692
- else if(e.key==='ArrowRight'){e.preventDefault();sA(2)}
693
- else if(e.key==='ArrowUp'||e.key===' '){e.preventDefault();sA(3)}
694
- });
695
- document.addEventListener('keyup',e=>{if(['ArrowLeft','ArrowRight','ArrowUp',' '].includes(e.key)){e.preventDefault();sA(0)}});
696
-
697
- document.getElementById('bReset').onclick=async()=>{
698
- const r=await fetch('/reset',{method:'POST'});const d=await r.json();
699
- draw(aC,d.ai,true);draw(pC,d.player,true);
700
- document.getElementById('as').textContent=d.ai.score;
701
- document.getElementById('ps').textContent=d.player.score;
702
- };
703
-
704
- async function sendChat(){
705
- const inp=document.getElementById('ci');const msg=inp.value.trim();if(!msg)return;inp.value='';
706
- const m=document.getElementById('msgs');
707
- m.innerHTML+=`<div class="u">👤 ${msg}</div>`;m.scrollTop=m.scrollHeight;
708
- const r=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg})});
709
- const d=await r.json();
710
- m.innerHTML+=`<div class="b">🧠 ${d.response}</div>`;m.scrollTop=m.scrollHeight;
711
- }
712
-
713
- async function startTrain(){
714
- document.getElementById('ts').textContent='⏳ Запуск...';
715
- const r=await fetch('/train',{method:'POST'});const d=await r.json();
716
- document.getElementById('ts').textContent=d.message;
717
- }
718
-
719
- document.querySelectorAll('.tab').forEach(t=>t.onclick=function(){
720
- document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
721
- this.classList.add('active');const n=this.dataset.tab;
722
- document.querySelectorAll('.tc>div').forEach(d=>d.classList.add('hidden'));
723
- document.getElementById(n+'Tab').classList.remove('hidden');
724
- if(n==='stats')fetch('/stats').then(r=>r.json()).then(d=>{
725
- document.getElementById('sc').innerHTML=`
726
- <p>🧠 DQN шагов: ${d.steps}</p>
727
- <p>📉 ε: ${d.epsilon}</p>
728
- <p>🏆 Рекорд: ${d.best_score}</p>
729
- <p>⚡ Тренируется: ${d.training?'✅':'❌'}</p>
730
- <hr style="border-color:#30363d;margin:8px 0">
731
- <p>💬 Чат записей: ${d.chat.entries}</p>
732
- <p>📝 Словарь: ${d.chat.vocab}</p>
733
- <p>🔢 Параметров чата: ${d.chat.params.toLocaleString()}</p>
734
- <p>📐 Эмбеддинг: ${d.chat.emb_dim}D</p>`;
735
- });
736
- });
737
-
738
- fetch('/stats').then(r=>r.json()).then(d=>{
739
- document.getElementById('nnInfo').textContent=
740
- `🧠 Чат-нейросеть: ${d.chat.params.toLocaleString()} параметров | ��ловарь: ${d.chat.vocab} | Эмбеддинг: ${d.chat.emb_dim}D | Записей: ${d.chat.entries}`;
741
- });
742
-
743
- setInterval(update,100);update();
744
- </script>
745
- </body>
746
- </html>
747
- """
748
-
749
-
750
- # ============================================================================
751
- # FLASK ROUTES
752
- # ============================================================================
753
-
754
- app = Flask(__name__)
755
-
756
- @app.route('/')
757
- def index(): return render_template_string(HTML)
758
-
759
- @app.route('/step', methods=['POST'])
760
- def step():
761
- global ai_env, pl_env
762
- action = request.json.get('action', 0)
763
- if ai_env.alive: ai_env.step(agent.act(ai_env.get_state()))
764
- else: ai_env.reset()
765
- if pl_env.alive: pl_env.step(action)
766
- else: pl_env.reset()
767
- return jsonify({
768
- 'ai': ai_env.world_data(), 'player': pl_env.world_data(),
769
- 'epsilon': agent.eps, 'best_score': agent.best
770
- })
771
-
772
- @app.route('/reset', methods=['POST'])
773
- def reset():
774
- global seed, ai_env, pl_env
775
- seed = random.randint(0, 999999)
776
- ai_env = Engine(seed); pl_env = Engine(seed)
777
- return jsonify({'ai': ai_env.world_data(), 'player': pl_env.world_data()})
778
-
779
- @app.route('/chat', methods=['POST'])
780
- def chat_route():
781
- msg = request.json.get('message', '').strip()
782
- if msg.startswith('/data '):
783
- p = msg[6:].split('|')
784
- if len(p) != 2: return jsonify({'response': "❌ Формат: /data вопрос|ответ"})
785
- return jsonify({'response': chat.teach(p[0], p[1])})
786
- elif msg == '/stats':
787
- s = chat.stats
788
- return jsonify({'response': f"🧠 Чат: {s['params']} парам., {s['vocab']} слов, {s['entries']} записей, {s['emb_dim']}D"})
789
- elif msg == '/train':
790
- return jsonify({'response': start_training()})
791
- else:
792
- return jsonify({'response': chat.ask(msg)})
793
-
794
- @app.route('/train', methods=['POST'])
795
- def train_route(): return jsonify({'message': start_training()})
796
-
797
- @app.route('/stats')
798
- def stats():
799
- return jsonify({
800
- 'steps': agent.steps, 'epsilon': round(agent.eps, 3),
801
- 'best_score': agent.best, 'training': is_training,
802
- 'chat': chat.stats
803
- })
804
-
805
- def start_training():
806
- global is_training
807
- if is_training: return "⏳ Уже тренируется!"
808
- is_training = True
809
- def _t():
810
- global is_training
811
- try:
812
- for ep in range(100):
813
- if not is_training: break
814
- sc = agent.train_ep()
815
- if ep % 10 == 0: logger.info(f"DQN Ep {ep}: score={sc:.1f}, ε={agent.eps:.3f}")
816
- except Exception as e: logger.error(f"DQN error: {e}")
817
- finally: is_training = False
818
- threading.Thread(target=_t, daemon=True).start()
819
- return "🚀 Тренировка DQN запущена!"
820
-
821
-
822
- if __name__ == '__main__':
823
- app.run(host='0.0.0.0', port=C.PORT, debug=False)
 
 
 
 
 
 
 
 
 
 
 
1
  import numpy as np
2
  import torch
3
  import torch.nn as nn
4
  import torch.optim as optim
5
+ from torch.distributions import Categorical
6
+ import gradio as gr
7
+ import matplotlib.pyplot as plt
8
+ from collections import defaultdict
9
+ import random
10
+
11
+ # ==========================================
12
+ # 1. БЕСКОНЕЧНЫЙ ПРОЦЕДУРНЫЙ МИР
13
+ # ==========================================
14
+ class InfiniteWorld:
15
+ """
16
+ Мир хранится в словаре чанков.
17
+ Координаты не ограничены.
18
+ Биомы генерируются детерминировано по hash координат.
19
+ """
20
+ CHUNK_SIZE = 16
21
+ VIEW_RADIUS = 8 # Агент видит 16x16 вокруг себя
22
+
23
+ def __init__(self, seed=42):
24
+ self.seed = seed
25
+ self.chunks = {} # (cx, cy) -> np.array(CHUNK_SIZE, CHUNK_SIZE)
26
+ self.agent_pos = [0, 0]
27
+ self.steps = 0
28
+ self.max_steps = 1000
29
+
30
+ def _get_chunk(self, cx: int, cy: int) -> np.ndarray:
31
+ if (cx, cy) not in self.chunks:
32
+ # Детерминированная генерация по координатам
33
+ rng = np.random.RandomState(hash((cx, cy, self.seed)) % (2**31))
34
+ chunk = np.zeros((self.CHUNK_SIZE, self.CHUNK_SIZE), dtype=np.float32)
35
+ # Простая процедурная генерация: кластеры блоков
36
+ noise = rng.rand(self.CHUNK_SIZE, self.CHUNK_SIZE)
37
+ chunk[noise > 0.7] = 1.0 # "Природные" блоки
38
+ self.chunks[(cx, cy)] = chunk
39
+ return self.chunks[(cx, cy)]
40
+
41
+ def _world_coords(self, x: int, y: int):
42
+ cx, lx = divmod(x, self.CHUNK_SIZE)
43
+ cy, ly = divmod(y, self.CHUNK_SIZE)
44
+ return cx, cy, lx, ly
45
+
46
+ def get_block(self, x: int, y: int) -> float:
47
+ cx, cy, lx, ly = self._world_coords(x, y)
48
+ return self._get_chunk(cx, cy)[lx, ly]
49
+
50
+ def set_block(self, x: int, y: int, val: float):
51
+ cx, cy, lx, ly = self._world_coords(x, y)
52
+ self._get_chunk(cx, cy)[lx, ly] = val
53
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  def reset(self):
55
+ self.agent_pos = [0, 0]
56
+ self.steps = 0
57
+ return self._get_obs()
58
+
59
+ def _get_obs(self):
60
+ """Возвращает локальный патч 16x16x3 вокруг агента"""
61
+ x, y = self.agent_pos
62
+ patch = np.zeros((self.VIEW_RADIUS*2, self.VIEW_RADIUS*2, 3), dtype=np.float32)
63
+
64
+ for dx in range(-self.VIEW_RADIUS, self.VIEW_RADIUS):
65
+ for dy in range(-self.VIEW_RADIUS, self.VIEW_RADIUS):
66
+ wx, wy = x + dx, y + dy
67
+ block = self.get_block(wx, wy)
68
+ px = dx + self.VIEW_RADIUS
69
+ py = dy + self.VIEW_RADIUS
70
+ patch[px, py, 0] = block
71
+ # Канал 1: расстояние до центра (позиционный энкодинг)
72
+ patch[px, py, 1] = max(0, 1.0 - abs(dx)/self.VIEW_RADIUS)
73
+ patch[px, py, 2] = max(0, 1.0 - abs(dy)/self.VIEW_RADIUS)
74
+
75
+ # Отмечаем позицию агента в центре
76
+ patch[self.VIEW_RADIUS, self.VIEW_RADIUS, 1] = 1.0
77
+ return patch
78
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  def step(self, action: int):
80
+ self.steps += 1
81
+ reward = -0.005
82
+ done = self.steps >= self.max_steps
83
+
84
+ ACTIONS = {
85
+ 0: (-1, 0), 1: (1, 0), 2: (0, -1), 3: (0, 1), # Move N/S/W/E
86
+ 4: (0, 0), # Build
87
+ 5: (0, 0), # Dig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
89
+
90
+ if action < 4:
91
+ dx, dy = ACTIONS[action]
92
+ self.agent_pos[0] += dx
93
+ self.agent_pos[1] += dy
94
+ elif action == 4: # Build
95
+ x, y = self.agent_pos
96
+ if self.get_block(x, y) == 0:
97
+ self.set_block(x, y, 1.0)
98
+ reward = 1.0
99
+ elif action == 5: # Dig
100
+ x, y = self.agent_pos
101
+ if self.get_block(x, y) == 1.0:
102
+ self.set_block(x, y, 0.0)
103
+ reward = 0.3
104
+
105
+ return self._get_obs(), reward, done, {"pos": tuple(self.agent_pos)}
106
+
107
+
108
+ # ==========================================
109
+ # 2. PPO AGENT С ПАМЯТЬЮ
110
+ # ==========================================
111
+ class PPOAgent(nn.Module):
112
+ def __init__(self, action_space=6, hidden_dim=256):
113
  super().__init__()
114
+ self.action_space = action_space
115
+
116
+ # Vision encoder (обрабатывает локальный патч 16x16)
117
+ self.encoder = nn.Sequential(
118
+ nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(),
119
+ nn.Conv2d(32, 64, 3, stride=2, padding=1), nn.ReLU(),
120
+ nn.Conv2d(64, 64, 3, stride=2, padding=1), nn.ReLU(),
121
+ nn.AdaptiveAvgPool2d((4, 4)),
122
+ nn.Flatten()
123
  )
124
+
125
+ # GRU для памяти (агент помнит, что уже видел/строил)
126
+ self.gru = nn.GRUCell(64 * 4 * 4, hidden_dim)
127
+ self.hidden = None
128
+
129
+ # Actor-Critic heads
130
+ self.actor = nn.Linear(hidden_dim, action_space)
131
+ self.critic = nn.Linear(hidden_dim, 1)
132
+
133
+ def forward(self, obs, hidden=None):
134
+ features = self.encoder(obs.permute(0, 3, 1, 2))
135
+ h = self.gru(features, hidden)
136
+ logits = self.actor(h)
137
+ value = self.critic(h)
138
+ return logits, value, h
139
+
140
+ def act(self, obs, hidden=None):
 
 
 
 
141
  with torch.no_grad():
142
+ logits, value, new_hidden = self.forward(obs.unsqueeze(0), hidden)
143
+ dist = Categorical(logits=logits)
144
+ action = dist.sample()
145
+ return action.item(), dist.log_prob(action), value.squeeze(), new_hidden
146
+
147
+
148
+ # ==========================================
149
+ # 3. PPO TRAINING LOOP
150
+ # ==========================================
151
+ def train_ppo(episodes=300, steps_per_update=256, lr=3e-4):
152
+ env = InfiniteWorld()
153
+ agent = PPOAgent()
154
+ optimizer = optim.Adam(agent.parameters(), lr=lr)
155
+
156
+ reward_history = []
157
+
158
+ for ep in range(episodes):
159
+ obs = env.reset()
160
+ hidden = None
161
+ episode_rewards = []
162
+
163
+ # Rollout buffer
164
+ buffers = {'obs': [], 'actions': [], 'log_probs': [], 'rewards': [], 'values': [], 'hiddens': []}
165
+
166
+ for _ in range(steps_per_update):
167
+ obs_t = torch.FloatTensor(obs)
168
+ action, log_prob, value, hidden = agent.act(obs_t, hidden)
169
+ next_obs, reward, done, info = env.step(action)
170
+
171
+ buffers['obs'].append(obs_t)
172
+ buffers['actions'].append(action)
173
+ buffers['log_probs'].append(log_prob)
174
+ buffers['rewards'].append(reward)
175
+ buffers['values'].append(value)
176
+ buffers['hiddens'].append(hidden)
177
+ episode_rewards.append(reward)
178
+
179
+ obs = next_obs
180
+ if done:
181
+ obs = env.reset()
182
+ hidden = None
183
+
184
+ # GAE computation
185
+ returns = []
186
+ advantages = []
187
+ R = 0
188
+ A = 0
189
+ gamma, lam = 0.99, 0.95
190
+
191
+ for i in reversed(range(len(buffers['rewards']))):
192
+ R = buffers['rewards'][i] + gamma * R
193
+ delta = buffers['rewards'][i] + gamma * (buffers['values'][i+1].item() if i < len(buffers['values'])-1 else 0) - buffers['values'][i].item()
194
+ A = delta + gamma * lam * A
195
+ returns.insert(0, R)
196
+ advantages.insert(0, A)
197
+
198
+ returns = torch.FloatTensor(returns)
199
+ advantages = torch.FloatTensor(advantages)
200
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
201
+
202
+ # PPO Update (4 epochs)
203
+ obs_batch = torch.stack(buffers['obs'])
204
+ actions_batch = torch.LongTensor(buffers['actions'])
205
+ old_log_probs = torch.stack(buffers['log_probs']).detach()
206
+
207
+ for _ in range(4):
208
+ logits, values, _ = agent.forward(obs_batch)
209
+ dist = Categorical(logits=logits)
210
+ new_log_probs = dist.log_prob(actions_batch)
211
+ entropy = dist.entropy().mean()
212
+
213
+ ratio = (new_log_probs - old_log_probs).exp()
214
+ surr1 = ratio * advantages
215
+ surr2 = torch.clamp(ratio, 0.8, 1.2) * advantages
216
+
217
+ actor_loss = -torch.min(surr1, surr2).mean()
218
+ critic_loss = (returns - values.squeeze()).pow(2).mean()
219
+ loss = actor_loss + 0.5 * critic_loss - 0.01 * entropy
220
+
221
+ optimizer.zero_grad()
222
+ loss.backward()
223
+ nn.utils.clip_grad_norm_(agent.parameters(), 0.5)
224
+ optimizer.step()
225
+
226
+ avg_reward = np.mean(episode_rewards)
227
+ reward_history.append(avg_reward)
228
+ if ep % 20 == 0:
229
+ print(f"Ep {ep} | Avg Reward: {avg_reward:.3f} | Chunks explored: {len(env.chunks)}")
230
+
231
+ return agent, reward_history
232
+
233
+
234
+ # ==========================================
235
+ # 4. HF SPACE DEMO
236
+ # ==========================================
237
+ def create_demo():
238
+ print("🏗️ Обучение PPO-агента в бесконечном мире...")
239
+ agent, history = train_ppo(episodes=100)
240
+ agent.eval()
241
+
242
+ def run_exploration(n_steps=200):
243
+ env = InfiniteWorld(seed=random.randint(0, 99999))
244
+ obs = env.reset()
245
+ hidden = None
246
+ frames = []
247
+ positions = []
248
+
249
+ with torch.no_grad():
250
+ for _ in range(n_steps):
251
+ fig, ax = plt.subplots(figsize=(5, 5))
252
+ ax.imshow(obs)
253
+ ax.set_title(f"Pos: {env.agent_pos} | Chunks: {len(env.chunks)}")
254
+ ax.axis('off')
255
+ frames.append(fig)
256
+ plt.close(fig)
257
+ positions.append(tuple(env.agent_pos))
258
+
259
+ obs_t = torch.FloatTensor(obs)
260
+ action, _, _, hidden = agent.act(obs_t, hidden)
261
+ obs, _, done, _ = env.step(action)
262
+ if done:
263
+ break
264
+
265
+ # Карта посещённых позиций
266
+ fig2, ax2 = plt.subplots(figsize=(6, 6))
267
+ xs, ys = zip(*positions)
268
+ ax2.scatter(xs, ys, c=range(len(positions)), cmap='viridis', s=1)
269
+ ax2.set_title("Траектория исследования")
270
+ ax2.set_aspect('equal')
271
+ frames.append(fig2)
272
+ plt.close(fig2)
273
+
274
+ return frames
275
+
276
+ with gr.Blocks(title="Infinite Builder Agent") as demo:
277
+ gr.Markdown("# 🌍 Бесконечный мир: PPO-агент с нуля")
278
+ gr.Markdown("Агент видит только локальный патч 16×16, имеет GRU-память и исследует процедурный мир.")
279
+ btn = gr.Button("▶️ Запустить исследование (200 шагов)")
280
+ gallery = gr.Gallery(label="Процесс", columns=5, height="auto")
281
+ btn.click(fn=run_exploration, outputs=gallery)
282
+
283
+ return demo
284
+
285
+ if __name__ == "__main__":
286
+ demo = create_demo()
287
+ demo.launch()