comparison react_games/src/current.tsx @ 47:829623189a57

[Gara] Android commit. Bazelfied it.
author MrJuneJune <me@mrjunejune.com>
date Sat, 13 Dec 2025 14:20:34 -0800
parents 0cfd7d9277b0
children
comparison
equal deleted inserted replaced
46:b9a40c633c93 47:829623189a57
1 import { CSSProperties, useEffect, useReducer, useState } from "react"; 1 import { CSSProperties, useReducer, useState, useRef, useEffect } from "react";
2 import ReactDOM from "react-dom/client"; 2 import ReactDOM from "react-dom/client";
3 3
4 /** 4 /**
5 * 2048 5 * CONFIGURATION
6 * 6 * Replace this with your actual API key or fetch it from an environment variable.
7 * 4 X 4
8 */ 7 */
9 8 const OPENAI_API_KEY = "YOUR_OPENAI_API_KEY_HERE";
10 const MAX_WIDTH = 4; 9
11 10 interface ChatStyle {
12 type Color = 'white' | 'orange' | 'yellow' | 'red'; 11 mainContainer: CSSProperties;
13 12 sideBar: CSSProperties;
14 type Cell = { 13 sideBarItem: CSSProperties; // Added for hover/layout
15 value: number; 14 mainChat: CSSProperties;
16 color: Color; 15 mainMessage: CSSProperties;
16 messageBubble: CSSProperties; // Added for styling messages
17 inputBar: CSSProperties;
17 } 18 }
18 19
19 type Board = Cell[][]; 20 const STYLES: ChatStyle = {
20 21 mainContainer: {
21 type GameState = "in_progress" | "lost" | "won"; 22 display: "flex",
22 23 backgroundColor: "#1e1e1e",
23 type Game = { 24 color: "white",
24 board: Board; 25 fontFamily: "sans-serif",
25 state: GameState; 26 height: "100vh",
26 steps: number; 27 margin: 0,
27 } 28 },
28 29 sideBar: {
29 type Command = "u" | "d" | "l" | "r"; 30 width: "250px",
30 31 height: "100vh",
31 type GameAction = 32 overflowY: "auto",
32 { type: "move", command: Command } | { type: "calculate" }; 33 backgroundColor: "#000000",
33 34 borderRight: "1px solid #333",
34 35 padding: "1rem",
35 interface GameStyle { 36 },
36 container: CSSProperties; 37 sideBarItem: {
37 board: CSSProperties; 38 padding: "10px",
38 cell: (color: Color) => CSSProperties; 39 cursor: "pointer",
39 } 40 backgroundColor: "#333",
40 41 marginBottom: "5px",
41 const gameStyle: GameStyle = { 42 borderRadius: "5px",
42 container: { 43 whiteSpace: "nowrap",
44 overflow: "hidden",
45 textOverflow: "ellipsis",
46 },
47 mainChat: {
48 flex: 1,
43 display: "flex", 49 display: "flex",
44 flexDirection: "column", 50 flexDirection: "column",
45 justifyContent: "center", 51 backgroundColor: "#343541",
46 alignItems: "center", 52 height: "100vh",
47 height: "100vh" 53 },
48 }, 54 mainMessage: {
49 board: { 55 flex: 1,
50 display: "grid", 56 padding: "20px",
51 gridTemplateColumns: "repeat(4, 50px)", 57 overflowY: "auto",
52 background: "#EEFFEE",
53 },
54 cell: (color: Color) => ({
55 display: "flex", 58 display: "flex",
56 justifyContent: "center", 59 flexDirection: "column",
57 alignItems: "center", 60 gap: "15px",
58 aspectRatio: "1 / 1 ", 61 },
59 margin: "10px", 62 messageBubble: {
60 background: color, 63 padding: "15px",
61 }) 64 borderRadius: "8px",
62 } 65 lineHeight: "1.5",
63 66 maxWidth: "800px",
64 function initializeBoard(): Board { 67 margin: "0 auto",
65 const board = Array.from({ length: MAX_WIDTH }, () => 68 width: "100%",
66 Array.from({ length: MAX_WIDTH }, (): Cell => ({ value: 0, color: 'orange' })) 69 },
67 ); 70 inputBar: {
68 let rowIndex: number; 71 width: "100%",
69 let colIndex: number; 72 height: "100px",
70 rowIndex = Math.floor(Math.random() * 4); 73 padding: "15px",
71 colIndex = Math.floor(Math.random() * 4); 74 backgroundColor: "#40414f",
72 board[rowIndex][colIndex].value = 2; 75 color: "white",
73 board[rowIndex-1][colIndex].value = 2; 76 border: "none",
74 return board; 77 borderTop: "1px solid #555",
75 } 78 fontSize: "16px",
76 79 resize: "none",
77 function initializeGame(): Game { 80 outline: "none",
78 return { 81 },
79 board: initializeBoard(), 82 };
80 state: "in_progress", 83
81 steps: 0, 84 type Chat = {
85 id: string;
86 title: string;
87 createdAt: number;
88 };
89
90 type ChatHistory = Chat[];
91
92 type Message = {
93 id: string;
94 role: 'user' | 'assistant'; // Changed from 'author' to match OpenAI spec usually
95 content: string; // Changed from 'message' to 'content'
96 createdAt: number;
97 };
98
99 type MainPage = {
100 activeChatId: string | null;
101 chatHistory: ChatHistory;
102 currentMessages: Message[];
103 allChats: Record<string, Message[]>;
104 sendMessageStatus: 'idle' | 'inProgress' | 'failed' | 'success';
105 };
106
107 // Expanded Actions
108 type MainPageAction =
109 | { type: 'select_chat'; payload: { chatId: string } }
110 | { type: 'user_message_sent'; payload: { content: string; newChatId?: string } }
111 | { type: 'api_response_received'; payload: { content: string } }
112 | { type: 'api_error'; payload: { error: string } };
113
114 function mainPageDispatch(state: MainPage, action: MainPageAction): MainPage {
115 switch (action.type) {
116 case 'select_chat': {
117 const { chatId } = action.payload;
118 return {
119 ...state,
120 activeChatId: chatId,
121 currentMessages: state.allChats[chatId] || [],
122 sendMessageStatus: 'idle',
123 };
124 }
125
126 case 'user_message_sent': {
127 const { content, newChatId } = action.payload;
128
129 const newMessage: Message = {
130 id: Date.now().toString(),
131 role: 'user',
132 content: content,
133 createdAt: Date.now(),
134 };
135
136 // If we are starting a brand new chat (no active ID)
137 if (newChatId && !state.activeChatId) {
138 const newChatMetadata: Chat = {
139 id: newChatId,
140 title: content.substring(0, 30) + (content.length > 30 ? "..." : ""),
141 createdAt: Date.now()
142 };
143
144 return {
145 ...state,
146 activeChatId: newChatId,
147 sendMessageStatus: 'inProgress',
148 chatHistory: [newChatMetadata, ...state.chatHistory],
149 currentMessages: [newMessage],
150 allChats: {
151 ...state.allChats,
152 [newChatId]: [newMessage]
153 }
154 };
155 }
156
157 const chatId = state.activeChatId!;
158 const updatedMessages = [...state.currentMessages, newMessage];
159
160 return {
161 ...state,
162 sendMessageStatus: 'inProgress',
163 currentMessages: updatedMessages,
164 allChats: {
165 ...state.allChats,
166 [chatId]: updatedMessages
167 }
168 };
169 }
170
171 case 'api_response_received': {
172 if (!state.activeChatId) return state;
173
174 const newMsg: Message = {
175 id: Date.now().toString(),
176 role: 'assistant',
177 content: action.payload.content,
178 createdAt: Date.now()
179 };
180
181 const updatedMessages = [...state.currentMessages, newMsg];
182
183 return {
184 ...state,
185 sendMessageStatus: 'success',
186 currentMessages: updatedMessages,
187 allChats: {
188 ...state.allChats,
189 [state.activeChatId]: updatedMessages
190 }
191 };
192 }
193
194 case 'api_error': {
195 return {
196 ...state,
197 sendMessageStatus: 'failed'
198 };
199 }
200
201 default:
202 return state;
82 } 203 }
83 } 204 }
84 205
85 206 const initialMainPage: MainPage = {
86 function handleMove(board: Board, command: Command): Board { 207 activeChatId: null,
87 // Deep copy the board and initialize the merged status for the new board 208 chatHistory: [],
88 const copiedBoard = board.map(row => 209 currentMessages: [],
89 row.map(cell => ({ ...cell, merged: false })) 210 allChats: {},
90 ); 211 sendMessageStatus: 'idle'
91 212 };
92 let diff: { row: number, col: number }; 213
93 let startRow: number, endRow: number, stepRow: number; 214
94 let startCol: number, endCol: number, stepCol: number; 215 async function fetchOpenAICompletion(messages: Message[]) {
95 216 const apiMessages = messages.map(m => ({
96 const size = copiedBoard.length; 217 role: m.role,
97 218 content: m.content
98 switch (command) { 219 }));
99 case "u": 220
100 diff = { row: -1, col: 0 }; 221 try {
101 startRow = 0; endRow = size; stepRow = 1; 222 const response = await fetch("https://api.openai.com/v1/chat/completions", {
102 startCol = 0; endCol = size; stepCol = 1; 223 method: "POST",
103 break; 224 headers: {
104 case "d": 225 "Content-Type": "application/json",
105 diff = { row: 1, col: 0 }; 226 "Authorization": `Bearer ${OPENAI_API_KEY}`
106 startRow = size - 1; endRow = -1; stepRow = -1; 227 },
107 startCol = 0; endCol = size; stepCol = 1; 228 body: JSON.stringify({
108 break; 229 model: "gpt-3.5-turbo", // or gpt-4
109 case "l": 230 messages: apiMessages,
110 diff = { row: 0, col: -1 }; 231 })
111 startRow = 0; endRow = size; stepRow = 1; 232 });
112 startCol = 0; endCol = size; stepCol = 1; 233
113 break; 234 if (!response.ok) {
114 case "r": 235 throw new Error(`API Error: ${response.statusText}`);
115 diff = { row: 0, col: 1 }; 236 }
116 startRow = 0; endRow = size; stepRow = 1; 237
117 startCol = size - 1; endCol = -1; stepCol = -1; 238 const data = await response.json();
118 break; 239 return data.choices[0].message.content;
119 } 240 } catch (error) {
120 241 console.error(error);
121 for (let rowIndex = startRow; rowIndex !== endRow; rowIndex += stepRow) { 242 throw error;
122 for (let colIndex = startCol; colIndex !== endCol; colIndex += stepCol) {
123 const currentCell = copiedBoard[rowIndex][colIndex];
124
125 if (currentCell.value === 0) continue;
126
127 let r = rowIndex;
128 let c = colIndex;
129 let emptySlot: { r: number, c: number } = { r: rowIndex, c: colIndex };
130 let finalSlot: { r: number, c: number } = { r: rowIndex, c: colIndex };
131
132 while (true) {
133 r += diff.row;
134 c += diff.col;
135
136 if (r < 0 || r >= size || c < 0 || c >= size) {
137 finalSlot = emptySlot;
138 break;
139 }
140
141 const nextCell = copiedBoard[r][c];
142
143 if (nextCell.value === 0) {
144 emptySlot = { r, c };
145 finalSlot = emptySlot;
146 } else if (nextCell.value === currentCell.value && !nextCell.merged) {
147 finalSlot = { r, c };
148 break;
149 } else {
150 finalSlot = emptySlot;
151 break;
152 }
153 }
154
155 const targetCell = copiedBoard[finalSlot.r][finalSlot.c];
156
157 if (finalSlot.r === rowIndex && finalSlot.c === colIndex) {
158 continue;
159 }
160
161 if (targetCell.value === currentCell.value && !targetCell.merged) {
162 targetCell.value *= 2;
163 targetCell.merged = true;
164
165 copiedBoard[rowIndex][colIndex].value = 0;
166
167 } else if (targetCell.value === 0) {
168 targetCell.value = currentCell.value;
169 copiedBoard[rowIndex][colIndex].value = 0;
170 }
171 }
172 }
173
174 return copiedBoard;
175 }
176
177 function addNewItemsToTheBoard(board: Board) {
178 let randomRowIndex: number;
179 let randomColIndex: number;
180
181
182 let zeroPos = 0;
183 board.forEach((row) => {
184 row.forEach((cell) => {
185 if (cell.value === 0) {
186 zeroPos += 1;
187 }
188 })
189 })
190 if (zeroPos === 0) {
191 return;
192 }
193
194 let curr = 0;
195 const maxAddedValues = zeroPos < 2 ? 1 : (zeroPos / 2) | 0;
196 while (curr < maxAddedValues) {
197 randomRowIndex = Math.floor(Math.random() * board.length)
198 randomColIndex = Math.floor(Math.random() * board.length)
199 if (board[randomRowIndex][randomColIndex].value === 0)
200 {
201 board[randomRowIndex][randomColIndex].value = 2;
202 curr++;
203 }
204 } 243 }
205 } 244 }
206 245
207 function gameDispatch(game: Game, gameAction: GameAction): Game {
208 switch(gameAction.type) {
209 case "move": {
210 const newBoard = handleMove(game.board, gameAction.command);
211 addNewItemsToTheBoard(newBoard);
212 return {
213 ...game,
214 board: newBoard,
215 }
216 }
217 case "calculate": {
218 return {
219 ...game,
220 }
221 }
222 }
223 }
224
225 function Current() { 246 function Current() {
226 const [game, dispatch] = useReducer(gameDispatch, null, initializeGame); 247 const [state, dispatch] = useReducer(mainPageDispatch, initialMainPage);
227 248 const [inputValue, setInputValue] = useState("");
249 const messagesEndRef = useRef<HTMLDivElement>(null);
250
251 // Auto-scroll to bottom when messages change
228 useEffect(() => { 252 useEffect(() => {
229 window.addEventListener("keyup", (e) => { 253 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
230 switch(e.key) { 254 }, [state.currentMessages]);
231 case "ArrowDown": { 255
232 dispatch({ type: "move", command: "d" }); 256 const handleSendMessage = async () => {
233 return; 257 if (!inputValue.trim()) return;
234 } 258
235 case "ArrowUp": { 259 // 1. Determine if this is a new chat or existing
236 dispatch({ type: "move", command: "u" }); 260 const isNewChat = !state.activeChatId;
237 return; 261 const currentChatId = state.activeChatId || crypto.randomUUID(); // Generate ID for new chat
238 } 262
239 case "ArrowRight": { 263 const textToSend = inputValue;
240 dispatch({ type: "move", command: "r" }); 264 setInputValue(""); // Clear input immediately
241 return; 265
242 } 266 // 2. Dispatch User Message (Optimistic UI)
243 case "ArrowLeft": { 267 dispatch({
244 dispatch({ type: "move", command: "l" }); 268 type: 'user_message_sent',
245 return; 269 payload: { content: textToSend, newChatId: isNewChat ? currentChatId : undefined }
246 } 270 });
247 default: 271
248 return; 272 try {
249 } 273 // 3. Prepare context (include previous messages + new one)
250 }) 274 // Note: We reconstruct the array here because state update is async and might not be ready
251 275 const contextMessages: Message[] = [
252 }, []) 276 ...(isNewChat ? [] : state.currentMessages),
277 { id: 'temp', role: 'user', content: textToSend, createdAt: Date.now() }
278 ];
279
280 // 4. Call API
281 const aiResponse = await fetchOpenAICompletion(contextMessages);
282
283 // 5. Dispatch Success
284 dispatch({ type: 'api_response_received', payload: { content: aiResponse } });
285
286 } catch (error) {
287 dispatch({ type: 'api_error', payload: { error: 'Failed to fetch' } });
288 alert("Failed to connect to OpenAI. Check API Key.");
289 }
290 };
291
292 const handleKeyDown = (e: React.KeyboardEvent) => {
293 if (e.key === 'Enter' && !e.shiftKey) {
294 e.preventDefault();
295 handleSendMessage();
296 }
297 };
298
253 return ( 299 return (
254 <div style={gameStyle.container}> 300 <div style={STYLES.mainContainer}>
255 <h1> 2048 </h1> 301 {/* Sidebar */}
256 <div style={gameStyle.board}> 302 <div style={STYLES.sideBar}>
257 {game.board.map((row: Cell[]) => { 303 <h3 style={{color: '#ececf1', marginBottom: '20px'}}>History</h3>
258 return row.map((cell: Cell) => (<div style={gameStyle.cell(cell.color)}> {cell.value} </div>)) 304 <div
259 })} 305 style={{...STYLES.sideBarItem, border: '1px dashed #555'}}
306 onClick={() => {
307 // Reset to empty view for a new chat
308 // We can achieve this by setting activeChatId to null locally if we wanted
309 // But for this simple reducer, we can just reload the page or add a 'reset' action.
310 // For now, let's just allow clicking existing ones.
311 window.location.reload();
312 }}
313 >
314 + New Chat
315 </div>
316
317 {state.chatHistory.map((hist) => (
318 <div
319 key={hist.id}
320 style={{
321 ...STYLES.sideBarItem,
322 backgroundColor: state.activeChatId === hist.id ? '#555' : '#333'
323 }}
324 onClick={() => dispatch({ type: 'select_chat', payload: { chatId: hist.id } })}
325 >
326 {hist.title}
327 </div>
328 ))}
329 </div>
330
331 {/* Main Chat Area */}
332 <div style={STYLES.mainChat}>
333 <div style={STYLES.mainMessage}>
334 {state.currentMessages.length === 0 && (
335 <div style={{color: '#666', textAlign: 'center', marginTop: '40%'}}>
336 Send a message to start...
337 </div>
338 )}
339
340 {state.currentMessages.map((msg) => (
341 <div
342 key={msg.id}
343 style={{
344 ...STYLES.messageBubble,
345 backgroundColor: msg.role === 'assistant' ? '#444654' : 'transparent'
346 }}
347 >
348 <strong style={{color: msg.role === 'assistant' ? '#10a37f' : '#ececf1'}}>
349 {msg.role === 'assistant' ? 'AI' : 'You'}:
350 </strong>
351 <div style={{whiteSpace: 'pre-wrap', marginTop: '5px'}}>{msg.content}</div>
352 </div>
353 ))}
354
355 {state.sendMessageStatus === 'inProgress' && (
356 <div style={{...STYLES.messageBubble, color: '#888'}}>AI is typing...</div>
357 )}
358 <div ref={messagesEndRef} />
359 </div>
360
361 {/* Input Area */}
362 <textarea
363 style={STYLES.inputBar}
364 placeholder="Send a message..."
365 value={inputValue}
366 onChange={(e) => setInputValue(e.target.value)}
367 onKeyDown={handleKeyDown}
368 disabled={state.sendMessageStatus === 'inProgress'}
369 />
260 </div> 370 </div>
261 </div> 371 </div>
262 ); 372 );
263 } 373 }
264 374
265 ReactDOM.createRoot(document.getElementById("root")!).render(<Current />); 375 ReactDOM.createRoot(document.getElementById("root")!).render(<Current />);
376
377
378 https://www.linkedin.com/in/drakewong/
379 https://www.linkedin.com/in/dmitry-manannikov/