Mercurial
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/ |