From 3c64295efa3382ab64c7b7d02715ccacaaf63cc4 Mon Sep 17 00:00:00 2001 From: Matthias Berse Date: Thu, 21 Aug 2025 20:27:11 +0200 Subject: [PATCH 1/3] Add Vibe Editor --- hackerjeopardy_editor/README.md | 132 +++++ .../__pycache__/models.cpython-313.pyc | Bin 0 -> 7096 bytes hackerjeopardy_editor/example_quiz.json | 114 ++++ hackerjeopardy_editor/main.py | 536 ++++++++++++++++++ hackerjeopardy_editor/models.py | 129 +++++ hackerjeopardy_editor/yeswecan.json | 215 +++++++ 6 files changed, 1126 insertions(+) create mode 100644 hackerjeopardy_editor/README.md create mode 100644 hackerjeopardy_editor/__pycache__/models.cpython-313.pyc create mode 100644 hackerjeopardy_editor/example_quiz.json create mode 100644 hackerjeopardy_editor/main.py create mode 100644 hackerjeopardy_editor/models.py create mode 100644 hackerjeopardy_editor/yeswecan.json diff --git a/hackerjeopardy_editor/README.md b/hackerjeopardy_editor/README.md new file mode 100644 index 00000000..af91f626 --- /dev/null +++ b/hackerjeopardy_editor/README.md @@ -0,0 +1,132 @@ +# Hacker Jeopardy Editor + +A simple GUI editor for creating and editing Hacker Jeopardy quiz files. This replaces the clunky Unity editor with a user-friendly Python/Tkinter interface. + +## Features + +### Core Functionality +- **Load/Save JSON files** - Open existing quiz files or create new ones +- **Category Management** - Add, edit, and delete quiz categories with custom colors +- **Question Management** - Add, edit, and delete questions within categories +- **Simple Form UI** - Easy-to-use text fields and controls for all properties +- **Basic Validation** - Ensures required fields are filled and values are valid + +### Advanced Features +- **Color Picker** - Visual color selection for categories and questions +- **Media File Browser** - Easy selection of question and answer media files +- **Question Type Support** - Text, image, audio, and video question types +- **Real-time Updates** - Changes are reflected immediately in the interface + +## Requirements + +- Python 3.8 or higher +- Tkinter (included with Python) +- No additional dependencies required! + +## Installation + +1. Clone or download the project files +2. Ensure Python 3.8+ is installed +3. Run the editor: + +```bash +cd hackerjeopardy_editor +python3 main.py +``` + +## Usage + +### Getting Started + +1. **Create a New Quiz**: Use File → New to start with a blank quiz +2. **Load Sample Data**: Use File → Open and select `example_quiz.json` to see a working example +3. **Edit Quiz Info**: Update the game name and tagline at the top of the window + +### Working with Categories + +1. **Add Category**: Click "Add Category" in the Categories pane +2. **Edit Category**: Select a category from the list, then use the Category tab in the Editor pane +3. **Change Color**: Click "Choose Color" to pick a custom category color +4. **Delete Category**: Select a category and click "Delete Category" + +### Working with Questions + +1. **Add Question**: Select a category, then click "Add Question" in the Questions pane +2. **Edit Question**: Select a question from the list, then use the Question tab in the Editor pane +3. **Set Properties**: Fill in value, type, question text, answer, and optional note +4. **Add Media**: Use the Browse buttons to select question or answer media files +5. **Change Color**: Click "Choose Color" to pick a custom question color +6. **Delete Question**: Select a question and click "Delete Question" + +### File Operations + +- **New**: File → New (creates blank quiz) +- **Open**: File → Open (loads existing JSON file) +- **Save**: File → Save (saves to current file) +- **Save As**: File → Save As (saves to new file) + +## JSON Format + +The editor creates JSON files compatible with the Hacker Jeopardy Unity game. The format includes: + +```json +{ + "gameName": "Quiz Title", + "tagline": "Quiz Description", + "categories": [ + { + "name": "Category Name", + "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}, + "questions": [ + { + "value": 100, + "type": "text", + "question": "Question text", + "answer": "Answer text", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Optional note", + "questionColor": {"r": 0.8, "g": 0.2, "b": 0.2, "a": 1.0} + } + ] + } + ] +} +``` + +## Tips + +- **Save Frequently**: Use Ctrl+S or File → Save to avoid losing work +- **Use the Sample**: Load `example_quiz.json` to see how a complete quiz is structured +- **Color Coding**: Use different colors for categories and questions to make them visually distinct +- **Media Paths**: Media file paths can be relative or absolute +- **Question Values**: Typically use 100, 200, 300, 400, 500 for Jeopardy-style scoring + +## Troubleshooting + +### Common Issues + +1. **Application won't start**: Ensure Python 3.8+ is installed and Tkinter is available +2. **Can't load file**: Check that the JSON file is valid and follows the expected format +3. **Colors not showing**: Make sure color values are between 0.0 and 1.0 +4. **Media files not found**: Use absolute paths or ensure relative paths are correct + +### Error Messages + +- **"Question text is required"**: Fill in the question field before saving +- **"Answer text is required"**: Fill in the answer field before saving +- **"Question value must be positive"**: Enter a number greater than 0 for the question value + +## Development + +The editor consists of three main files: + +- `models.py`: Data classes for Quiz, Category, Question, and Color +- `main.py`: Main GUI application with Tkinter interface +- `example_quiz.json`: Sample quiz data for testing + +The code is designed to be simple and maintainable, focusing on core functionality over advanced features. + +## License + +This project is part of the Hacker Jeopardy game system. Use and modify as needed for your quiz creation needs. diff --git a/hackerjeopardy_editor/__pycache__/models.cpython-313.pyc b/hackerjeopardy_editor/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f269bd1d9f600684241e28ec56aafe2db49916f2 GIT binary patch literal 7096 zcmcgwU2GFq7M}6U*yDH{=RYAp7$b0ODTETX3~7-_<#p|e4Ycc0>#67MWhk+#w*Z-H!u)R*0J?)=0Krma+M zFZksC&HX#)`|i1C-RrGkAnkqQk5eD|80KGCu##YA)<1{LT}ELP_8dd-uj3p$>L89$ zj&L%TxpVxeK!i~zamvhuqmAig6uzBNg#8X}N!MgMSS zVLbwwyUYYbScM@Dg(cih*f8NIgbAm@^*}3>T(rc~l0ZaSa+)PKExBk3HbQD9Jc_%W zv5!DfYKS-L2}rsyl8h(GDxUMd&FLAPB{~~5i)+I=`G zwsGd7dEC%oCxzVuU+gz~CD@Aoyxq;tZ0hf*=>L#fn&M#2MAAsl&BP0oZ!WX>$UZ5D%S zB$i5&f$3yKjcffgbGkE?48lTni7Kk!DBeB&LdC~eQO4}U~W zslFk%FQ?>s^8WltzICB~VQ9g%?CV_?d)HxDCS0yXJaMY+&mnV{8D}rr`o_LSQrI&6 zT9yob70>|>P_)3J2z%fF6OJq8eK^Hgr4Lup$Emog^x?+==$E_3c}3iBS`z5lo!&M= z)KE&5>8Z(+Bsopht))mT5hV?rJ@dUQM!#s$rGzM%qs4gctHSWopJnZhBRSO z(7AA2)7_wxAT^%Nibrgq;$_kTIbFoLsRnBe4O-E z`wEm(oy1K`2E#gmU$IT3ZEoV3kS0W2JJi*fb>6Z%k2ywdS)F9|^G(#jS6*7G1?H|- zd>|W?I?@<*2kLe4wOLieLtotn6Rbu+=)$#7d{))@lzJlta&AV|!S1e_9pUmxXxCLT zrq^3l=YeHHzYL|Obf3|-QNe>!Ro!d#q$XQI{Wck#hN*!Jn2Ydta|#1gHo&W}@DFIf z0_Zpsw?Bvi@B;i;^wUo;{cYIHtPY#tBZr|YHHLdEX@x=T{S z?ep{Jm)rN>mkwb2&H0-vKDpqNjb3*9(0%CzFtyu<=MU%WKNz;_kKC7zmVC`wFSW4% zON~3wM0Uco1TB=Htr9d9x)*xmkY8YN2!zhTp6Tw5?Y@W&XeQ(!ibE)DxR7I5JcXhU zMK_8Kj_t=H3ZxM&1_C&?zA5u^cBsfVmFikDr?Oo|zNOUEk~xzNW3Hhob2>YMtv$P! z`R3e>1@6wfMSgc_XCQMScR3%v^Jb9`l-jq!r@gtM`8`E`TWRZ#%=z5@BERDa?+}iB zE4qaiV-+}e7=CuLu(OVY@&n#)B^&d=#^B+ajBEJ7E?`YbLEqP_xJhl457g-52-s;f zN#<73TGG;(dR?}vbRMt0F&pmci_)4@c3IkIx@3c>Ja9JF=&KH-bcFS2Pz>!frt`jIuqId~JIa^Td==IhJHc`-ih{-adn46!w{r>#> z1{v6JH)z6!yTecwYAN8Wig~)C)3_KMf3s%MzA0pX0NQ7;9}2W`4RHq0uIn%W-BD_6 z1&{+GTT60x=4>{VJ2U@Yk?(%O^Md@XBv|;w0jlHkJ!tt6_{6uWR5fKU^k8?RFO zCIytjW14s;Y86k}3)QDcP+JGJ-m=%n1?a4`fiTcFJU?9Ube1|~K-dLDSl9O;?C93 zD?We0=U?%46?|Pb;s@?adp9olimLd7E}tV?vOW@!StY7z*D8u*%Xkq>Syi(3sNL(8|4 z8!~jIY0KS(a7y(j{{Uk>>l>Lfd3e@i=!H811BAAMn+qibeXlt(I+kS@qp;WRaf&^_ zVsCVRz`f?jxvKMV74sd3`2>X@7wq|<^aJymJ?MOp$N9$jYR>o-m$_(&Ai_EY{KC?c z%$z^LFdjx`E{_=DxN%3-G4Ld5qGNEI#`x46!>_P(9T>61xmfH6w%|b9)Z$%Q)k z^R|M2Xz?_N2X!wzb}?HIuGKI*`*T+clD{OitVr7n()K@06*>kN+dw>!h8}NawhXOx zFde>-9wxM<6Jhhs`+}I_|mgY4F)ok z^=gC&Ca(yJ^AHa~ZWsQu04Fn1m!Ijg!4(0pPC8^}2bd+c9piQ|lWagtwq9(Liq6R}+I2Iej zU}qBETRQ)?mIN7@ot`0Y!YANPHNzi>cEQjy7+GK^H6#(+Y9tMo5d(E&>b^|{{s$YRfz-od5NL#gie+25YcMLv$-iGP$>2raRH6~2^CKjoO(p%w4o z1MlE>8Y1n(T}QY-*PL{_ss}FtZa+m$_Tw2ij>mr+im0x_H#mzgx|!!IYHWw8C?VbU zYBCft6orV|P?Zs3oPza}7zoe;nZ^P>LUbPDaS;-MEL6)_J%i#ZiVz4p5N(7bYq5Kf zB9Iwu$2ZB*D&{_j}u;Dfljc67rM+)Bjk+Pan69pP>=^1l}18UjUXiG zl?`$Ixfui;-6T@1LDDuq({*p!kS-oIg0;s<8oprRr=hmjIF@Dq$#}kI0$(%!M{Lbg zM-%H`V?e;l93EIR6#F3e)X~betT7M`2cC9fWXv1R% button + self.selected_cell = None # (cat_index, value) + + # Editor pane + edit_frame = ttk.LabelFrame(content_frame, text="Editor") + edit_frame.grid(row=0, column=1, sticky="nsew", padx=(5, 0)) + + # Create notebook for category and question editing + self.notebook = ttk.Notebook(edit_frame) + self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Category editor tab + self.cat_edit_frame = ttk.Frame(self.notebook) + self.notebook.add(self.cat_edit_frame, text="Category") + self.setup_category_editor() + + # Question editor tab + self.q_edit_frame = ttk.Frame(self.notebook) + self.notebook.add(self.q_edit_frame, text="Question") + self.setup_question_editor() + + # Configure grid weights (now 2 columns instead of 3) + content_frame.columnconfigure(0, weight=1) + content_frame.columnconfigure(1, weight=2) + content_frame.rowconfigure(0, weight=1) + + def setup_category_editor(self): + # Category name + ttk.Label(self.cat_edit_frame, text="Category Name:").grid(row=0, column=0, sticky="w", padx=5, pady=5) + self.cat_name_var = tk.StringVar() + self.cat_name_entry = ttk.Entry(self.cat_edit_frame, textvariable=self.cat_name_var, width=30) + self.cat_name_entry.grid(row=0, column=1, sticky="ew", padx=5, pady=5) + self.cat_name_var.trace('w', self.on_category_changed) + + # Category color + ttk.Label(self.cat_edit_frame, text="Color:").grid(row=1, column=0, sticky="w", padx=5, pady=5) + color_frame = ttk.Frame(self.cat_edit_frame) + color_frame.grid(row=1, column=1, sticky="ew", padx=5, pady=5) + + self.cat_color_canvas = tk.Canvas(color_frame, width=50, height=25, bg="blue") + self.cat_color_canvas.pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(color_frame, text="Choose Color", command=self.choose_category_color).pack(side=tk.LEFT) + + self.cat_edit_frame.columnconfigure(1, weight=1) + + def setup_question_editor(self): + row = 0 + + # Question value + ttk.Label(self.q_edit_frame, text="Value:").grid(row=row, column=0, sticky="w", padx=5, pady=2) + self.q_value_var = tk.StringVar() + self.q_value_entry = ttk.Entry(self.q_edit_frame, textvariable=self.q_value_var, width=10) + self.q_value_entry.grid(row=row, column=1, sticky="w", padx=5, pady=2) + self.q_value_var.trace('w', self.on_question_changed) + row += 1 + + # Question type + ttk.Label(self.q_edit_frame, text="Type:").grid(row=row, column=0, sticky="w", padx=5, pady=2) + self.q_type_var = tk.StringVar() + self.q_type_combo = ttk.Combobox(self.q_edit_frame, textvariable=self.q_type_var, + values=["text", "image", "audio", "video"], width=15) + self.q_type_combo.grid(row=row, column=1, sticky="w", padx=5, pady=2) + self.q_type_var.trace('w', self.on_question_changed) + row += 1 + + # Question text + ttk.Label(self.q_edit_frame, text="Question:").grid(row=row, column=0, sticky="nw", padx=5, pady=2) + self.q_question_text = tk.Text(self.q_edit_frame, height=4, width=40) + self.q_question_text.grid(row=row, column=1, sticky="ew", padx=5, pady=2) + self.q_question_text.bind('', self.on_question_text_changed) + row += 1 + + # Answer text + ttk.Label(self.q_edit_frame, text="Answer:").grid(row=row, column=0, sticky="nw", padx=5, pady=2) + self.q_answer_text = tk.Text(self.q_edit_frame, height=4, width=40) + self.q_answer_text.grid(row=row, column=1, sticky="ew", padx=5, pady=2) + self.q_answer_text.bind('', self.on_answer_text_changed) + row += 1 + + # Note + ttk.Label(self.q_edit_frame, text="Note:").grid(row=row, column=0, sticky="nw", padx=5, pady=2) + self.q_note_text = tk.Text(self.q_edit_frame, height=3, width=40) + self.q_note_text.grid(row=row, column=1, sticky="ew", padx=5, pady=2) + self.q_note_text.bind('', self.on_note_text_changed) + row += 1 + + # Question media path + ttk.Label(self.q_edit_frame, text="Question Media:").grid(row=row, column=0, sticky="w", padx=5, pady=2) + media_frame = ttk.Frame(self.q_edit_frame) + media_frame.grid(row=row, column=1, sticky="ew", padx=5, pady=2) + self.q_media_var = tk.StringVar() + self.q_media_entry = ttk.Entry(media_frame, textvariable=self.q_media_var) + self.q_media_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) + ttk.Button(media_frame, text="Browse", command=self.browse_question_media).pack(side=tk.RIGHT) + self.q_media_var.trace('w', self.on_question_changed) + row += 1 + + # Answer media path + ttk.Label(self.q_edit_frame, text="Answer Media:").grid(row=row, column=0, sticky="w", padx=5, pady=2) + answer_media_frame = ttk.Frame(self.q_edit_frame) + answer_media_frame.grid(row=row, column=1, sticky="ew", padx=5, pady=2) + self.q_answer_media_var = tk.StringVar() + self.q_answer_media_entry = ttk.Entry(answer_media_frame, textvariable=self.q_answer_media_var) + self.q_answer_media_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) + ttk.Button(answer_media_frame, text="Browse", command=self.browse_answer_media).pack(side=tk.RIGHT) + self.q_answer_media_var.trace('w', self.on_question_changed) + row += 1 + + # Question color + ttk.Label(self.q_edit_frame, text="Question Color:").grid(row=row, column=0, sticky="w", padx=5, pady=2) + q_color_frame = ttk.Frame(self.q_edit_frame) + q_color_frame.grid(row=row, column=1, sticky="ew", padx=5, pady=2) + + self.q_color_canvas = tk.Canvas(q_color_frame, width=50, height=25, bg="blue") + self.q_color_canvas.pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(q_color_frame, text="Choose Color", command=self.choose_question_color).pack(side=tk.LEFT) + + self.q_edit_frame.columnconfigure(1, weight=1) + + # File operations + def new_quiz(self): + if messagebox.askyesno("New Quiz", "Create a new quiz? Unsaved changes will be lost."): + self.quiz = Quiz() + self.current_category = None + self.current_question = None + self.current_file = None + self.refresh_all() + + def open_quiz(self): + filepath = filedialog.askopenfilename( + title="Open Quiz File", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")] + ) + if filepath: + try: + self.quiz = Quiz.load_from_file(filepath) + self.current_file = filepath + self.current_category = None + self.current_question = None + self.refresh_all() + messagebox.showinfo("Success", "Quiz loaded successfully!") + except Exception as e: + messagebox.showerror("Error", f"Failed to load quiz: {str(e)}") + + def save_quiz(self): + if self.current_file: + try: + self.quiz.save_to_file(self.current_file) + messagebox.showinfo("Success", "Quiz saved successfully!") + except Exception as e: + messagebox.showerror("Error", f"Failed to save quiz: {str(e)}") + else: + self.save_quiz_as() + + def save_quiz_as(self): + filepath = filedialog.asksaveasfilename( + title="Save Quiz As", + defaultextension=".json", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")] + ) + if filepath: + try: + self.quiz.save_to_file(filepath) + self.current_file = filepath + messagebox.showinfo("Success", "Quiz saved successfully!") + except Exception as e: + messagebox.showerror("Error", f"Failed to save quiz: {str(e)}") + + # Category operations + def add_category(self): + category = Category() + self.quiz.categories.append(category) + self.create_matrix() + # Select the new category + self.select_category(len(self.quiz.categories) - 1) + + def delete_category(self): + if self.current_category: + cat_index = self.quiz.categories.index(self.current_category) + if messagebox.askyesno("Delete Category", "Are you sure you want to delete this category?"): + del self.quiz.categories[cat_index] + self.current_category = None + self.current_question = None + self.selected_cell = None + self.refresh_all() + + # Question operations (now handled through matrix clicks) + def delete_question(self): + if self.current_question and self.current_category: + if messagebox.askyesno("Delete Question", "Are you sure you want to delete this question?"): + self.current_category.questions.remove(self.current_question) + self.current_question = None + self.selected_cell = None + self.create_matrix() + self.refresh_question_editor() + + # Event handlers + def on_quiz_info_changed(self, *args): + self.quiz.gameName = self.game_name_var.get() + self.quiz.tagline = self.tagline_var.get() + + + def on_category_changed(self, *args): + if self.current_category: + self.current_category.name = self.cat_name_var.get() + self.create_matrix() # Refresh matrix to show new category name + + def on_question_changed(self, *args): + if self.current_question: + try: + self.current_question.value = int(self.q_value_var.get() or "0") + except ValueError: + pass + self.current_question.type = self.q_type_var.get() + self.current_question.questionMediaPath = self.q_media_var.get() or None + self.current_question.answerMediaPath = self.q_answer_media_var.get() or None + self.create_matrix() # Refresh matrix to show changes + + def on_question_text_changed(self, event): + if self.current_question: + self.current_question.question = self.q_question_text.get("1.0", tk.END).strip() + self.create_matrix() # Refresh matrix to show new question text + + def on_answer_text_changed(self, event): + if self.current_question: + self.current_question.answer = self.q_answer_text.get("1.0", tk.END).strip() + + def on_note_text_changed(self, event): + if self.current_question: + self.current_question.note = self.q_note_text.get("1.0", tk.END).strip() + + # Color choosers + def choose_category_color(self): + if self.current_category: + color = colorchooser.askcolor(color=self.current_category.color.to_hex()) + if color[1]: # color[1] is the hex string + self.current_category.color = Color.from_hex(color[1]) + self.refresh_category_editor() + + def choose_question_color(self): + if self.current_question: + color = colorchooser.askcolor(color=self.current_question.questionColor.to_hex()) + if color[1]: # color[1] is the hex string + self.current_question.questionColor = Color.from_hex(color[1]) + self.refresh_question_editor() + + # File browsers + def browse_question_media(self): + filepath = filedialog.askopenfilename( + title="Select Question Media", + filetypes=[("All files", "*.*"), ("Images", "*.png *.jpg *.jpeg *.gif"), + ("Audio", "*.mp3 *.wav"), ("Video", "*.mp4 *.avi")] + ) + if filepath: + self.q_media_var.set(filepath) + + def browse_answer_media(self): + filepath = filedialog.askopenfilename( + title="Select Answer Media", + filetypes=[("All files", "*.*"), ("Images", "*.png *.jpg *.jpeg *.gif"), + ("Audio", "*.mp3 *.wav"), ("Video", "*.mp4 *.avi")] + ) + if filepath: + self.q_answer_media_var.set(filepath) + + # Matrix methods + def create_matrix(self): + """Create the Jeopardy-style matrix grid""" + # Clear existing matrix + for widget in self.matrix_container.winfo_children(): + widget.destroy() + self.matrix_buttons.clear() + + if not self.quiz.categories: + # Show message when no categories + no_cat_label = ttk.Label(self.matrix_container, text="Add categories to see the quiz board", + font=("Arial", 12), foreground="gray") + no_cat_label.pack(expand=True) + return + + # Create grid frame + grid_frame = ttk.Frame(self.matrix_container) + grid_frame.pack(fill=tk.BOTH, expand=True) + + # Standard Jeopardy values + values = [100, 200, 300, 400, 500] + + # Create header row with category names + ttk.Label(grid_frame, text="", width=8).grid(row=0, column=0, padx=1, pady=1) # Empty corner + for col, category in enumerate(self.quiz.categories): + header_btn = tk.Button(grid_frame, text=category.name, + bg=category.color.to_hex(), fg="white", + font=("Arial", 10, "bold"), width=15, height=2, + command=lambda c=col: self.select_category(c)) + header_btn.grid(row=0, column=col+1, padx=1, pady=1, sticky="ew") + + # Create value rows + for row, value in enumerate(values): + # Value label + ttk.Label(grid_frame, text=f"${value}", font=("Arial", 10, "bold"), + width=8).grid(row=row+1, column=0, padx=1, pady=1) + + # Question cells + for col, category in enumerate(self.quiz.categories): + question = self.find_question_by_value(category, value) + cell_key = (col, value) + + if question: + # Existing question - show truncated text + text = question.question[:20] + "..." if len(question.question) > 20 else question.question + bg_color = self.lighten_color(category.color.to_hex()) + btn = tk.Button(grid_frame, text=text, bg=bg_color, + font=("Arial", 9), width=15, height=3, wraplength=100, + command=lambda c=col, v=value: self.select_cell(c, v)) + else: + # Empty cell - show add option + btn = tk.Button(grid_frame, text="+ Add\nQuestion", bg="#f0f0f0", + font=("Arial", 9), width=15, height=3, fg="gray", + command=lambda c=col, v=value: self.select_cell(c, v)) + + btn.grid(row=row+1, column=col+1, padx=1, pady=1, sticky="ew") + self.matrix_buttons[cell_key] = btn + + # Configure column weights for resizing + for col in range(len(self.quiz.categories) + 1): + grid_frame.columnconfigure(col, weight=1) + + def find_question_by_value(self, category, value): + """Find a question in a category by its value""" + for question in category.questions: + if question.value == value: + return question + return None + + def lighten_color(self, hex_color): + """Lighten a hex color for better readability""" + # Remove # if present + hex_color = hex_color.lstrip('#') + # Convert to RGB + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + # Lighten by mixing with white + r = min(255, r + 80) + g = min(255, g + 80) + b = min(255, b + 80) + return f"#{r:02x}{g:02x}{b:02x}" + + def select_category(self, cat_index): + """Select a category for editing""" + if cat_index < len(self.quiz.categories): + self.current_category = self.quiz.categories[cat_index] + self.current_question = None + self.selected_cell = None + self.refresh_category_editor() + self.refresh_question_editor() + self.notebook.select(0) # Switch to category tab + self.highlight_selection() + + def select_cell(self, cat_index, value): + """Select a cell (question) for editing""" + if cat_index < len(self.quiz.categories): + category = self.quiz.categories[cat_index] + question = self.find_question_by_value(category, value) + + self.current_category = category + self.selected_cell = (cat_index, value) + + if question: + # Edit existing question + self.current_question = question + else: + # Create new question + question = Question(value=value) + category.questions.append(question) + self.current_question = question + self.create_matrix() # Refresh matrix to show new question + + self.refresh_category_editor() + self.refresh_question_editor() + self.notebook.select(1) # Switch to question tab + self.highlight_selection() + + def highlight_selection(self): + """Highlight the selected cell in the matrix""" + # Reset all button styles + for (col, value), btn in self.matrix_buttons.items(): + category = self.quiz.categories[col] if col < len(self.quiz.categories) else None + if category: + question = self.find_question_by_value(category, value) + if question: + btn.config(relief="raised", borderwidth=1) + else: + btn.config(relief="raised", borderwidth=1) + + # Highlight selected cell + if self.selected_cell and self.selected_cell in self.matrix_buttons: + self.matrix_buttons[self.selected_cell].config(relief="solid", borderwidth=3) + + # Refresh methods + def refresh_all(self): + self.refresh_quiz_info() + self.create_matrix() + self.refresh_category_editor() + self.refresh_question_editor() + + def refresh_quiz_info(self): + self.game_name_var.set(self.quiz.gameName) + self.tagline_var.set(self.quiz.tagline) + + def refresh_categories(self): + self.category_listbox.delete(0, tk.END) + for category in self.quiz.categories: + self.category_listbox.insert(tk.END, category.name) + + def refresh_questions(self): + self.question_listbox.delete(0, tk.END) + if self.current_category: + for question in self.current_category.questions: + display_text = f"${question.value}: {question.question[:50]}..." + self.question_listbox.insert(tk.END, display_text) + + def refresh_category_editor(self): + if self.current_category: + self.cat_name_var.set(self.current_category.name) + self.cat_color_canvas.config(bg=self.current_category.color.to_hex()) + else: + self.cat_name_var.set("") + self.cat_color_canvas.config(bg="white") + + def refresh_question_editor(self): + if self.current_question: + self.q_value_var.set(str(self.current_question.value)) + self.q_type_var.set(self.current_question.type) + + self.q_question_text.delete("1.0", tk.END) + self.q_question_text.insert("1.0", self.current_question.question) + + self.q_answer_text.delete("1.0", tk.END) + self.q_answer_text.insert("1.0", self.current_question.answer) + + self.q_note_text.delete("1.0", tk.END) + self.q_note_text.insert("1.0", self.current_question.note or "") + + self.q_media_var.set(self.current_question.questionMediaPath or "") + self.q_answer_media_var.set(self.current_question.answerMediaPath or "") + + self.q_color_canvas.config(bg=self.current_question.questionColor.to_hex()) + else: + self.q_value_var.set("") + self.q_type_var.set("text") + self.q_question_text.delete("1.0", tk.END) + self.q_answer_text.delete("1.0", tk.END) + self.q_note_text.delete("1.0", tk.END) + self.q_media_var.set("") + self.q_answer_media_var.set("") + self.q_color_canvas.config(bg="white") + + def run(self): + self.root.mainloop() + +if __name__ == "__main__": + app = JeopardyEditor() + app.run() diff --git a/hackerjeopardy_editor/models.py b/hackerjeopardy_editor/models.py new file mode 100644 index 00000000..d5694f46 --- /dev/null +++ b/hackerjeopardy_editor/models.py @@ -0,0 +1,129 @@ +import json +from dataclasses import dataclass, asdict +from typing import List, Optional + +@dataclass +class Color: + r: float = 0.0 + g: float = 0.0 + b: float = 1.0 + a: float = 1.0 + + def to_hex(self): + """Convert to hex color for tkinter color picker""" + r = int(self.r * 255) + g = int(self.g * 255) + b = int(self.b * 255) + return f"#{r:02x}{g:02x}{b:02x}" + + @classmethod + def from_hex(cls, hex_color): + """Create Color from hex string""" + hex_color = hex_color.lstrip('#') + r = int(hex_color[0:2], 16) / 255.0 + g = int(hex_color[2:4], 16) / 255.0 + b = int(hex_color[4:6], 16) / 255.0 + return cls(r, g, b, 1.0) + +@dataclass +class Question: + value: int = 100 + type: str = "text" + question: str = "" + answer: str = "" + questionMediaPath: Optional[str] = None + answerMediaPath: Optional[str] = None + note: Optional[str] = "" + questionColor: Color = None + + def __post_init__(self): + if self.questionColor is None: + self.questionColor = Color(0.2, 0.2, 0.8, 1.0) + + def validate(self): + """Validate question data""" + errors = [] + if not self.question.strip(): + errors.append("Question text is required") + if not self.answer.strip(): + errors.append("Answer text is required") + if self.value <= 0: + errors.append("Question value must be positive") + return errors + +@dataclass +class Category: + name: str = "New Category" + color: Color = None + questions: List[Question] = None + + def __post_init__(self): + if self.color is None: + self.color = Color(0.0, 0.5, 1.0, 1.0) + if self.questions is None: + self.questions = [] + + def validate(self): + """Validate category data""" + errors = [] + if not self.name.strip(): + errors.append("Category name is required") + return errors + +@dataclass +class Quiz: + gameName: str = "New Quiz" + tagline: str = "Enter tagline here" + categories: List[Category] = None + + def __post_init__(self): + if self.categories is None: + self.categories = [] + + def validate(self): + """Validate quiz data""" + errors = [] + if not self.gameName.strip(): + errors.append("Game name is required") + if not self.tagline.strip(): + errors.append("Tagline is required") + return errors + + def to_dict(self): + """Convert to dictionary for JSON serialization""" + return asdict(self) + + @classmethod + def from_dict(cls, data): + """Create Quiz from dictionary (JSON data)""" + # Convert color dictionaries back to Color objects + categories = [] + for cat_data in data.get('categories', []): + # Convert category color + if 'color' in cat_data and cat_data['color']: + cat_data['color'] = Color(**cat_data['color']) + + # Convert question colors + questions = [] + for q_data in cat_data.get('questions', []): + if 'questionColor' in q_data and q_data['questionColor']: + q_data['questionColor'] = Color(**q_data['questionColor']) + questions.append(Question(**q_data)) + + cat_data['questions'] = questions + categories.append(Category(**cat_data)) + + data['categories'] = categories + return cls(**data) + + def save_to_file(self, filepath): + """Save quiz to JSON file""" + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) + + @classmethod + def load_from_file(cls, filepath): + """Load quiz from JSON file""" + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + return cls.from_dict(data) diff --git a/hackerjeopardy_editor/yeswecan.json b/hackerjeopardy_editor/yeswecan.json new file mode 100644 index 00000000..119822e1 --- /dev/null +++ b/hackerjeopardy_editor/yeswecan.json @@ -0,0 +1,215 @@ +{ + "gameName": "Security Fundamentals", + "tagline": "", + "categories": [ + { + "name": "Passwords", + "color": { + "r": 1.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "questions": [ + { + "value": 100, + "type": "text", + "question": "What is the recommended minimum password length?", + "answer": "12 characters", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Some sources say 8, but 12 is better practice", + "questionColor": { + "r": 0.8, + "g": 0.2, + "b": 0.2, + "a": 1.0 + } + }, + { + "value": 200, + "type": "text", + "question": "What type of attack tries many password combinations automatically?", + "answer": "Brute force attack", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Dictionary attacks are a subset of brute force", + "questionColor": { + "r": 0.8, + "g": 0.2, + "b": 0.2, + "a": 1.0 + } + }, + { + "value": 300, + "type": "text", + "question": "What authentication method uses something you know, have, and are?", + "answer": "Multi-factor authentication (MFA)", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Also called 2FA when using two factors", + "questionColor": { + "r": 0.8, + "g": 0.2, + "b": 0.2, + "a": 1.0 + } + } + ] + }, + { + "name": "Network Security", + "color": { + "r": 0.0, + "g": 0.8, + "b": 0.0, + "a": 1.0 + }, + "questions": [ + { + "value": 100, + "type": "text", + "question": "What port does HTTPS typically use?", + "answer": "443", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "HTTP uses port 80, HTTPS uses 443", + "questionColor": { + "r": 0.2, + "g": 0.6, + "b": 0.2, + "a": 1.0 + } + }, + { + "value": 200, + "type": "text", + "question": "What does VPN stand for?", + "answer": "Virtual Private Network", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Creates encrypted tunnel over public networks", + "questionColor": { + "r": 0.2, + "g": 0.6, + "b": 0.2, + "a": 1.0 + } + }, + { + "value": 300, + "type": "text", + "question": "What type of attack intercepts communications between two parties?", + "answer": "Man-in-the-middle attack", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Also abbreviated as MITM attack", + "questionColor": { + "r": 0.2, + "g": 0.6, + "b": 0.2, + "a": 1.0 + } + } + ] + }, + { + "name": "Malware", + "color": { + "r": 0.5, + "g": 0.0, + "b": 0.8, + "a": 1.0 + }, + "questions": [ + { + "value": 100, + "type": "text", + "question": "What type of malware encrypts files and demands payment?", + "answer": "Ransomware", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Famous examples: WannaCry, CryptoLocker", + "questionColor": { + "r": 0.4, + "g": 0.1, + "b": 0.6, + "a": 1.0 + } + }, + { + "value": 200, + "type": "text", + "question": "What malware appears legitimate but contains malicious code?", + "answer": "Trojan horse", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Named after the Greek mythology story", + "questionColor": { + "r": 0.4, + "g": 0.1, + "b": 0.6, + "a": 1.0 + } + }, + { + "value": 300, + "type": "text", + "question": "What type of malware spreads automatically across networks?", + "answer": "Worm", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "Unlike viruses, worms don't need host files", + "questionColor": { + "r": 0.4, + "g": 0.1, + "b": 0.6, + "a": 1.0 + } + } + ] + }, + { + "name": "Cloud", + "color": { + "r": 0.0, + "g": 0.5, + "b": 1.0, + "a": 1.0 + }, + "questions": [ + { + "value": 100, + "type": "text", + "question": "Hetzner, netcup, Amazon", + "answer": "What is a cloud provider", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "List of cloud providers", + "questionColor": { + "r": 0.2, + "g": 0.2, + "b": 0.8, + "a": 1.0 + } + }, + { + "value": 200, + "type": "text", + "question": "", + "answer": "", + "questionMediaPath": null, + "answerMediaPath": null, + "note": "", + "questionColor": { + "r": 0.2, + "g": 0.2, + "b": 0.8, + "a": 1.0 + } + } + ] + } + ] +} \ No newline at end of file From 447314fd330b086e6189df20e42dfb7235c6ab8b Mon Sep 17 00:00:00 2001 From: Matthias Berse Date: Thu, 21 Aug 2025 21:02:29 +0200 Subject: [PATCH 2/3] Add support for jeopardy file extension --- hackerjeopardy_editor/README.md | 32 ++++++++- hackerjeopardy_editor/main.py | 32 ++++++++- hackerjeopardy_editor/models.py | 124 ++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/hackerjeopardy_editor/README.md b/hackerjeopardy_editor/README.md index af91f626..74dc7387 100644 --- a/hackerjeopardy_editor/README.md +++ b/hackerjeopardy_editor/README.md @@ -61,13 +61,16 @@ python3 main.py ### File Operations - **New**: File → New (creates blank quiz) -- **Open**: File → Open (loads existing JSON file) +- **Open**: File → Open (loads existing JSON or .jeopardy file) - **Save**: File → Save (saves to current file) - **Save As**: File → Save As (saves to new file) +- **Export as .jeopardy**: File → Export as .jeopardy (exports Unity-compatible format) -## JSON Format +## File Formats -The editor creates JSON files compatible with the Hacker Jeopardy Unity game. The format includes: +### JSON Format (.json) + +The editor's native format creates JSON files compatible with the Hacker Jeopardy Unity game. The format includes: ```json { @@ -77,6 +80,7 @@ The editor creates JSON files compatible with the Hacker Jeopardy Unity game. Th { "name": "Category Name", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}, + "hint": "Category hint text", "questions": [ { "value": 100, @@ -94,6 +98,28 @@ The editor creates JSON files compatible with the Hacker Jeopardy Unity game. Th } ``` +### Unity .jeopardy Format (.jeopardy) + +The editor also supports Unity's native .jeopardy format for seamless integration with the Unity game. This format uses a 3-line structure: + +``` +Game Name +Tagline +[{"categoryName":"Category","categoryColorR":255,"categoryColorG":0,"categoryColorB":0,"categoryHint":"Hint","questions":[...]}] +``` + +**Key differences from JSON format:** +- Line 1: Game name (plain text) +- Line 2: Tagline (plain text) +- Line 3+: JSON array of categories with Unity-specific field names +- Colors are RGB integers (0-255) instead of floats (0-1) +- Field names use Unity conventions (`categoryName` vs `name`, etc.) + +**Workflow Support:** +- **Import**: Open .jeopardy files created in Unity for editing +- **Export**: Save editor projects as .jeopardy files for Unity +- **Round-trip**: Full data integrity between editor and Unity formats + ## Tips - **Save Frequently**: Use Ctrl+S or File → Save to avoid losing work diff --git a/hackerjeopardy_editor/main.py b/hackerjeopardy_editor/main.py index e62fa0b9..51743518 100644 --- a/hackerjeopardy_editor/main.py +++ b/hackerjeopardy_editor/main.py @@ -26,6 +26,8 @@ def setup_ui(self): file_menu.add_command(label="Save", command=self.save_quiz) file_menu.add_command(label="Save As", command=self.save_quiz_as) file_menu.add_separator() + file_menu.add_command(label="Export as .jeopardy", command=self.export_jeopardy) + file_menu.add_separator() file_menu.add_command(label="Exit", command=self.root.quit) # Main layout - 3 panes @@ -113,6 +115,13 @@ def setup_category_editor(self): self.cat_color_canvas.pack(side=tk.LEFT, padx=(0, 5)) ttk.Button(color_frame, text="Choose Color", command=self.choose_category_color).pack(side=tk.LEFT) + # Category hint + ttk.Label(self.cat_edit_frame, text="Hint:").grid(row=2, column=0, sticky="w", padx=5, pady=5) + self.cat_hint_var = tk.StringVar() + self.cat_hint_entry = ttk.Entry(self.cat_edit_frame, textvariable=self.cat_hint_var, width=30) + self.cat_hint_entry.grid(row=2, column=1, sticky="ew", padx=5, pady=5) + self.cat_hint_var.trace('w', self.on_category_changed) + self.cat_edit_frame.columnconfigure(1, weight=1) def setup_question_editor(self): @@ -201,11 +210,14 @@ def new_quiz(self): def open_quiz(self): filepath = filedialog.askopenfilename( title="Open Quiz File", - filetypes=[("JSON files", "*.json"), ("All files", "*.*")] + filetypes=[("JSON files", "*.json"), ("Jeopardy files", "*.jeopardy"), ("All files", "*.*")] ) if filepath: try: - self.quiz = Quiz.load_from_file(filepath) + if filepath.lower().endswith('.jeopardy'): + self.quiz = Quiz.load_from_jeopardy_file(filepath) + else: + self.quiz = Quiz.load_from_file(filepath) self.current_file = filepath self.current_category = None self.current_question = None @@ -238,6 +250,19 @@ def save_quiz_as(self): except Exception as e: messagebox.showerror("Error", f"Failed to save quiz: {str(e)}") + def export_jeopardy(self): + filepath = filedialog.asksaveasfilename( + title="Export as .jeopardy", + defaultextension=".jeopardy", + filetypes=[("Jeopardy files", "*.jeopardy"), ("All files", "*.*")] + ) + if filepath: + try: + self.quiz.save_to_jeopardy_file(filepath) + messagebox.showinfo("Success", "Quiz exported as .jeopardy successfully!") + except Exception as e: + messagebox.showerror("Error", f"Failed to export quiz: {str(e)}") + # Category operations def add_category(self): category = Category() @@ -275,6 +300,7 @@ def on_quiz_info_changed(self, *args): def on_category_changed(self, *args): if self.current_category: self.current_category.name = self.cat_name_var.get() + self.current_category.hint = self.cat_hint_var.get() self.create_matrix() # Refresh matrix to show new category name def on_question_changed(self, *args): @@ -495,9 +521,11 @@ def refresh_questions(self): def refresh_category_editor(self): if self.current_category: self.cat_name_var.set(self.current_category.name) + self.cat_hint_var.set(self.current_category.hint) self.cat_color_canvas.config(bg=self.current_category.color.to_hex()) else: self.cat_name_var.set("") + self.cat_hint_var.set("") self.cat_color_canvas.config(bg="white") def refresh_question_editor(self): diff --git a/hackerjeopardy_editor/models.py b/hackerjeopardy_editor/models.py index d5694f46..c599fd2d 100644 --- a/hackerjeopardy_editor/models.py +++ b/hackerjeopardy_editor/models.py @@ -56,6 +56,7 @@ class Category: name: str = "New Category" color: Color = None questions: List[Question] = None + hint: str = "" def __post_init__(self): if self.color is None: @@ -127,3 +128,126 @@ def load_from_file(cls, filepath): with open(filepath, 'r', encoding='utf-8') as f: data = json.load(f) return cls.from_dict(data) + + def to_jeopardy_format(self): + """Convert to Unity .jeopardy format""" + categories_data = [] + + for category in self.categories: + # Convert category to Unity format + cat_data = { + "categoryName": category.name, + "categoryColorR": int(category.color.r * 255), + "categoryColorG": int(category.color.g * 255), + "categoryColorB": int(category.color.b * 255), + "categoryHint": category.hint, + "questions": [] + } + + # Convert questions to Unity format + for question in category.questions: + q_data = { + "value": question.value, + "questionColorR": int(question.questionColor.r * 255), + "questionColorG": int(question.questionColor.g * 255), + "questionColorB": int(question.questionColor.b * 255), + "PresentationType": 0 if question.type == "text" else 1, # 0=text, 1=media + "questionText": question.question, + "questionImage": question.questionMediaPath, + "questionVideo": None, # Unity format has separate image/video fields + "answerImage": question.answerMediaPath, + "answerVideo": None, + "answer": question.answer, + "isAvailable": True, + "questionNote": question.note or "" + } + cat_data["questions"].append(q_data) + + categories_data.append(cat_data) + + return categories_data + + def save_to_jeopardy_file(self, filepath): + """Save quiz to Unity .jeopardy file format""" + categories_data = self.to_jeopardy_format() + + with open(filepath, 'w', encoding='utf-8') as f: + # Line 1: Game name + f.write(self.gameName + '\n') + # Line 2: Tagline + f.write(self.tagline + ' \n') + # Line 3+: Categories JSON array + json.dump(categories_data, f, separators=(',', ':'), ensure_ascii=False) + + @classmethod + def from_jeopardy_format(cls, game_name, tagline, categories_data): + """Create Quiz from Unity .jeopardy format data""" + categories = [] + + for cat_data in categories_data: + # Convert Unity category format to internal format + color = Color( + r=cat_data.get("categoryColorR", 0) / 255.0, + g=cat_data.get("categoryColorG", 0) / 255.0, + b=cat_data.get("categoryColorB", 255) / 255.0, + a=1.0 + ) + + questions = [] + for q_data in cat_data.get("questions", []): + # Convert Unity question format to internal format + question_color = Color( + r=q_data.get("questionColorR", 0) / 255.0, + g=q_data.get("questionColorG", 0) / 255.0, + b=q_data.get("questionColorB", 255) / 255.0, + a=1.0 + ) + + # Determine media path (Unity has separate image/video fields) + question_media = q_data.get("questionImage") or q_data.get("questionVideo") + answer_media = q_data.get("answerImage") or q_data.get("answerVideo") + + question = Question( + value=q_data.get("value", 100), + type="text" if q_data.get("PresentationType", 0) == 0 else "media", + question=q_data.get("questionText", ""), + answer=q_data.get("answer", ""), + questionMediaPath=question_media, + answerMediaPath=answer_media, + note=q_data.get("questionNote", ""), + questionColor=question_color + ) + questions.append(question) + + category = Category( + name=cat_data.get("categoryName", "Unnamed Category"), + color=color, + questions=questions, + hint=cat_data.get("categoryHint", "") + ) + categories.append(category) + + return cls( + gameName=game_name, + tagline=tagline, + categories=categories + ) + + @classmethod + def load_from_jeopardy_file(cls, filepath): + """Load quiz from Unity .jeopardy file""" + with open(filepath, 'r', encoding='utf-8') as f: + lines = f.readlines() + + if len(lines) < 3: + raise ValueError("Invalid .jeopardy file format: must have at least 3 lines") + + # Parse the 3-line format + game_name = lines[0].strip() + tagline = lines[1].strip() + categories_json = ''.join(lines[2:]).strip() + + # Parse categories JSON + categories_data = json.loads(categories_json) + + return cls.from_jeopardy_format(game_name, tagline, categories_data) From c2b3961a8569aef740d083d9b9b306b6c2b5ef39 Mon Sep 17 00:00:00 2001 From: Matthias Berse Date: Wed, 10 Dec 2025 08:45:13 +0100 Subject: [PATCH 3/3] Remove binary file --- .gitignore | 2 ++ .../__pycache__/models.cpython-313.pyc | Bin 7096 -> 0 bytes 2 files changed, 2 insertions(+) create mode 100644 .gitignore delete mode 100644 hackerjeopardy_editor/__pycache__/models.cpython-313.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..36fa0f31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.pyc +__pycache__ diff --git a/hackerjeopardy_editor/__pycache__/models.cpython-313.pyc b/hackerjeopardy_editor/__pycache__/models.cpython-313.pyc deleted file mode 100644 index f269bd1d9f600684241e28ec56aafe2db49916f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7096 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