diff options
63 files changed, 8095 insertions, 88 deletions
diff --git a/.aider.chat.history.md b/.aider.chat.history.md new file mode 100644 index 0000000..1492d30 --- /dev/null +++ b/.aider.chat.history.md @@ -0,0 +1,7 @@ + +# aider chat started at 2025-04-03 14:47:33 + +> Newer aider version v0.80.2 is available. +> /Users/eli/.local/share/uv/tools/aider-chat/bin/python -m pip install --upgrade --upgrade-strategy only-if-needed aider-chat +> Run pip install? (Y)es/(N)o [Yes]: y +> Re-run aider to use new version. diff --git a/.aider.input.history b/.aider.input.history new file mode 100644 index 0000000..ee84a20 --- /dev/null +++ b/.aider.input.history @@ -0,0 +1,3 @@ + +# 2025-04-03 14:47:41.084132 ++Y diff --git a/awk/scheme/scheme/TODO.txt b/awk/scheme/scheme/TODO.txt new file mode 100644 index 0000000..31723a4 --- /dev/null +++ b/awk/scheme/scheme/TODO.txt @@ -0,0 +1,69 @@ +Scheme Interpreter TODO +===================== + +Current State: +------------- +- Basic arithmetic operations (+,-,*,/) are working +- Let expressions with simple bindings are working +- Function definitions (define) and lambda expressions are partially implemented +- Stack-based virtual machine with environment for variable bindings + +Recent Changes: +-------------- +1. Fixed function table array naming conflicts (func_name -> func_def_names) +2. Modified vm_get_value to handle function names correctly +3. Attempted to fix argument handling in function calls +4. Modified lambda compilation to avoid pushing function name twice + +Current Issues: +-------------- +1. Function Definitions (define): + - Function calls are failing with "ADD requires numeric operands" + - Arguments may not be properly passed to functions + - Function environment may not be properly set up + +2. Lambda Expressions: + - Direct lambda calls are failing + - Lambda bindings in let expressions are failing + - Function environment for lambda parameters may not be correct + +Next Steps: +----------- +1. Debug Function Calls: + - Add detailed debug logging in vm_call_function + - Verify argument handling and environment setup + - Check if function code is being properly stored and retrieved + +2. Fix Function Environment: + - Review how parameters are stored in the environment + - Ensure environment is properly restored after function calls + - Verify parameter values are correctly passed to functions + +3. Improve Lambda Support: + - Review lambda compilation process + - Ensure lambda functions are properly stored and called + - Fix environment handling for lambda parameters + +4. Testing Strategy: + - Create test cases for each type of function call + - Add debug output to track stack and environment state + - Verify each step of function compilation and execution + +5. Code Cleanup: + - Review and document function handling code + - Ensure consistent naming and error handling + - Add comments explaining complex operations + +Technical Notes: +--------------- +- Function calls use a combination of LOOKUP, GET_VALUE, and CALL instructions +- Environment stack handles variable bindings and function parameters +- Function code is stored in FUNCTIONS table with unique names +- Lambda functions use __lambda_N naming scheme + +Debugging Tips: +-------------- +1. Enable DEBUG=1 to see detailed execution logs +2. Check stack contents before and after each operation +3. Verify environment state during function calls +4. Monitor function code storage and retrieval \ No newline at end of file diff --git a/awk/scheme/scheme/bin/compiler.awk b/awk/scheme/scheme/bin/compiler.awk index db48388..debaa2c 100755 --- a/awk/scheme/scheme/bin/compiler.awk +++ b/awk/scheme/scheme/bin/compiler.awk @@ -232,17 +232,36 @@ function compile_primitive_call(op, args, arg_array, nargs, i) { debug("Primitive call: op=" op " args=" args) nargs = split_args(args, arg_array) - # First compile all arguments - for (i = 1; i <= nargs; i++) { - compile_expr(arg_array[i]) + # Check if this is a lambda function call + if (substr(op, 1, 1) == "(") { + # This is a lambda function call + # First compile the lambda function + compile_expr(op) + + # Then compile all arguments + for (i = 1; i <= nargs; i++) { + compile_expr(arg_array[i]) + } + + # Call the function + print "CALL __lambda_" (next_label - 1) + return } # Then emit appropriate operation if (op == "+") { + # Compile arguments + for (i = 1; i <= nargs; i++) { + compile_expr(arg_array[i]) + } for (i = 1; i < nargs; i++) print "ADD" } else if (op == "-") { + # Compile arguments + for (i = 1; i <= nargs; i++) { + compile_expr(arg_array[i]) + } if (nargs == 1) { print "PUSH_CONST N:0" print "SWAP" @@ -251,41 +270,71 @@ function compile_primitive_call(op, args, arg_array, nargs, i) { print "SUB" } else if (op == "*") { + # Compile arguments + for (i = 1; i <= nargs; i++) { + compile_expr(arg_array[i]) + } for (i = 1; i < nargs; i++) print "MUL" } else if (op == "cons") { if (nargs != 2) error("cons requires 2 arguments") + # Compile arguments + for (i = 1; i <= nargs; i++) { + compile_expr(arg_array[i]) + } print "CONS" } else if (op == "car") { if (nargs != 1) error("car requires 1 argument") + # Compile argument + compile_expr(arg_array[1]) print "CAR" } else if (op == "cdr") { if (nargs != 1) error("cdr requires 1 argument") + # Compile argument + compile_expr(arg_array[1]) print "CDR" } else if (op == "<") { if (nargs != 2) error("< requires 2 arguments") + # Compile arguments + for (i = 1; i <= nargs; i++) { + compile_expr(arg_array[i]) + } print "LT" } else if (op == "=") { if (nargs != 2) error("= requires 2 arguments") + # Compile arguments + for (i = 1; i <= nargs; i++) { + compile_expr(arg_array[i]) + } print "EQ" } else { # Function call for user-defined functions debug("Function call: " op) - print "CALL " op + # Look up the function name + print "LOOKUP " op + # Get the actual function name + print "GET_VALUE" + # Then compile arguments + for (i = 1; i <= nargs; i++) { + compile_expr(arg_array[i]) + } + # Call the function + print "CALL" } } # Splits let bindings into individual variable/value pairs -function split_bindings(bindings, binding_array, count, current, paren_count, i, c) { +function split_bindings(bindings, binding_array, count, current, paren_count, i, c, in_lambda) { count = 0 current = "" paren_count = 0 + in_lambda = 0 for (i = 1; i <= length(bindings); i++) { c = substr(bindings, i, 1) @@ -293,14 +342,14 @@ function split_bindings(bindings, binding_array, count, current, paren_count, i, # Track nested parentheses if (c == "(") { paren_count++ - if (paren_count == 1) { + if (paren_count == 1 && !in_lambda) { current = "" # Start new binding continue } } if (c == ")") { paren_count-- - if (paren_count == 0) { + if (paren_count == 0 && !in_lambda) { # End of binding binding_array[++count] = current current = "" @@ -308,6 +357,11 @@ function split_bindings(bindings, binding_array, count, current, paren_count, i, } } + # Track if we're inside a lambda expression + if (substr(bindings, i, 7) == "lambda ") { + in_lambda = 1 + } + # Only add character if we're inside a binding if (paren_count > 0) { current = current c @@ -346,17 +400,34 @@ function compile_let(args, bindings, body, binding_array, nbindings, i, var, val nbindings = split_bindings(bindings, binding_array) for (i = 1; i <= nbindings; i++) { debug("Processing binding: " binding_array[i]) - split(binding_array[i], binding_parts, " ") - var = binding_parts[1] - val = binding_parts[2] + + # Find the variable name (everything up to the first space) + var = binding_array[i] + sub(/ .*$/, "", var) + + # Find the value (everything after the first space) + val = binding_array[i] + sub(/^[^ ]+ /, "", val) debug("Binding var: " var " val: " val) # Compile the value - compile_expr(val) - - # Store in environment - print "STORE " var + if (substr(val, 1, 1) == "(") { + # Handle lambda or other compound expressions + if (substr(val, 2, 6) == "lambda") { + # This is a lambda expression + # First compile the lambda + compile_lambda(substr(val, 8)) # Skip "(lambda " + # Store the function name in the environment + print "STORE " var + } else { + compile_expr(val) + print "STORE " var + } + } else { + compile_expr(val) + print "STORE " var + } } # Compile the body @@ -420,6 +491,58 @@ function compile_define(args, name, params, body, param_array, nparams, i, paren } } +# Compiles lambda expressions (anonymous functions) +function compile_lambda(args, params, body, param_array, nparams, i, lambda_name) { + # Generate a unique name for the lambda function + lambda_name = "__lambda_" next_label++ + + # Split into parameters and body + if (substr(args, 1, 1) != "(") error("Malformed lambda expression") + + # Find matching closing parenthesis for parameters + paren_count = 1 + i = 2 + while (paren_count > 0 && i <= length(args)) { + if (substr(args, i, 1) == "(") paren_count++ + if (substr(args, i, 1) == ")") paren_count-- + i++ + } + if (paren_count > 0) error("Unmatched parenthesis in lambda parameters") + + params = substr(args, 2, i - 3) # Remove parentheses + body = substr(args, i) + + # Trim whitespace from body + sub(/^[ \t\n]+/, "", body) + sub(/[ \t\n]+$/, "", body) + + debug("Lambda parameters: " params) + debug("Lambda body: " body) + + # Create function label + print "LABEL " lambda_name + + # Process parameters + nparams = split(params, param_array, " ") + for (i = 1; i <= nparams; i++) { + print "STORE " param_array[i] + } + + # Compile function body + compile_expr(body) + + # Clean up parameters and return + for (i = nparams; i >= 1; i--) { + print "POP_ENV" + } + print "RETURN" + + # Only push the function name if we're not in a direct call + if (!(args ~ /^\([^)]*\)[^(]*$/)) { + print "PUSH_CONST S:" lambda_name + } +} + # Main expression compiler - dispatches based on expression type function compile_expr(expr, split_result, op, args) { debug("Compiling expression: " expr) @@ -453,6 +576,8 @@ function compile_expr(expr, split_result, op, args) { compile_define(args) } else if (op == "let") { compile_let(args) + } else if (op == "lambda") { + compile_lambda(args) } else { compile_primitive_call(op, args) } diff --git a/awk/scheme/scheme/bin/vm.awk b/awk/scheme/scheme/bin/vm.awk index 2c56fda..4e7d2c7 100755 --- a/awk/scheme/scheme/bin/vm.awk +++ b/awk/scheme/scheme/bin/vm.awk @@ -28,10 +28,10 @@ BEGIN { env_size = 0 # Current size of environment stack # Function table for storing defined functions - delete func_name # Function names - delete func_pc # Entry points - delete func_code # Function bodies - func_size = 0 # Number of defined functions + delete func_def_names # Function names + delete func_def_pc # Entry points + delete func_def_code # Function bodies + func_def_size = 0 # Number of defined functions # Call stack for function returns call_stack_ptr = 0 @@ -54,9 +54,9 @@ BEGIN { debug("Code: " code) # Store function in function table - func_name[func_size] = name - func_code[func_size] = code - func_size++ + func_def_names[func_def_size] = name + func_def_code[func_def_size] = code + func_def_size++ } } while ((getline line < STATE_FILE) > 0) close(STATE_FILE) @@ -97,6 +97,16 @@ BEGIN { } } + # Register built-in functions + FUNCTIONS["+"] = "add" + FUNCTIONS["-"] = "subtract" + FUNCTIONS["*"] = "multiply" + FUNCTIONS["/"] = "divide" + FUNCTIONS["="] = "equals" + FUNCTIONS["<"] = "less_than" + FUNCTIONS[">"] = "greater_than" + FUNCTIONS["add1"] = "add_one" + # Track if VM halted normally (vs error) normal_exit = 0 } @@ -353,6 +363,9 @@ function execute(instr) { else if (op == "RETURN") { vm_return() } + else if (op == "GET_VALUE") { + vm_get_value() + } else { error("Unknown instruction: " op) } @@ -404,10 +417,26 @@ function vm_store(name) { } } + # Handle lambda functions + val = peek() + if (isSymbol(val)) { + func_name = getValue(val) + if (func_name ~ /^__lambda_/) { + # Store the function code under the new name + FUNCTIONS[name] = FUNCTIONS[func_name] + # Store the new name in the environment + env_name[env_size] = name + env_val[env_size] = makeValue(T_SYMBOL, name) + env_size++ + return + } + } + # Add to environment env_name[env_size] = name env_val[env_size] = peek() env_size++ + debug("Environment after store:") dump_env() } @@ -428,20 +457,22 @@ function vm_pop_env() { } # Variable lookup implementation -function vm_lookup(name, i, global_name) { +function vm_lookup(name, i, global_name, val) { debug("Looking up " name " in environment of size: " env_size) dump_env() + # Check if it's a function (built-in or user-defined) + if (name in FUNCTIONS) { + debug("Found function: " name) + push(makeValue(T_SYMBOL, name)) + return + } + # Try global name first, then local global_name = "__global_" name for (i = env_size - 1; i >= 0; i--) { - if (env_name[i] == global_name) { - debug("Found global " name " = " env_val[i] " at position " i) - push(env_val[i]) - return - } - if (env_name[i] == name) { - debug("Found local " name " = " env_val[i] " at position " i) + if (env_name[i] == global_name || env_name[i] == name) { + debug("Found " name " = " env_val[i] " at position " i) push(env_val[i]) return } @@ -471,44 +502,56 @@ function vm_define_function(name, start_pc) { } # Function call implementation -function vm_call_function(name, code_lines, j, saved_pc, saved_env_size, arg, param_name) { - debug("Calling function: " name) +function vm_call_function(func_name, code_lines, j, saved_pc, saved_env_size, arg, param_name) { + debug("Calling function: " func_name) - if (!(name in FUNCTIONS)) { - error("Undefined function: " name) + # If name is a symbol, get its value + if (isSymbol(func_name)) { + func_name = getValue(func_name) } - # Get argument before modifying program - arg = pop() - debug("Function argument: " arg) + # Handle anonymous functions + if (func_name ~ /^__lambda_/) { + if (!(func_name in FUNCTIONS)) { + error("Undefined lambda function: " func_name) + } + } else if (!(func_name in FUNCTIONS)) { + error("Undefined function: " func_name) + } saved_pc = pc saved_env_size = env_size # Split function code into lines - split(FUNCTIONS[name], code_lines, "\n") + split(FUNCTIONS[func_name], code_lines, "\n") + + # Add function code to program at current position + for (j in code_lines) { + program[pc + j - 1] = code_lines[j] + } - # Get parameter name from STORE instruction + # Check if this is a parameterized function if (code_lines[1] ~ /^STORE /) { + # This is a parameterized function (lambda) + # Get parameter name from STORE instruction param_name = substr(code_lines[1], 7) debug("Found parameter name: " param_name) + + # Get argument from stack + arg = pop() + debug("Function argument: " arg) + + # Create new environment frame + debug("Creating new environment frame at size: " env_size) + env_name[env_size] = param_name + env_val[env_size] = arg + env_size++ } else { - error("Function missing parameter definition") - } - - # Create new environment frame - debug("Creating new environment frame at size: " env_size) - env_name[env_size] = param_name - env_val[env_size] = arg - env_size++ - - # Add function code to program - for (j in code_lines) { - program[length(program)] = code_lines[j] + # This is a built-in function or non-parameterized function + debug("Calling non-parameterized function: " func_name) } # Save return info and jump to function - pc = length(program) - length(code_lines) call_stack[++call_stack_ptr] = saved_pc env_stack[call_stack_ptr] = saved_env_size @@ -559,9 +602,9 @@ function lookup_no_error(name, i) { # State persistence implementation function save_state() { debug("Saving state to: " STATE_FILE) - for (i = 0; i < func_size; i++) { - debug("Saving function: " func_name[i]) - print "FUNC " func_name[i] " " func_code[i] > STATE_FILE + for (i = 0; i < func_def_size; i++) { + debug("Saving function: " func_def_names[i]) + print "FUNC " func_def_names[i] " " func_def_code[i] > STATE_FILE } close(STATE_FILE) @@ -574,4 +617,91 @@ function save_state() { } } close(ENV_STATE_FILE) +} + +# Built-in function implementations +function equals() { + if (stack_ptr < 2) error("= requires two operands") + val2 = pop() + val1 = pop() + if (!isNumber(val1) || !isNumber(val2)) error("= requires numeric operands") + result = (getValue(val1) == getValue(val2)) ? 1 : 0 + push(makeValue(T_BOOLEAN, result)) +} + +function less_than() { + if (stack_ptr < 2) error("< requires two operands") + val2 = pop() + val1 = pop() + if (!isNumber(val1) || !isNumber(val2)) error("< requires numeric operands") + result = (getValue(val1) < getValue(val2)) ? 1 : 0 + push(makeValue(T_BOOLEAN, result)) +} + +function greater_than() { + if (stack_ptr < 2) error("> requires two operands") + val2 = pop() + val1 = pop() + if (!isNumber(val1) || !isNumber(val2)) error("> requires numeric operands") + result = (getValue(val1) > getValue(val2)) ? 1 : 0 + push(makeValue(T_BOOLEAN, result)) +} + +function add() { + if (stack_ptr < 2) error("+ requires two operands") + val2 = pop() + val1 = pop() + if (!isNumber(val1) || !isNumber(val2)) error("+ requires numeric operands") + result = getValue(val1) + getValue(val2) + push(makeValue(T_NUMBER, result)) +} + +function subtract() { + if (stack_ptr < 2) error("- requires two operands") + val2 = pop() + val1 = pop() + if (!isNumber(val1) || !isNumber(val2)) error("- requires numeric operands") + result = getValue(val1) - getValue(val2) + push(makeValue(T_NUMBER, result)) +} + +function multiply() { + if (stack_ptr < 2) error("* requires two operands") + val2 = pop() + val1 = pop() + if (!isNumber(val1) || !isNumber(val2)) error("* requires numeric operands") + result = getValue(val1) * getValue(val2) + push(makeValue(T_NUMBER, result)) +} + +function divide() { + if (stack_ptr < 2) error("/ requires two operands") + val2 = pop() + val1 = pop() + if (!isNumber(val1) || !isNumber(val2)) error("/ requires numeric operands") + if (getValue(val2) == 0) error("Division by zero") + result = getValue(val1) / getValue(val2) + push(makeValue(T_NUMBER, result)) +} + +function add_one() { + if (stack_ptr < 1) error("add1 requires one operand") + val = pop() + if (!isNumber(val)) error("add1 requires numeric operand") + result = getValue(val) + 1 + push(makeValue(T_NUMBER, result)) +} + +# Get value from top of stack +function vm_get_value() { + val = peek() + if (isSymbol(val)) { + name = getValue(val) + # If it's a function name, just push the name directly + if (name in FUNCTIONS) { + push(name) + } else { + push(makeValue(T_SYMBOL, name)) + } + } } \ No newline at end of file diff --git a/awk/scheme/scheme/examples/lambda.test.scm b/awk/scheme/scheme/examples/lambda.test.scm new file mode 100644 index 0000000..1f2bb09 --- /dev/null +++ b/awk/scheme/scheme/examples/lambda.test.scm @@ -0,0 +1,12 @@ +; Test lambda function support +((lambda (x) (+ x 1)) 41) ; Should return 42 + +; Test lambda with multiple parameters +((lambda (x y) (+ x y)) 20 22) ; Should return 42 + +; Test lambda in let expression +(let ((add1 (lambda (x) (+ x 1)))) + (add1 41)) ; Should return 42 + +; Test nested lambda +((lambda (x) ((lambda (y) (+ x y)) 1)) 41) ; Should return 42 \ No newline at end of file diff --git a/haskell/Adventure.hs b/haskell/Adventure.hs new file mode 100644 index 0000000..e8a504b --- /dev/null +++ b/haskell/Adventure.hs @@ -0,0 +1,480 @@ +-- Import necessary modules +import System.IO ( hFlush, stdout ) -- For flushing the output buffer +import Data.Char ( toLower ) -- For case-insensitive input parsing +import Data.List ( find, delete, intercalate ) -- Added intercalate for joining strings +import Control.Monad ( when, unless ) -- Added unless for conditional IO +import Data.Maybe ( catMaybes ) -- Added catMaybes for filtering Maybe lists + +-- ===== Data Types ===== +-- These define the structure of our game world and state. +-- Using algebraic data types (ADTs) is idiomatic Haskell. + +-- Represents the different locations in our game. +-- 'deriving (Show, Eq)' allows us to print and compare locations. +data Location + = EntranceHall -- Starting point + | DarkCorridor + | TreasureRoom + | Library + deriving (Show, Eq) + +-- Represents the items the player can interact with or collect. +-- 'deriving (Show, Eq, Ord)' allows printing, comparing, and ordering (useful for inventory consistency). +data Item + = Key + | Lantern + | Book + deriving (Show, Eq, Ord) + +-- Represents the overall state of the game world, including things +-- not carried by the player. +data WorldState = WorldState + { keyLocation :: Maybe Location -- Nothing if player has it, Just Location otherwise + , lanternLocation:: Maybe Location -- Nothing if player has it, Just Location otherwise + , bookLocation :: Maybe Location -- Nothing if player has it, Just Location otherwise + , chestLocked :: Bool -- Is the chest in the TreasureRoom locked? + } deriving (Show) -- Allows printing the WorldState (mostly for debugging) + +-- Represents the complete game state: player's location, inventory, and the world state. +-- This structure is immutable; functions will create *new* GameState values instead of modifying old ones. +data GameState = GameState + { currentLocation :: Location + , inventory :: [Item] -- Player's inventory (a list of Items) + , world :: WorldState -- The state of the rest of the world + } deriving (Show) -- Allows printing the GameState (mostly for debugging) + +-- Represents the commands the player can issue. +data Command + = Go Direction + | Get Item + | Drop Item + | Inventory + | Look + | Unlock Target + | Examine Item -- New command to examine items + | Read Target -- New command for reading things (like the book) + | Quit + | Unknown String -- To handle unrecognized input + deriving (Show, Eq) + +-- Represents directions for movement. +data Direction = North | South | East | West deriving (Show, Eq) + +-- Represents things that can be interacted with directly (besides items to Get/Drop). +data Target = Chest | BookTarget deriving (Show, Eq) -- Renamed Book to BookTarget to avoid name clash + +-- ===== Initial Game State ===== +-- Defines the starting configuration of the game. + +initialWorldState :: WorldState +initialWorldState = WorldState + { keyLocation = Just TreasureRoom -- Key starts in the treasure room + , lanternLocation = Just EntranceHall -- Lantern starts in the entrance hall + , bookLocation = Just Library -- Book starts in the library + , chestLocked = True -- Chest starts locked + } + +initialGameState :: GameState +initialGameState = GameState + { currentLocation = EntranceHall -- Player starts in the Entrance Hall + , inventory = [] -- Player starts with an empty inventory + , world = initialWorldState -- Use the initial world state + } + +-- ===== Game Logic ===== + +-- == Room Descriptions == +-- Functions that describe the current location based on the GameState. +-- Note how they take the *entire* GameState to potentially show different +-- descriptions based on world state or inventory. + +describeLocation :: GameState -> IO () +describeLocation state = do + putStrLn $ "--- " ++ show (currentLocation state) ++ " ---" + case currentLocation state of + EntranceHall -> describeEntranceHall state + DarkCorridor -> describeDarkCorridor state + TreasureRoom -> describeTreasureRoom state + Library -> describeLibrary state + putStrLn "" -- Add a blank line for spacing + listItemsInLocation state + listExits state + +describeEntranceHall :: GameState -> IO () +describeEntranceHall state = do + putStrLn "You are in a dusty Entrance Hall." + putStrLn "A dim light filters from a crack in the ceiling." + when (lanternLocation (world state) == Just EntranceHall) $ + putStrLn "An old brass Lantern sits on a pedestal." + putStrLn "To the north, you see a Dark Corridor." + putStrLn "To the east lies a Library." + +describeDarkCorridor :: GameState -> IO () +describeDarkCorridor state = do + if Lantern `elem` inventory state then do + putStrLn "Your Lantern pushes back the oppressive darkness." + putStrLn "This is a narrow corridor heading east and west." + putStrLn "The south exit leads back to the Entrance Hall." + putStrLn "To the north, you see a sturdy wooden door." + else do + putStrLn "It's pitch black here. You can't see a thing!" + putStrLn "You feel a wall to the south." + -- Player can't see exits or items without light. + +describeTreasureRoom :: GameState -> IO () +describeTreasureRoom state = do + putStrLn "You've entered a small Treasure Room!" + putStrLn "The air is thick with the smell of old wood and metal." + let w = world state + if chestLocked w then + putStrLn "In the center sits a large wooden Chest, clamped shut with a heavy lock." + else do + putStrLn "In the center sits a large wooden Chest. It is unlocked." + when (keyLocation w == Just TreasureRoom) $ + putStrLn "Inside the open chest, you see a shiny Brass Key." + + putStrLn "The only exit is back west into the Dark Corridor." + + +describeLibrary :: GameState -> IO () +describeLibrary state = do + putStrLn "You are in a quiet Library. Bookshelves line the walls." + putStrLn "Most books seem to have crumbled to dust." + when (bookLocation (world state) == Just Library) $ + putStrLn "One notable exception is a large, leather-bound Book on a reading stand." + putStrLn "The only exit is back west to the Entrance Hall." + + +-- Helper to list items visible in the current location +listItemsInLocation :: GameState -> IO () +listItemsInLocation state = do + let loc = currentLocation state + let w = world state + let itemsHere = catMaybes -- catMaybes removes Nothing values + [ if keyLocation w == Just loc then Just "Brass Key" else Nothing + , if lanternLocation w == Just loc then Just "Brass Lantern" else Nothing + , if bookLocation w == Just loc then Just "Leather Book" else Nothing + ] + unless (null itemsHere) $ -- unless is 'when (not ...)' + putStrLn $ "You see here: " ++ intercalate ", " itemsHere + putStrLn "" -- Extra newline + +-- Helper to list visible exits based on location and potentially state (like needing light) +listExits :: GameState -> IO () +listExits state = do + putStr "Possible exits: " + let exits = case currentLocation state of + EntranceHall -> ["North", "East"] + DarkCorridor -> if Lantern `elem` inventory state + then ["South", "North", "West"] -- Added West exit + else ["South"] -- Can only feel the way back + TreasureRoom -> ["West"] + Library -> ["West"] + putStrLn $ intercalate ", " exits + + +-- == Input Parsing == +-- Converts player's raw input string into a structured Command. + +parseCommand :: String -> Command +parseCommand input = + case words (map toLower input) of -- Split input into lower-case words + ["go", dir] -> parseDirection dir + ["n"] -> Go North + ["s"] -> Go South + ["e"] -> Go East + ["w"] -> Go West + ["get", item] -> parseGet item + ["take", item] -> parseGet item + ["drop", item] -> parseDrop item + ["inventory"] -> Inventory + ["i"] -> Inventory + ["look"] -> Look + ["l"] -> Look + ["unlock", target] -> parseUnlock target + ["examine", item] -> parseExamine item + ["read", target] -> parseRead target + ["quit"] -> Quit + [] -> Unknown "" -- Handle empty input + ws -> Unknown (unwords ws) -- Handle unrecognized input + +parseDirection :: String -> Command +parseDirection "north" = Go North +parseDirection "south" = Go South +parseDirection "east" = Go East +parseDirection "west" = Go West +parseDirection dir = Unknown ("go " ++ dir) + +parseGet :: String -> Command +parseGet "key" = Get Key +parseGet "lantern" = Get Lantern +parseGet "book" = Get Book +parseGet item = Unknown ("get " ++ item) + +parseDrop :: String -> Command +parseDrop "key" = Drop Key +parseDrop "lantern" = Drop Lantern +parseDrop "book" = Drop Book +parseDrop item = Unknown ("drop " ++ item) + +parseUnlock :: String -> Command +parseUnlock "chest" = Unlock Chest +-- Add other unlockable targets here if needed +parseUnlock target = Unknown ("unlock " ++ target) + +parseExamine :: String -> Command +parseExamine "key" = Examine Key +parseExamine "lantern" = Examine Lantern +parseExamine "book" = Examine Book +parseExamine item = Unknown ("examine " ++ item) + +parseRead :: String -> Command +parseRead "book" = Read BookTarget +parseRead target = Unknown ("read " ++ target) + +-- == Command Processing == +-- This is the core logic. It takes a command and the current state, +-- and returns the *new* state. This function is PURE - it has no side effects (like printing). +-- All I/O (printing messages) happens in the `gameLoop` based on the state transition. + +updateState :: Command -> GameState -> GameState +updateState cmd state = + case cmd of + Go dir -> go dir state + Get item -> getItem item state + Drop item -> dropItem item state + Unlock target -> unlockTarget target state + -- Commands that don't change state are handled directly in the loop + Inventory -> state + Look -> state + Examine _ -> state -- Doesn't change state, just prints info + Read _ -> state -- Doesn't change state, just prints info + Quit -> state -- Handled in the loop + Unknown _ -> state -- Handled in the loop + +-- Handle 'go' command +go :: Direction -> GameState -> GameState +go dir state@(GameState loc _ worldState) = + let maybeNewLoc = case (loc, dir) of + (EntranceHall, North) -> Just DarkCorridor + (EntranceHall, East) -> Just Library + (DarkCorridor, South) -> Just EntranceHall + (DarkCorridor, North) -> if Lantern `elem` inventory state + then Just TreasureRoom + else Nothing -- Can't navigate without light + (DarkCorridor, West) -> Nothing -- Wall or blocked path? Let's say wall for now. + (TreasureRoom, West) -> Just DarkCorridor + (Library, West) -> Just EntranceHall + _ -> Nothing -- Invalid direction from this location + in case maybeNewLoc of + Just newLoc -> state { currentLocation = newLoc } -- Return new state with updated location + Nothing -> state -- No state change if move is invalid + + +-- Handle 'get' command +getItem :: Item -> GameState -> GameState +getItem item state@(GameState loc inv w) = + let itemLoc = itemCurrentLocation item w + playerHasItem = item `elem` inv + in if not playerHasItem && itemLoc == Just loc then + -- Item is here and player doesn't have it: take it + state { inventory = item : inv -- Add item to inventory + , world = updateItemLocation item Nothing w -- Remove item from world location + } + else + -- Item not here, or player already has it: no change + state + +-- Handle 'drop' command +dropItem :: Item -> GameState -> GameState +dropItem item state@(GameState loc inv w) = + if item `elem` inv then + -- Player has the item: drop it + state { inventory = delete item inv -- Remove item from inventory + , world = updateItemLocation item (Just loc) w -- Place item in current location + } + else + -- Player doesn't have the item: no change + state + +-- Handle 'unlock' command +unlockTarget :: Target -> GameState -> GameState +unlockTarget target state@(GameState loc inv w) = + case target of + Chest -> + if loc == TreasureRoom && chestLocked w && Key `elem` inv then + -- In correct room, chest is locked, player has key: unlock + state { world = w { chestLocked = False } } + else + -- Conditions not met: no change + state + -- Add cases for other unlockable targets here + _ -> state -- Target not recognized or handled + + +-- == Helper functions for State Updates == + +-- Finds where an item is currently located in the world (or Nothing if player has it) +itemCurrentLocation :: Item -> WorldState -> Maybe Location +itemCurrentLocation Key w = keyLocation w +itemCurrentLocation Lantern w = lanternLocation w +itemCurrentLocation Book w = bookLocation w + +-- Returns an updated WorldState with the item's location changed. +-- This is a key functional pattern: create a *new* modified record. +updateItemLocation :: Item -> Maybe Location -> WorldState -> WorldState +updateItemLocation Key newLoc w = w { keyLocation = newLoc } +updateItemLocation Lantern newLoc w = w { lanternLocation = newLoc } +updateItemLocation Book newLoc w = w { bookLocation = newLoc } + + +-- == Output Feedback == +-- Functions to provide feedback to the player after a command is processed. +-- These functions compare the old state and the new state, or check conditions. + +provideFeedback :: Command -> GameState -> GameState -> IO () +provideFeedback cmd oldState newState = + case cmd of + Go dir -> + if currentLocation oldState == currentLocation newState then + -- Location didn't change, must be an invalid move + case (currentLocation oldState, dir) of + (DarkCorridor, North) | Lantern `notElem` inventory oldState -> + putStrLn "It's too dark to see the way north." + _ -> putStrLn "You can't go that way." + else + -- Location changed, describe the new location (handled by the main loop) + return () -- No extra message needed here + + Get item -> + let itemLoc = itemCurrentLocation item (world oldState) + playerHadItem = item `elem` inventory oldState + playerHasItem = item `elem` inventory newState + in if not playerHadItem && playerHasItem then + putStrLn $ "You take the " ++ show item ++ "." + else if itemLoc /= Just (currentLocation oldState) then + putStrLn "You don't see that item here." + else -- Only other case is player already had it + putStrLn "You already have that!" + + Drop item -> + let playerHadItem = item `elem` inventory oldState + playerHasItem = item `elem` inventory newState + in if playerHadItem && not playerHasItem then + putStrLn $ "You drop the " ++ show item ++ "." + else + putStrLn "You aren't carrying that item." + + Unlock target -> + case target of + Chest -> + let oldLocked = chestLocked (world oldState) + newLocked = chestLocked (world newState) + in if currentLocation oldState /= TreasureRoom then + putStrLn "You don't see a chest here." + else if oldLocked && not newLocked then + putStrLn "You unlock the chest with the key." + else if not oldLocked then + putStrLn "The chest is already unlocked." + else if Key `notElem` inventory oldState then + putStrLn "You don't have the key." + else -- Should not happen if logic is correct + putStrLn "You can't unlock that." + + Inventory -> + if null (inventory newState) then + putStrLn "You are not carrying anything." + else do + putStrLn "You are carrying:" + mapM_ (putStrLn . (" - " ++ ) . show) (inventory newState) -- Print each item + + Look -> return () -- Description is handled by the main loop calling describeLocation + + Examine item -> examineItem item oldState -- Use oldState as examine doesn't change state + + Read target -> readTarget target oldState -- Use oldState as read doesn't change state + + Quit -> putStrLn "Goodbye!" + + Unknown "" -> putStrLn "Please enter a command." + Unknown s -> putStrLn $ "I don't understand '" ++ s ++ "'." + + +-- Handle 'examine' command - Provides descriptions of items +examineItem :: Item -> GameState -> IO () +examineItem item state = + if item `elem` inventory state || itemCurrentLocation item (world state) == Just (currentLocation state) + then case item of + Key -> putStrLn "It's a small, ornate brass key. Looks important." + Lantern -> putStrLn "It's a sturdy brass lantern. It might light up dark places." + Book -> putStrLn "A large, heavy book bound in worn leather. The pages look brittle." + else + putStrLn "You don't see that item here to examine." + +-- Handle 'read' command +readTarget :: Target -> GameState -> IO () +readTarget target state = + case target of + BookTarget -> + let bookIsHere = bookLocation (world state) == Just (currentLocation state) + playerHasBook = Book `elem` inventory state + in if currentLocation state == Library && (bookIsHere || playerHasBook) then + do + putStrLn "You open the ancient book. Most pages are illegible, but one entry catches your eye:" + putStrLn "\"Where light fails, the hidden path reveals the reward.\"" -- Hint for DarkCorridor/TreasureRoom + else + putStrLn "There is nothing here to read." + _ -> putStrLn "You can't read that." + + +-- ===== Main Game Loop ===== +-- This function handles the interaction cycle: display state, get input, process, repeat. +-- It uses recursion to loop, passing the *new* state to the next iteration. + +gameLoop :: GameState -> IO () +gameLoop currentState = do + -- 1. Describe the current situation + describeLocation currentState + putStrLn "" -- Blank line before prompt + + -- 2. Prompt for input + putStr "> " + hFlush stdout -- Ensure '>' appears before waiting for input + + -- 3. Get user input + input <- getLine + + -- 4. Parse the input into a command + let command = parseCommand input + + -- 5. Check for Quit command + if command == Quit then + provideFeedback command currentState currentState -- Print goodbye message + else do + -- 6. Process the command to get the potential new state + let newState = updateState command currentState + + -- 7. Provide feedback based on the command and state change + provideFeedback command currentState newState + + -- 8. Loop with the new state + gameLoop newState + +-- ===== Entry Point ===== +-- The `main` function starts the game. + +main :: IO () +main = do + putStrLn "Welcome to the Haskell Adventure!" + putStrLn "Type 'look' to examine your surroundings, 'inventory' to see what you carry." + putStrLn "Commands: go [north/south/east/west], get [item], drop [item], unlock [target], examine [item], read [target], quit" + putStrLn "======================================================================================" + gameLoop initialGameState + + +-- ===== Helper Imports (already included at top, listed here for clarity) ===== +-- import System.IO ( hFlush, stdout ) +-- import Data.Char ( toLower ) +-- import Data.List ( find, delete, intercalate ) -- Added intercalate +-- import Control.Monad ( when, unless ) -- Added unless +-- import Data.Maybe ( catMaybes ) -- Added catMaybes \ No newline at end of file diff --git a/html/flexagon/TODO.txt b/html/flexagon/TODO.txt new file mode 100644 index 0000000..b9c9160 --- /dev/null +++ b/html/flexagon/TODO.txt @@ -0,0 +1,89 @@ +HEXAHEXAFLEXAGON IMPLEMENTATION STATUS & TODO +========================================= + +CURRENT IMPLEMENTATION: +--------------------- +- Core mathematical model for a hexahexaflexagon with 19 equilateral triangles +- 3D transformation utilities (rotation matrices, point transformation) +- Animation system with easing functions +- Basic rendering system with debug visualization +- Mouse interaction handling + +WHAT WE'VE TRIED: +--------------- +1. Initial Rendering Debug Steps: + - Added light gray background for visibility + - Drew coordinate axes (red for X, green for Y) + - Made faces semi-transparent (alpha 0.7) + - Added face numbers for identification + - Disabled face culling temporarily + - Added initial rotation transforms (PI/6 for both X and Y) + - Increased scale to 100 for better visibility + +2. State Management: + - Implemented state tracking for faces, transforms, and animation + - Added debug logging for state changes + - Simplified initial state creation + +CURRENT ISSUES: +------------- +1. Visibility: + - Faces are being created but not visible (visible faces count = 0) + - Transform matrix calculations may not be working as expected + - Initial positioning might be incorrect + +2. Triangle Strip: + - Need to verify the initial strip creation geometry + - Connection between triangles needs review + - Pat pattern implementation might need adjustment + +NEXT STEPS TO TRY: +---------------- +1. Geometry Verification: + - Add debug logging for triangle vertices during creation + - Verify triangle dimensions and connections + - Add visual markers for triangle orientation + +2. Transform Pipeline: + - Add step-by-step logging of matrix transformations + - Verify matrix multiplication implementation + - Test transform chain with simpler shapes first + +3. Rendering Improvements: + - Implement proper z-ordering for faces + - Add depth testing + - Improve perspective projection + - Add face normal calculations for proper visibility + +4. Development Tools: + - Add state visualization panel + - Add transform controls for manual positioning + - Add vertex position display + - Create test cases for geometry calculations + +5. Simplification Steps: + - Start with fewer triangles (e.g., 6) to verify basic folding + - Implement single fold before full flexagon + - Add step-by-step folding visualization + +IMMEDIATE NEXT ACTIONS: +-------------------- +1. Add vertex position logging in createTriangle() +2. Verify initial strip layout is correct +3. Test transform pipeline with single triangle +4. Add visual debug helpers for face orientation +5. Implement proper z-depth sorting + +QUESTIONS TO RESOLVE: +------------------ +1. Is the initial triangle strip properly oriented? +2. Are the transformation matrices being applied in the correct order? +3. Is the perspective projection working correctly? +4. Are the face normals being calculated properly? +5. Is the pat pattern being correctly applied to the strip? + +REFERENCES: +---------- +- Original flexagon research pat notation +- 3D graphics pipeline best practices +- Matrix transformation order conventions \ No newline at end of file diff --git a/html/flexagon/flexagon.js b/html/flexagon/flexagon.js new file mode 100644 index 0000000..56069ad --- /dev/null +++ b/html/flexagon/flexagon.js @@ -0,0 +1,502 @@ +// Flexagon Simulation +// This implementation uses a functional programming approach with immutable data structures +// and clear separation between the mathematical model, rendering, and interaction systems. + +// ===== Core Mathematical Model ===== +// The flexagon is modeled as a series of connected triangles in 3D space that can be folded +// A hexahexaflexagon is made from a strip of 19 equilateral triangles + +/** + * Represents a point in 3D space + * @typedef {Object} Point3D + * @property {number} x - X coordinate + * @property {number} y - Y coordinate + * @property {number} z - Z coordinate + */ + +/** + * Represents a 3x3 transformation matrix + * @typedef {Object} Matrix3D + * @property {number[]} values - 3x3 matrix values in row-major order + */ + +/** + * Represents a triangle face of the flexagon + * @typedef {Object} Face + * @property {Point3D[]} vertices - Three vertices of the triangle + * @property {string} color - Color of the face + * @property {number} layer - Layer depth of the face + * @property {number[]} connectedFaces - Indices of connected faces + * @property {number[]} sharedEdges - Indices of shared edges with connected faces + */ + +/** + * Represents the state of the flexagon + * @typedef {Object} FlexagonState + * @property {Face[]} faces - All faces of the flexagon + * @property {number} currentFaceIndex - Index of the currently visible face + * @property {Matrix3D} transform - Current 3D transformation + * @property {boolean} isAnimating - Whether an animation is in progress + * @property {number} animationProgress - Progress of current animation (0-1) + */ + +// Constants for the hexaflexagon +const FLEXAGON_CONFIG = { + triangleCount: 19, // Number of triangles in the strip + colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD', '#D4A5A5'], + animationDuration: 500, // ms + foldAngle: Math.PI / 3, // 60 degrees + sideLength: 100, // Length of triangle sides + // Pat notation for the hexahexaflexagon + // This represents the folding pattern as described in the Flexagon paper + patPattern: [1, 2, 3, 1, 2, 3, 1, 2, 3, 4, 5, 6, 4, 5, 6, 4, 5, 6, 1] +}; + +// ===== 3D Transformation Utilities ===== +/** + * Creates an identity matrix + * @returns {Matrix3D} Identity matrix + */ +const createIdentityMatrix = () => ({ + values: [ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1 + ] +}); + +/** + * Creates a rotation matrix around the X axis + * @param {number} angle - Rotation angle in radians + * @returns {Matrix3D} Rotation matrix + */ +const createRotationX = (angle) => ({ + values: [ + 1, 0, 0, + 0, Math.cos(angle), -Math.sin(angle), + 0, Math.sin(angle), Math.cos(angle) + ] +}); + +/** + * Creates a rotation matrix around the Y axis + * @param {number} angle - Rotation angle in radians + * @returns {Matrix3D} Rotation matrix + */ +const createRotationY = (angle) => ({ + values: [ + Math.cos(angle), 0, Math.sin(angle), + 0, 1, 0, + -Math.sin(angle), 0, Math.cos(angle) + ] +}); + +/** + * Multiplies two matrices + * @param {Matrix3D} a - First matrix + * @param {Matrix3D} b - Second matrix + * @returns {Matrix3D} Resulting matrix + */ +const multiplyMatrices = (a, b) => { + const result = createIdentityMatrix(); + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + let sum = 0; + for (let i = 0; i < 3; i++) { + sum += a.values[row * 3 + i] * b.values[i * 3 + col]; + } + result.values[row * 3 + col] = sum; + } + } + return result; +}; + +/** + * Applies a transformation matrix to a point + * @param {Point3D} point - Point to transform + * @param {Matrix3D} matrix - Transformation matrix + * @returns {Point3D} Transformed point + */ +const transformPoint = (point, matrix) => ({ + x: point.x * matrix.values[0] + point.y * matrix.values[1] + point.z * matrix.values[2], + y: point.x * matrix.values[3] + point.y * matrix.values[4] + point.z * matrix.values[5], + z: point.x * matrix.values[6] + point.y * matrix.values[7] + point.z * matrix.values[8] +}); + +// ===== Animation System ===== +/** + * Easing function for smooth animations + * @param {number} t - Time progress (0-1) + * @returns {number} Eased progress + */ +const easeInOutCubic = (t) => { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +}; + +/** + * Creates an equilateral triangle in 3D space + * @param {number} centerX - Center X coordinate + * @param {number} centerY - Center Y coordinate + * @param {number} centerZ - Center Z coordinate + * @param {number} angle - Rotation angle + * @param {number} size - Size of the triangle + * @returns {Point3D[]} Array of three vertices + */ +const createTriangle = (centerX, centerY, centerZ, angle, size) => { + const height = size * Math.sqrt(3) / 2; + return [ + { x: centerX, y: centerY, z: centerZ }, + { + x: centerX + size * Math.cos(angle), + y: centerY + size * Math.sin(angle), + z: centerZ + }, + { + x: centerX + size * Math.cos(angle + Math.PI / 3), + y: centerY + size * Math.sin(angle + Math.PI / 3), + z: centerZ + } + ]; +}; + +/** + * Creates the initial strip of triangles for the hexaflexagon + * @returns {Face[]} Array of connected triangles + */ +const createInitialStrip = () => { + const faces = []; + const size = FLEXAGON_CONFIG.sideLength; + + // Create the strip of triangles following the pat pattern + for (let i = 0; i < FLEXAGON_CONFIG.triangleCount; i++) { + const centerX = (i * size * 1.5); + const centerY = 0; + const centerZ = 0; + const angle = (i % 2) * Math.PI; // Alternate triangle orientations + + faces.push({ + vertices: createTriangle(centerX, centerY, centerZ, angle, size), + color: FLEXAGON_CONFIG.colors[FLEXAGON_CONFIG.patPattern[i] - 1], + layer: Math.floor(i / 6), // Group triangles into layers + connectedFaces: [ + (i + 1) % FLEXAGON_CONFIG.triangleCount, + (i - 1 + FLEXAGON_CONFIG.triangleCount) % FLEXAGON_CONFIG.triangleCount + ], + sharedEdges: [1, 2] // Indices of shared edges with next and previous triangles + }); + } + + return faces; +}; + +/** + * Determines if a face is visible + * @param {Face} face - Face to check + * @param {Matrix3D} transform - Current transformation + * @returns {boolean} Whether the face is visible + */ +const isFaceVisible = (face) => { + // During development, show all faces + return true; +}; + +/** + * Creates the initial state + * @returns {FlexagonState} Initial state of the flexagon + */ +const createInitialState = () => { + const faces = createInitialStrip(); + + // Apply initial transformations to make faces visible + const initialTransform = multiplyMatrices( + createRotationX(Math.PI / 6), // Tilt forward + createRotationY(Math.PI / 6) // Rotate slightly right + ); + + return { + faces: faces, + currentFaceIndex: 0, + transform: initialTransform, + isAnimating: false, + animationProgress: 0 + }; +}; + +/** + * Folds the strip of triangles into a hexagonal shape + * @param {Face[]} faces - Array of faces in the strip + * @returns {Face[]} Folded faces + */ +const foldStrip = (faces) => { + // Implementation of the folding algorithm + // This follows the Tuckerman traverse pattern + const foldedFaces = [...faces]; + const foldAngles = [ + Math.PI / 3, -Math.PI / 3, // First fold + Math.PI / 3, -Math.PI / 3, // Second fold + Math.PI / 3, -Math.PI / 3 // Third fold + ]; + + // Apply the folds + for (let i = 0; i < foldAngles.length; i++) { + const foldIndex = i * 3; + const foldAngle = foldAngles[i]; + + // Transform all vertices after the fold point + for (let j = foldIndex + 1; j < foldedFaces.length; j++) { + foldedFaces[j].vertices = foldedFaces[j].vertices.map(vertex => + transformPoint(vertex, createRotationY(foldAngle)) + ); + } + } + + return foldedFaces; +}; + +/** + * Performs a flex operation on the flexagon + * @param {FlexagonState} state - Current state + * @returns {FlexagonState} New state after flexing + */ +const flex = (state) => { + if (state.isAnimating) return state; + + const currentFace = state.faces[state.currentFaceIndex]; + const nextFaceIndex = currentFace.connectedFaces[0]; + + // Create rotation matrices for the animation + const startRotation = createIdentityMatrix(); + const endRotation = multiplyMatrices( + createRotationX(FLEXAGON_CONFIG.foldAngle), + createRotationY(Math.PI / 3) + ); + + // Apply an initial rotation to better show the 3D structure + const initialRotation = createRotationX(Math.PI / 6); + const combinedRotation = multiplyMatrices(endRotation, initialRotation); + + return { + ...state, + currentFaceIndex: nextFaceIndex, + isAnimating: true, + animationProgress: 0, + transform: startRotation + }; +}; + +/** + * Updates the animation state + * @param {FlexagonState} state - Current state + * @param {number} deltaTime - Time since last update in ms + * @returns {FlexagonState} Updated state + */ +const updateAnimation = (state, deltaTime) => { + if (!state.isAnimating) return state; + + const newProgress = Math.min(1, state.animationProgress + deltaTime / FLEXAGON_CONFIG.animationDuration); + const easedProgress = easeInOutCubic(newProgress); + + // Interpolate between start and end transformations + const startRotation = createIdentityMatrix(); + const endRotation = multiplyMatrices( + createRotationX(FLEXAGON_CONFIG.foldAngle), + createRotationY(Math.PI / 3) + ); + + const interpolatedMatrix = { + values: startRotation.values.map((value, i) => + value + (endRotation.values[i] - value) * easedProgress + ) + }; + + return { + ...state, + animationProgress: newProgress, + transform: interpolatedMatrix, + isAnimating: newProgress < 1 + }; +}; + +// ===== Rendering System ===== +/** + * Projects a 3D point onto 2D canvas coordinates with perspective + * @param {Point3D} point - 3D point to project + * @param {number} canvasWidth - Width of the canvas + * @param {number} canvasHeight - Height of the canvas + * @returns {Object} 2D coordinates {x, y} + */ +const projectPoint = (point, canvasWidth, canvasHeight) => { + // Perspective projection with a fixed focal length + const focalLength = 500; + const scale = focalLength / (focalLength + point.z); + + return { + x: point.x * scale + canvasWidth / 2, + y: point.y * scale + canvasHeight / 2 + }; +}; + +/** + * Calculates the normal vector of a face + * @param {Face} face - Face to calculate normal for + * @returns {Point3D} Normal vector + */ +const calculateFaceNormal = (face) => { + const v1 = face.vertices[1]; + const v2 = face.vertices[2]; + + return { + x: (v1.y - v2.y) * (v1.z - v2.z), + y: (v1.z - v2.z) * (v1.x - v2.x), + z: (v1.x - v2.x) * (v1.y - v2.y) + }; +}; + +/** + * Renders the flexagon on the canvas + * @param {CanvasRenderingContext2D} ctx - Canvas context + * @param {FlexagonState} state - Current state + */ +const renderFlexagon = (ctx, state) => { + const canvas = ctx.canvas; + + // Clear canvas with a light gray background for debugging + ctx.fillStyle = '#f0f0f0'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Debug: Draw coordinate axes + ctx.beginPath(); + ctx.strokeStyle = 'red'; + ctx.moveTo(canvas.width/2, canvas.height/2); + ctx.lineTo(canvas.width/2 + 50, canvas.height/2); + ctx.stroke(); + ctx.beginPath(); + ctx.strokeStyle = 'green'; + ctx.moveTo(canvas.width/2, canvas.height/2); + ctx.lineTo(canvas.width/2, canvas.height/2 - 50); + ctx.stroke(); + + // Center the flexagon + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const scale = 100; // Increase scale to make faces more visible + + // Draw all faces for debugging + state.faces.forEach((face, index) => { + // Transform vertices + const projectedPoints = face.vertices.map(vertex => { + const transformed = transformPoint(vertex, state.transform); + return { + x: centerX + transformed.x * scale, + y: centerY + transformed.y * scale + }; + }); + + // Draw face + ctx.beginPath(); + ctx.moveTo(projectedPoints[0].x, projectedPoints[0].y); + projectedPoints.slice(1).forEach(point => { + ctx.lineTo(point.x, point.y); + }); + ctx.closePath(); + + // Fill with semi-transparent color + ctx.fillStyle = face.color; + ctx.globalAlpha = 0.7; + ctx.fill(); + + // Draw edges + ctx.strokeStyle = '#000'; + ctx.globalAlpha = 1.0; + ctx.stroke(); + + // Draw face number + ctx.fillStyle = '#000'; + ctx.font = '14px Arial'; + const centerPoint = { + x: projectedPoints.reduce((sum, p) => sum + p.x, 0) / 3, + y: projectedPoints.reduce((sum, p) => sum + p.y, 0) / 3 + }; + ctx.fillText(index.toString(), centerPoint.x, centerPoint.y); + }); + + // Reset alpha + ctx.globalAlpha = 1.0; +}; + +// ===== Interaction System ===== +/** + * Sets up mouse interaction for the flexagon + * @param {HTMLCanvasElement} canvas - Canvas element + * @param {Function} onFlex - Callback when flexing occurs + */ +const setupInteraction = (canvas, onFlex) => { + let isDragging = false; + let startX = 0; + + canvas.addEventListener('mousedown', (e) => { + isDragging = true; + startX = e.clientX; + }); + + canvas.addEventListener('mousemove', (e) => { + if (!isDragging) return; + + const deltaX = e.clientX - startX; + if (Math.abs(deltaX) > 50) { // Threshold for flexing + onFlex(); + isDragging = false; + } + }); + + canvas.addEventListener('mouseup', () => { + isDragging = false; + }); + + canvas.addEventListener('mouseleave', () => { + isDragging = false; + }); +}; + +// ===== Main Application ===== +const main = () => { + const canvas = document.getElementById('flexagonCanvas'); + const ctx = canvas.getContext('2d'); + + // Set canvas size + canvas.width = 600; + canvas.height = 600; + + console.log('Canvas initialized:', canvas.width, 'x', canvas.height); + + // Initialize state + let state = createInitialState(); + console.log('Initial state created:', { + faceCount: state.faces.length, + vertices: state.faces[0]?.vertices + }); + + let lastTime = performance.now(); + + // Animation loop + const animate = (currentTime) => { + const deltaTime = currentTime - lastTime; + lastTime = currentTime; + + state = updateAnimation(state, deltaTime); + renderFlexagon(ctx, state); + + requestAnimationFrame(animate); + }; + + // Setup interaction + setupInteraction(canvas, () => { + state = flex(state); + }); + + // Start animation loop + requestAnimationFrame(animate); +}; + +// Start the application when the DOM is loaded +document.addEventListener('DOMContentLoaded', main); diff --git a/html/flexagon/index.html b/html/flexagon/index.html new file mode 100644 index 0000000..5d42dec --- /dev/null +++ b/html/flexagon/index.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Flexagon Simulation</title> + <style> + body { + margin: 0; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: #f0f0f0; + } + canvas { + border: 1px solid #ccc; + background: white; + } + </style> +</head> +<body> + <canvas id="flexagonCanvas"></canvas> + <script src="flexagon.js"></script> +</body> +</html> diff --git a/html/fps/game.js b/html/fps/game.js new file mode 100644 index 0000000..e157ab1 --- /dev/null +++ b/html/fps/game.js @@ -0,0 +1,992 @@ +// Game state management +const GameState = { + player: { + x: 0, + y: 0, + angle: 0, + health: 100, + ammo: 10, + score: 0 + }, + level: { + width: 32, + height: 32, + map: [], + flag: { x: 0, y: 0 } + }, + enemies: [], + items: [], + isGameOver: false, + gun: { + recoil: 0, + lastShot: 0, + muzzleFlash: 0, + slidePosition: 0, // 0 = forward, 1 = back + tilt: 0 // 0 = normal, 1 = tilted + }, + shots: [], + isStarted: false, + gradients: {}, // Cache for wall gradients + lastGradientUpdate: 0, + particles: [], + damageFlash: 0 +}; + +// Level generation using a simple maze algorithm +const generateLevel = () => { + const map = Array(GameState.level.height).fill().map(() => + Array(GameState.level.width).fill(1) + ); + + // Create starting room + const startRoomSize = 5; + for (let y = 1; y < startRoomSize; y++) { + for (let x = 1; x < startRoomSize; x++) { + map[y][x] = 0; + } + } + + // Add fewer random larger rooms + const numLargeRooms = 2; // Reduced from 3 + for (let i = 0; i < numLargeRooms; i++) { + const roomWidth = Math.floor(Math.random() * 3) + 4; // 4-6 (reduced from 4-7) + const roomHeight = Math.floor(Math.random() * 3) + 4; // 4-6 (reduced from 4-7) + const roomX = Math.floor(Math.random() * (GameState.level.width - roomWidth - 2)) + 1; + const roomY = Math.floor(Math.random() * (GameState.level.height - roomHeight - 2)) + 1; + + // Create room + for (let y = roomY; y < roomY + roomHeight; y++) { + for (let x = roomX; x < roomX + roomWidth; x++) { + map[y][x] = 0; + } + } + + // Connect to maze + const connectX = roomX + Math.floor(roomWidth/2); + const connectY = roomY + Math.floor(roomHeight/2); + map[connectY][connectX] = 0; + } + + // Simple maze generation using depth-first search with single-cell hallways + const carveMaze = (x, y) => { + // Ensure we're within bounds + if (x <= 0 || x >= GameState.level.width - 1 || y <= 0 || y >= GameState.level.height - 1) { + return; + } + + map[y][x] = 0; + + const directions = [ + [0, -2], [2, 0], [0, 2], [-2, 0] + ].sort(() => Math.random() - 0.5); + + for (const [dx, dy] of directions) { + const nx = x + dx; + const ny = y + dy; + if (nx > 0 && nx < GameState.level.width - 1 && + ny > 0 && ny < GameState.level.height - 1 && + map[ny][nx] === 1) { + // Carve single-cell paths + const midX = x + Math.floor(dx/2); + const midY = y + Math.floor(dy/2); + if (midX >= 0 && midX < GameState.level.width && + midY >= 0 && midY < GameState.level.height) { + map[midY][midX] = 0; + } + carveMaze(nx, ny); + } + } + }; + + // Start maze generation from the edge of the starting room + carveMaze(startRoomSize, startRoomSize); + + // Place flag in a random open space (not in starting room) + const openSpaces = []; + for (let y = 0; y < GameState.level.height; y++) { + for (let x = 0; x < GameState.level.width; x++) { + if (map[y][x] === 0 && (x >= startRoomSize || y >= startRoomSize)) { + openSpaces.push({x, y}); + } + } + } + const flagPos = openSpaces[Math.floor(Math.random() * openSpaces.length)]; + GameState.level.flag = flagPos; + + // Place enemies in open spaces (not in starting room) + GameState.enemies = []; + for (let i = 0; i < 5; i++) { + const pos = openSpaces[Math.floor(Math.random() * openSpaces.length)]; + GameState.enemies.push({ + x: pos.x, + y: pos.y, + health: Math.floor(Math.random() * 4) + 2 + }); + } + + // Place items in open spaces (not in starting room) + GameState.items = []; + // Add health packs + for (let i = 0; i < 8; i++) { + const pos = openSpaces[Math.floor(Math.random() * openSpaces.length)]; + GameState.items.push({ + x: pos.x, + y: pos.y, + type: 'health', + value: Math.floor(Math.random() * 5) + 1 + }); + } + // Add ammo packs + for (let i = 0; i < 6; i++) { + const pos = openSpaces[Math.floor(Math.random() * openSpaces.length)]; + GameState.items.push({ + x: pos.x, + y: pos.y, + type: 'ammo', + value: 5 + }); + } + + GameState.level.map = map; + GameState.player.x = startRoomSize/2; + GameState.player.y = startRoomSize/2; + GameState.player.angle = 0; +}; + +// Player movement and controls +const handlePlayerMovement = (keys) => { + const moveSpeed = 0.05; + const rotateSpeed = 0.05; + + if (keys.w) { + // Move forward in the direction the player is facing + GameState.player.x += Math.sin(GameState.player.angle) * moveSpeed; + GameState.player.y -= Math.cos(GameState.player.angle) * moveSpeed; + } + if (keys.s) { + // Move backward + GameState.player.x -= Math.sin(GameState.player.angle) * moveSpeed; + GameState.player.y += Math.cos(GameState.player.angle) * moveSpeed; + } + if (keys.a) { + // Strafe left + GameState.player.x -= Math.cos(GameState.player.angle) * moveSpeed; + GameState.player.y -= Math.sin(GameState.player.angle) * moveSpeed; + } + if (keys.d) { + // Strafe right + GameState.player.x += Math.cos(GameState.player.angle) * moveSpeed; + GameState.player.y += Math.sin(GameState.player.angle) * moveSpeed; + } + + // Add arrow key rotation + if (keys.ArrowLeft) { + GameState.player.angle -= rotateSpeed; + } + if (keys.ArrowRight) { + GameState.player.angle += rotateSpeed; + } +}; + +// Enemy AI +const updateEnemies = () => { + GameState.enemies = GameState.enemies.map(enemy => { + if (enemy.health <= 0) return null; + + const dx = GameState.player.x - enemy.x; + const dy = GameState.player.y - enemy.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 0.5) { + GameState.player.health -= 10; + GameState.damageFlash = 1.0; // Trigger full flash + if (GameState.player.health <= 0) { + GameState.isGameOver = true; + } + } else if (dist < 5) { + // Check if path to player is clear + const steps = 10; + let canMove = true; + for (let i = 1; i <= steps; i++) { + const testX = enemy.x + (dx / dist) * (i / steps); + const testY = enemy.y + (dy / dist) * (i / steps); + if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { + canMove = false; + break; + } + } + + if (canMove) { + enemy.x += dx / dist * 0.05; + enemy.y += dy / dist * 0.05; + } + } + + return enemy; + }).filter(Boolean); +}; + +// Collision detection +const checkCollisions = () => { + // Wall collisions with improved precision and bounds checking + const playerX = GameState.player.x; + const playerY = GameState.player.y; + + // Check if player is within map bounds + if (playerX < 0 || playerX >= GameState.level.width || + playerY < 0 || playerY >= GameState.level.height) { + // Push player back to last valid position + GameState.player.x = Math.max(0, Math.min(GameState.player.x, GameState.level.width - 1)); + GameState.player.y = Math.max(0, Math.min(GameState.player.y, GameState.level.height - 1)); + return; + } + + const nextX = playerX + Math.sin(GameState.player.angle) * 0.1; + const nextY = playerY + Math.cos(GameState.player.angle) * 0.1; + + // Check all four corners of the player's collision box + const checkPoint = (x, y) => { + const gridX = Math.floor(x); + const gridY = Math.floor(y); + // Ensure we're within bounds + if (gridX < 0 || gridX >= GameState.level.width || + gridY < 0 || gridY >= GameState.level.height) { + return true; // Treat out of bounds as a wall + } + return GameState.level.map[gridY][gridX] === 1; + }; + + const collisionBox = 0.2; // Player collision box size + + if (checkPoint(playerX + collisionBox, playerY + collisionBox) || + checkPoint(playerX + collisionBox, playerY - collisionBox) || + checkPoint(playerX - collisionBox, playerY + collisionBox) || + checkPoint(playerX - collisionBox, playerY - collisionBox)) { + // Push player back to last valid position + GameState.player.x = Math.floor(GameState.player.x) + 0.5; + GameState.player.y = Math.floor(GameState.player.y) + 0.5; + } + + // Item collection + GameState.items = GameState.items.filter(item => { + const dx = GameState.player.x - item.x; + const dy = GameState.player.y - item.y; + if (Math.sqrt(dx * dx + dy * dy) < 0.5) { + if (item.type === 'ammo') { + GameState.player.ammo += item.value; + } else { + GameState.player.health = Math.min(100, GameState.player.health + item.value); + } + return false; + } + return true; + }); + + // Flag collection + const dx = GameState.player.x - GameState.level.flag.x; + const dy = GameState.player.y - GameState.level.flag.y; + if (Math.sqrt(dx * dx + dy * dy) < 0.5) { + GameState.player.score++; + generateLevel(); + } +}; + +// Rendering system +const render = (ctx) => { + const canvas = ctx.canvas; + const width = canvas.width; + const height = canvas.height; + + // Clear screen + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, width, height); + + if (!GameState.isStarted) { + // Draw start screen + ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + ctx.fillRect(0, 0, width, height); + + // Draw title + ctx.fillStyle = '#fff'; + ctx.font = '48px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('Hedge Maze', width/2, height/2 - 50); + + // Draw start button + const buttonWidth = 200; + const buttonHeight = 60; + const buttonX = width/2 - buttonWidth/2; + const buttonY = height/2; + + ctx.fillStyle = '#333'; + ctx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight); + + ctx.fillStyle = '#fff'; + ctx.font = '24px monospace'; + ctx.fillText('START GAME', width/2, buttonY + buttonHeight/2 + 8); + + return; + } + + // Draw ceiling + ctx.fillStyle = '#1a1a4a'; + ctx.fillRect(0, 0, width, height/2); + + // Draw floor + ctx.fillStyle = '#4a2a00'; + ctx.fillRect(0, height/2, width, height/2); + + // Draw walls and sprites using ray casting + const fov = Math.PI / 3; + const numRays = Math.floor(width / 2); // Reduce ray count by half + const rayResults = []; + + // Pre-calculate common values + const halfHeight = height / 2; + const brightnessSteps = 20; // Number of brightness levels to cache + const gradientCache = {}; + + // Create cached gradients for different brightness levels + for (let b = 0; b <= brightnessSteps; b++) { + const brightness = b / brightnessSteps; + const baseColor = Math.floor(brightness * 100); + const distanceColor = Math.floor(brightness * 50); + + gradientCache[b] = { + top: `rgb(${distanceColor}, ${baseColor + 20}, ${distanceColor})`, + middle: `rgb(${distanceColor}, ${baseColor}, ${distanceColor})`, + bottom: `rgb(${distanceColor}, ${baseColor - 20}, ${distanceColor})`, + line: `rgba(0, ${baseColor - 30}, 0, 0.3)` + }; + } + + for (let i = 0; i < width; i += 2) { + const rayIndex = Math.floor(i / 2); + const rayAngle = GameState.player.angle - fov/2 + fov * rayIndex / numRays; + let distance = 0; + let hit = false; + + while (!hit && distance < 20) { + distance += 0.2; + const testX = GameState.player.x + Math.sin(rayAngle) * distance; + const testY = GameState.player.y + Math.cos(rayAngle) * distance; + + // Check for wall hits + if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { + hit = true; + } + } + + rayResults[rayIndex] = { + distance, + angle: rayAngle + }; + + const wallHeight = height / (distance * Math.cos(rayAngle - GameState.player.angle)); + const brightness = Math.max(0, 1 - distance / 20); + const brightnessIndex = Math.floor(brightness * brightnessSteps); + const colors = gradientCache[brightnessIndex]; + + const wallTop = halfHeight - wallHeight/2; + const wallBottom = halfHeight + wallHeight/2; + + // Draw wall with cached colors + ctx.fillStyle = colors.middle; + ctx.fillRect(i, wallTop, 2, wallHeight); + + // Draw top and bottom highlights + ctx.fillStyle = colors.top; + ctx.fillRect(i, wallTop, 2, 1); + ctx.fillStyle = colors.bottom; + ctx.fillRect(i, wallBottom - 1, 2, 1); + + // Draw vertical lines less frequently + if (i % 8 === 0) { + ctx.fillStyle = colors.line; + ctx.fillRect(i, wallTop, 2, wallHeight); + } + } + + // Draw shots + const now = Date.now(); + GameState.shots = GameState.shots.filter(shot => { + const age = now - shot.time; + if (age > 200) return false; // Remove shots older than 200ms + + const progress = age / 200; + const distance = progress * 20; + const x = GameState.player.x + Math.sin(shot.angle) * distance; + const y = GameState.player.y + Math.cos(shot.angle) * distance; + + // Convert world position to screen position + const dx = x - GameState.player.x; + const dy = y - GameState.player.y; + const angle = Math.atan2(dx, dy) - GameState.player.angle; + const screenX = (angle / fov + 0.5) * width; + + if (screenX >= 0 && screenX < width) { + ctx.fillStyle = '#ffff00'; + ctx.fillRect(screenX, halfHeight, 2, 2); + } + + return true; + }); + + // Draw gun + const gunY = height - 150; + const gunX = width/2; + const recoilOffset = Math.sin(GameState.gun.recoil * Math.PI) * 50; + const tiltAngle = GameState.gun.tilt * Math.PI / 6; + + // Only draw gun if it's not in full recoil + if (GameState.gun.recoil < 0.8) { + ctx.save(); + ctx.translate(gunX, gunY + recoilOffset); + ctx.rotate(tiltAngle); + + // Gun body (larger rectangle) extending below screen + ctx.fillStyle = '#333'; + ctx.fillRect(-30, 0, 60, height); + + // Gun slide (smaller rectangle) with sliding animation + const slideOffset = GameState.gun.slidePosition * 20; + ctx.fillStyle = '#222'; + // Adjusted slide dimensions: shorter above, longer below + ctx.fillRect(-8, -15 - slideOffset, 16, 90); // Changed from -30 to -15 for top, and 60 to 90 for height + + // Muzzle flash + if (GameState.gun.muzzleFlash > 0) { + const flashSize = GameState.gun.muzzleFlash * 30; + ctx.fillStyle = `rgba(255, 255, 0, ${GameState.gun.muzzleFlash})`; + ctx.beginPath(); + ctx.arc(0, -15 - slideOffset, flashSize, 0, Math.PI * 2); // Adjusted to match new slide position + ctx.fill(); + } + + ctx.restore(); + } + + // Draw crosshair + ctx.fillStyle = '#fff'; + ctx.fillRect(width/2 - 5, halfHeight, 10, 1); + ctx.fillRect(width/2, halfHeight - 5, 1, 10); + + // Draw HUD - only canvas-based + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(10, 10, 200, 120); + + ctx.fillStyle = '#fff'; + ctx.font = '24px monospace'; + ctx.fillText(`Health: ${GameState.player.health}`, 100, 45); + ctx.fillText(`Ammo: ${GameState.player.ammo}`, 80, 80); + ctx.fillText(`Score: ${GameState.player.score}`, 80, 115); + + // Draw compass + const compassSize = 100; + const compassX = width/2 - compassSize/2; + const compassY = 20; + + // Draw compass background + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(compassX, compassY, compassSize, compassSize); // Make it square + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 2; + ctx.strokeRect(compassX, compassY, compassSize, compassSize); + + // Draw compass directions + ctx.fillStyle = '#fff'; + ctx.font = '16px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Draw N, E, S, W in traditional compass layout + ctx.fillText('N', compassX + compassSize/2, compassY + 15); + ctx.fillText('E', compassX + compassSize - 15, compassY + compassSize/2); + ctx.fillText('S', compassX + compassSize/2, compassY + compassSize - 15); + ctx.fillText('W', compassX + 15, compassY + compassSize/2); + + // Draw direction indicator - ensure it's always visible within the compass + const angle = GameState.player.angle; + const radius = compassSize/2 - 15; // Keep indicator within compass bounds + const indicatorX = compassX + compassSize/2 + Math.sin(angle) * radius; + const indicatorY = compassY + compassSize/2 - Math.cos(angle) * radius; + + ctx.fillStyle = '#0f0'; + ctx.beginPath(); + ctx.arc(indicatorX, indicatorY, 4, 0, Math.PI * 2); + ctx.fill(); + + // Draw sprites + const drawSprite = (x, y, distance, type) => { + // Calculate screen position + const dx = x - GameState.player.x; + const dy = y - GameState.player.y; + const angle = Math.atan2(dx, dy) - GameState.player.angle; + + // Convert to screen coordinates + const screenX = (angle / (Math.PI / 3) + 0.5) * width; + const screenY = height / 2; + + // Calculate size based on distance + const baseSize = 0.5; + const size = (baseSize / distance) * height; + + // Only draw if on screen and not behind a wall + if (screenX >= 0 && screenX < width) { + const wallDistance = rayResults[Math.floor(screenX / 2)].distance; + if (distance < wallDistance) { + // Set color based on type + if (type === 'enemy') { + ctx.fillStyle = '#f00'; + } else if (type === 'health') { + ctx.fillStyle = '#0f0'; + } else if (type === 'ammo') { + ctx.fillStyle = '#0ff'; + } else if (type === 'flag') { + ctx.fillStyle = '#ff0'; + } + + // Draw single square + ctx.fillRect(screenX - size/2, screenY - size/2, size, size); + } + } + }; + + // Draw all sprites + GameState.enemies.forEach(enemy => { + const dx = enemy.x - GameState.player.x; + const dy = enemy.y - GameState.player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + drawSprite(enemy.x, enemy.y, distance, 'enemy'); + }); + + GameState.items.forEach(item => { + const dx = item.x - GameState.player.x; + const dy = item.y - GameState.player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + drawSprite(item.x, item.y, distance, item.type); + }); + + // Draw flag + const dx = GameState.level.flag.x - GameState.player.x; + const dy = GameState.level.flag.y - GameState.player.y; + const flagDistance = Math.sqrt(dx * dx + dy * dy); + drawSprite(GameState.level.flag.x, GameState.level.flag.y, flagDistance, 'flag'); + + // Draw particles + GameState.particles.forEach(particle => { + const dx = particle.x - GameState.player.x; + const dy = particle.y - GameState.player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dx, dy) - GameState.player.angle; + + // Convert world position to screen position + const screenX = (angle / (Math.PI / 3) + 0.5) * width; + const screenY = height / 2; + + // Ensure size is always positive and within reasonable bounds + const size = Math.max(1, Math.min(particle.size, (1 - distance / 20) * particle.size)); + + if (screenX >= 0 && screenX < width && size > 0) { + ctx.save(); + ctx.translate(screenX, screenY); + ctx.rotate(particle.rotation); + + ctx.fillStyle = particle.color; + ctx.globalAlpha = particle.life; + + // Draw a more interesting particle shape + ctx.beginPath(); + ctx.moveTo(0, -size); + ctx.lineTo(size, size); + ctx.lineTo(-size, size); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); + ctx.globalAlpha = 1; + } + }); + + // Draw mini-map + const miniMapSize = 200; + const miniMapX = width - miniMapSize - 10; + const miniMapY = 10; + const cellSize = miniMapSize / GameState.level.width; + + // Draw mini-map background with semi-transparent border + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(miniMapX, miniMapY, miniMapSize, miniMapSize); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 2; + ctx.strokeRect(miniMapX, miniMapY, miniMapSize, miniMapSize); + + // Draw walls + ctx.fillStyle = '#666'; + for (let y = 0; y < GameState.level.height; y++) { + for (let x = 0; x < GameState.level.width; x++) { + if (GameState.level.map[y][x] === 1) { + ctx.fillRect( + miniMapX + x * cellSize, + miniMapY + y * cellSize, + cellSize, + cellSize + ); + } + } + } + + // Draw flag + ctx.fillStyle = '#ff0'; + ctx.beginPath(); + ctx.arc( + miniMapX + GameState.level.flag.x * cellSize + cellSize/2, + miniMapY + GameState.level.flag.y * cellSize + cellSize/2, + cellSize/2, + 0, + Math.PI * 2 + ); + ctx.fill(); + + // Draw items + GameState.items.forEach(item => { + if (item.type === 'health') { + ctx.fillStyle = '#0f0'; + } else { + ctx.fillStyle = '#0ff'; + } + ctx.beginPath(); + ctx.arc( + miniMapX + item.x * cellSize + cellSize/2, + miniMapY + item.y * cellSize + cellSize/2, + cellSize/3, + 0, + Math.PI * 2 + ); + ctx.fill(); + }); + + // Draw enemies + ctx.fillStyle = '#f00'; + GameState.enemies.forEach(enemy => { + ctx.beginPath(); + ctx.arc( + miniMapX + enemy.x * cellSize + cellSize/2, + miniMapY + enemy.y * cellSize + cellSize/2, + cellSize/2, + 0, + Math.PI * 2 + ); + ctx.fill(); + }); + + // Draw player with enhanced direction indicator + const playerX = miniMapX + GameState.player.x * cellSize; + const playerY = miniMapY + GameState.player.y * cellSize; + const size = cellSize * 1.5; + + // Draw player outline + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(playerX + cellSize/2, playerY + cellSize/2, size/2 + 2, 0, Math.PI * 2); + ctx.stroke(); + + // Draw player triangle with direction indicator - use the angle directly + ctx.fillStyle = '#00f'; + ctx.save(); + ctx.translate(playerX + cellSize/2, playerY + cellSize/2); + ctx.rotate(GameState.player.angle); // Use the angle directly + + // Draw main triangle + ctx.beginPath(); + ctx.moveTo(0, -size/2); + ctx.lineTo(size/2, size/2); + ctx.lineTo(-size/2, size/2); + ctx.closePath(); + ctx.fill(); + + // Draw direction indicator line + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, -size/2); + ctx.lineTo(0, -size); + ctx.stroke(); + + // Draw small circle at the end of the direction line + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(0, -size, size/4, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + + // Draw damage flash effect + if (GameState.damageFlash > 0) { + // Create a radial gradient that's transparent in the center and red at the edges + const gradient = ctx.createRadialGradient( + width/2, height/2, 0, // Inner circle (center) + width/2, height/2, Math.max(width, height)/2 // Outer circle (edges) + ); + gradient.addColorStop(0, `rgba(255, 0, 0, 0)`); // Transparent center + gradient.addColorStop(0.7, `rgba(255, 0, 0, 0)`); // Start red at 70% of radius + gradient.addColorStop(1, `rgba(255, 0, 0, ${GameState.damageFlash})`); // Full red at edges + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + GameState.damageFlash -= 0.05; // Fade out over time + } +}; + +// Game loop +const gameLoop = (ctx) => { + if (!GameState.isStarted) { + render(ctx); + requestAnimationFrame(() => gameLoop(ctx)); + return; + } + + if (GameState.isGameOver) { + ctx.fillStyle = '#fff'; + ctx.font = '48px monospace'; + ctx.fillText('GAME OVER', ctx.canvas.width/2 - 100, ctx.canvas.height/2); + return; + } + + // Check for firing input + if ((keys[' '] || keys.e) && GameState.player.ammo > 0 && + Date.now() - GameState.gun.lastShot > 200) { + GameState.player.ammo--; + GameState.gun.recoil = 1; + GameState.gun.muzzleFlash = 1; + GameState.gun.lastShot = Date.now(); + GameState.shots.push({ + time: Date.now(), + angle: GameState.player.angle + }); + + // Check for enemy hits + const fov = Math.PI / 3; + const rayAngle = GameState.player.angle; + let distance = 0; + let hitEnemy = null; + let hitWall = false; + + while (!hitEnemy && !hitWall && distance < 20) { + distance += 0.1; + const testX = GameState.player.x + Math.sin(rayAngle) * distance; + const testY = GameState.player.y + Math.cos(rayAngle) * distance; + + if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { + hitWall = true; + break; + } + + hitEnemy = GameState.enemies.find(enemy => { + const dx = testX - enemy.x; + const dy = testY - enemy.y; + return Math.sqrt(dx * dx + dy * dy) < 0.5; + }); + } + + if (hitEnemy) { + hitEnemy.health--; + if (hitEnemy.health <= 0) { + // Create more particles with more colors + const colors = [ + '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', + '#ff8800', '#88ff00', '#00ff88', '#0088ff', '#8800ff', '#ff0088', + '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', '#44ffff' + ]; + for (let i = 0; i < 80; i++) { // More particles + const color = colors[Math.floor(Math.random() * colors.length)]; + GameState.particles.push(new Particle(hitEnemy.x, hitEnemy.y, color)); + } + } + } + } + + // Update gun recoil, muzzle flash, and slide position + if (GameState.gun.recoil > 0) { + GameState.gun.recoil -= 0.1; + // Animate slide moving back + GameState.gun.slidePosition = Math.min(1, GameState.gun.slidePosition + 0.2); + } else if (GameState.gun.slidePosition > 0) { + // Animate slide moving forward + GameState.gun.slidePosition = Math.max(0, GameState.gun.slidePosition - 0.1); + } + if (GameState.gun.muzzleFlash > 0) { + GameState.gun.muzzleFlash -= 0.2; + } + + // Update gun tilt based on ammo + if (GameState.player.ammo === 0) { + GameState.gun.tilt = Math.min(1, GameState.gun.tilt + 0.1); + } else { + GameState.gun.tilt = Math.max(0, GameState.gun.tilt - 0.1); + } + + // Update particles + GameState.particles = GameState.particles.filter(particle => particle.update()); + + handlePlayerMovement(keys); + updateEnemies(); + checkCollisions(); + render(ctx); + + requestAnimationFrame(() => gameLoop(ctx)); +}; + +// Input handling +const keys = { + w: false, + a: false, + s: false, + d: false, + ArrowLeft: false, + ArrowRight: false, + ' ': false, // Space bar + e: false // E key +}; + +document.addEventListener('keydown', e => { + if (!GameState.isStarted && (e.key === ' ' || e.key === 'Enter')) { + GameState.isStarted = true; + generateLevel(); + return; + } + + if (e.key.toLowerCase() in keys) keys[e.key.toLowerCase()] = true; + if (e.key in keys) keys[e.key] = true; +}); + +document.addEventListener('keyup', e => { + if (e.key.toLowerCase() in keys) keys[e.key.toLowerCase()] = false; + if (e.key in keys) keys[e.key] = false; +}); + +document.addEventListener('mousemove', e => { + if (GameState.isStarted) { + GameState.player.angle += e.movementX * 0.01; + } +}); + +// Update click handler to handle both start screen and firing +document.addEventListener('click', (e) => { + if (!GameState.isStarted) { + const canvas = document.getElementById('gameCanvas'); + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const buttonWidth = 200; + const buttonHeight = 60; + const buttonX = canvas.width/2 - buttonWidth/2; + const buttonY = canvas.height/2; + + if (x >= buttonX && x <= buttonX + buttonWidth && + y >= buttonY && y <= buttonY + buttonHeight) { + GameState.isStarted = true; + generateLevel(); + } + return; + } + + // Handle firing on click during gameplay + if (GameState.player.ammo > 0 && Date.now() - GameState.gun.lastShot > 200) { + GameState.player.ammo--; + GameState.gun.recoil = 1; + GameState.gun.muzzleFlash = 1; + GameState.gun.lastShot = Date.now(); + GameState.shots.push({ + time: Date.now(), + angle: GameState.player.angle + }); + + // Check for enemy hits + const fov = Math.PI / 3; + const rayAngle = GameState.player.angle; + let distance = 0; + let hitEnemy = null; + let hitWall = false; + + while (!hitEnemy && !hitWall && distance < 20) { + distance += 0.1; + const testX = GameState.player.x + Math.sin(rayAngle) * distance; + const testY = GameState.player.y + Math.cos(rayAngle) * distance; + + if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { + hitWall = true; + break; + } + + hitEnemy = GameState.enemies.find(enemy => { + const dx = testX - enemy.x; + const dy = testY - enemy.y; + return Math.sqrt(dx * dx + dy * dy) < 0.5; + }); + } + + if (hitEnemy) { + hitEnemy.health--; + if (hitEnemy.health <= 0) { + // Create more particles with more colors + const colors = [ + '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', + '#ff8800', '#88ff00', '#00ff88', '#0088ff', '#8800ff', '#ff0088', + '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', '#44ffff' + ]; + for (let i = 0; i < 80; i++) { // More particles + const color = colors[Math.floor(Math.random() * colors.length)]; + GameState.particles.push(new Particle(hitEnemy.x, hitEnemy.y, color)); + } + } + } + } +}); + +// Initialize game +const init = () => { + const canvas = document.getElementById('gameCanvas'); + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + const ctx = canvas.getContext('2d'); + + // Don't generate level or start game loop until start button is clicked + render(ctx); + gameLoop(ctx); +}; + +// Add particle class +class Particle { + constructor(x, y, color) { + this.x = x; + this.y = y; + this.color = color; + this.size = Math.random() * 8 + 4; // Larger size range (4-12) + + // Random direction in all directions (360 degrees) + const angle = Math.random() * Math.PI * 2; + const speed = Math.random() * 15 + 5; // Faster spread (5-20) + this.speedX = Math.sin(angle) * speed; + this.speedY = Math.cos(angle) * speed; + + this.life = 1.0; + this.decay = Math.random() * 0.005 + 0.002; // Slower decay + this.rotation = Math.random() * Math.PI * 2; + this.rotationSpeed = (Math.random() - 0.5) * 0.2; + this.gravity = Math.random() * 0.15 + 0.05; + } + + update() { + this.x += this.speedX; + this.y += this.speedY; + this.speedY += this.gravity; + this.life -= this.decay; + this.rotation += this.rotationSpeed; + return this.life > 0; + } +} + +init(); diff --git a/html/fps/index.html b/html/fps/index.html new file mode 100644 index 0000000..4715b47 --- /dev/null +++ b/html/fps/index.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Hedge Maze</title> + <style> + body { + margin: 0; + padding: 0; + overflow: hidden; + background-color: #000; + } + canvas { + display: block; + } + </style> +</head> +<body> + <canvas id="gameCanvas"></canvas> + <script type="module" src="game.js"></script> +</body> +</html> diff --git a/html/immoral/app.js b/html/immoral/app.js new file mode 100644 index 0000000..3e6cfa7 --- /dev/null +++ b/html/immoral/app.js @@ -0,0 +1,825 @@ +/** + * Web Font Vacuum + * A tool to find and extract web fonts from any website + * + * We sort of set up a pipeline where each step processes the data and passes it to the next: + * 1. URL Input > 2. Fetch HTML > 3. Parse & Extract > 4. Process CSS > 5. Display Results + * + */ + +/** + * Proxy List + * Proxies are buggy, and temperamental...so why not use a whole lot of them! + * List of CORS proxies we can try if the main one fails. + * Keeps track of the last working proxy to optimize future requests. + * @type {Array<{name: string, url: string, urlFormatter: (url: string) => string}>} + */ +const CORS_PROXIES = [ + { + name: 'allorigins', + url: 'https://api.allorigins.win/raw?url=', + urlFormatter: url => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}` + }, + { + name: 'corsproxy.io', + url: 'https://corsproxy.io/?', + urlFormatter: url => `https://corsproxy.io/?${encodeURIComponent(url)}` + }, + { + name: 'cors.sh', + url: 'https://cors.sh/', + urlFormatter: url => `https://cors.sh/${url}` + }, + { + name: 'corsanywhere', + url: 'https://cors-anywhere.herokuapp.com/', // FIXME: pretty certain this one doesn't work without human intervention + urlFormatter: url => `https://cors-anywhere.herokuapp.com/${url}` + }, + { + name: 'thingproxy', + url: 'https://thingproxy.freeboard.io/fetch/', + urlFormatter: url => `https://thingproxy.freeboard.io/fetch/${url}` + } +]; + +// Keep track of which proxy worked last +let lastWorkingProxyIndex = 0; +let proxyFailureCount = 0; +const MAX_PROXY_FAILURES = 3; + +async function fetchWithProxies(url, attempt = 0, isBinary = false) { + // Start with the last working proxy + const startIndex = lastWorkingProxyIndex; + + for (let i = 0; i < CORS_PROXIES.length; i++) { + // Calculate the current proxy index, wrapping around if necessary + const proxyIndex = (startIndex + i) % CORS_PROXIES.length; + const proxy = CORS_PROXIES[proxyIndex]; + + try { + console.log(`Trying proxy: ${proxy.name} for URL: ${url}`); + + const fetchOptions = { + headers: { + 'Accept': isBinary ? '*/*' : 'text/html,application/xhtml+xml,text/css', + 'Origin': window.location.origin + }, + mode: 'cors' + }; + + const response = await fetch(proxy.urlFormatter(url), fetchOptions); + + if (response.ok) { + lastWorkingProxyIndex = proxyIndex; + proxyFailureCount = 0; + return response; + } + } catch (error) { + console.log(`Proxy ${proxy.name} failed:`, error); + proxyFailureCount++; + + // If we've had too many failures, wait a bit before continuing + if (proxyFailureCount >= MAX_PROXY_FAILURES) { + await new Promise(resolve => setTimeout(resolve, 1000)); + proxyFailureCount = 0; + } + } + } + + throw new Error('All proxies failed to fetch the resource'); +} + +async function downloadFont(url, filename) { + try { + console.log('Downloading font from:', url); + const response = await fetchWithProxies(url, 0, true); + + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength === 0) { + throw new Error('Received empty font file'); + } + + // Convert ArrayBuffer to Blob with proper MIME type + const blob = new Blob([arrayBuffer], { type: getFontMimeType(url) }); + + // Temporary link to trigger the download + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + + document.body.appendChild(link); + link.click(); + + // Small delay to ensure download starts before cleanup + setTimeout(() => { + document.body.removeChild(link); + URL.revokeObjectURL(objectUrl); + }, 100); + + return true; + } catch (error) { + console.error('Error downloading font:', error); + alert(`Error downloading font: ${error.message}`); + return false; + } +} + +// Assume the MIME type based on the file's extension +function getFontMimeType(url) { + const extension = url.split('.').pop().toLowerCase().split('?')[0]; + switch (extension) { + case 'woff': + return 'font/woff'; + case 'woff2': + return 'font/woff2'; + case 'ttf': + return 'font/ttf'; + case 'otf': + return 'font/otf'; + default: + return 'application/octet-stream'; + } +} + +/** + * Event listeners and initialization. + * + * 1. Enter URL and analyze + * 2. Fetch the HTML through a CORS proxy + * 3. Parse HTML to find: + * - Direct font links + * - Stylesheet links + * - Inline styles + * 4. Process each CSS source to find @font-face rules + * 5. Display results with preview/download options + */ +document.addEventListener('DOMContentLoaded', () => { + const urlInput = document.getElementById('urlInput'); + const analyzeBtn = document.getElementById('analyzeBtn'); + const resultsDiv = document.getElementById('results'); + const errorDiv = document.getElementById('error'); + + /** + * Two different methods to load the font: + * - Using a data URL + * - Fallback: Using a blob URL if data URL fails + * + * @param {string} url - The URL of the font file to preview + * @param {string} fontFamily - The font-family name to use + * @returns {Promise<boolean>} - Whether the font was successfully loaded + */ + async function previewFont(url, fontFamily) { + try { + console.log('Loading font from:', url); + const response = await fetchWithProxies(url, 0, true); + + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength === 0) { + throw new Error('Received empty font file'); + } + + // Convert ArrayBuffer to Blob + const blob = new Blob([arrayBuffer], { type: getFontMimeType(url) }); + + // Try using a data URL instead of a blob URL + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onload = async function() { + try { + const dataUrl = reader.result; + + const fontFace = new FontFace(fontFamily, `url(${dataUrl})`, { + style: 'normal', + weight: '400', + display: 'swap' + }); + + const loadedFont = await fontFace.load(); + document.fonts.add(loadedFont); + + resolve(true); + } catch (loadError) { + console.error('Font load error:', loadError); + + // Try fallback method with blob URL + try { + const fontUrl = URL.createObjectURL(blob); + const fontFace = new FontFace(fontFamily, `url(${fontUrl})`, { + style: 'normal', + weight: '400', + display: 'swap' + }); + + const loadedFont = await fontFace.load(); + document.fonts.add(loadedFont); + + URL.revokeObjectURL(fontUrl); + resolve(true); + } catch (fallbackError) { + console.error('Font fallback load error:', fallbackError); + reject(fallbackError); + } + } + }; + + reader.onerror = function() { + reject(new Error('Failed to read font file')); + }; + + reader.readAsDataURL(blob); + }); + } catch (error) { + console.error('Error loading font for preview:', error); + return false; + } + } + + /** + * Click handler for the Analyze button. + * + * 1. Validate input URL + * 2. Fetch and parse webpage + * 3. Look for fonts in: + * - Direct links (preload, regular links) + * - CSS files (external and inline) + * - Common font directories + * 4. Display results + */ + analyzeBtn.addEventListener('click', async () => { + const url = urlInput.value.trim(); + if (!url) { + showError('Please enter a valid URL'); + return; + } + + try { + // Clear previous results and errors + resultsDiv.innerHTML = ''; + errorDiv.style.display = 'none'; + + // Show loading state + resultsDiv.innerHTML = '<p>Analyzing webpage. Sometimes this takes a while.</p>'; + + // Fetch the target webpage through the proxy system + const response = await fetchWithProxies(url); + const html = await response.text(); + + // Create a temporary DOM to parse the HTML + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + // Find all potential font sources + const fontUrls = new Set(); + + // Get the base URL (domain) + const baseUrlObj = new URL(url); + const domain = baseUrlObj.origin; + + // Brute force common font paths, for scenarios where the font is not found in the css + const commonFontPaths = [ + '/assets/fonts/', + '/fonts/', + '/assets/', + '/css/fonts/', + '/wp-content/themes/*/fonts/', + '/static/fonts/' + ]; + + console.log('Checking common font paths for:', domain); + + // Check for direct font links in the HTML + extractDirectFontLinks(doc, url, fontUrls); + + // Check stylesheet links + const cssPromises = Array.from(doc.querySelectorAll('link[rel="stylesheet"]')).map(link => { + const href = link.getAttribute('href'); + if (!href) return Promise.resolve(); + + const cssUrl = new URL(href, url).href; + console.log('Processing CSS URL:', cssUrl); + return processCssUrl(cssUrl, fontUrls, url); + }); + + // Wait for all CSS files to be processed + await Promise.all(cssPromises.filter(Boolean)); + + // Check style tags + doc.querySelectorAll('style').forEach(style => { + extractFontUrlsFromCss(style.textContent, url, fontUrls); + }); + + // If no fonts found, try checking common paths + if (fontUrls.size === 0) { + console.log('No fonts found in CSS, checking common paths...'); + + const commonPathPromises = commonFontPaths.map(async path => { + try { + // Try to access the directory + const directoryUrl = new URL(path, domain).href; + console.log('Checking directory:', directoryUrl); + + // We can't list directory contents directly, but we can try common font names + const commonFontNames = ['font', 'fonts', 'webfont', 'custom-font', 'main']; + const fontExtensions = ['woff', 'woff2', 'ttf', 'otf']; + + for (const name of commonFontNames) { + for (const ext of fontExtensions) { + const fontUrl = `${directoryUrl}${name}.${ext}`; + try { + const fontResponse = await fetchWithProxies(fontUrl, 0, true); + if (fontResponse.ok) { + console.log('Found font at common path:', fontUrl); + fontUrls.add({ + url: fontUrl, + family: 'Unknown Font', + filename: `${name}.${ext}` + }); + } + } catch (error) { + // Ignore errors for common path checks + } + } + } + } catch (error) { + // Ignore errors for common path checks + } + }); + + await Promise.all(commonPathPromises); + } + + // Display results + if (fontUrls.size === 0) { + resultsDiv.innerHTML = '<p>No web fonts (WOFF/TTF/WOFF2/OTF) were found on this page.</p>'; + } else { + displayFontUrls(fontUrls); + } + + } catch (error) { + showError(`Error analyzing the webpage: ${error.message}`); + console.error('Full error:', error); + } + }); + + /** + * Processes a the URL of a CSS file to extract font information. + * + * @param {string} cssUrl - The URL of the CSS file to process + * @param {Set} fontUrls - Set to store found font URLs + * @param {string} baseUrl - Base URL for resolving relative paths + */ + async function processCssUrl(cssUrl, fontUrls, baseUrl) { + try { + console.log('Fetching CSS from:', cssUrl); + const response = await fetchWithProxies(cssUrl); + const css = await response.text(); + + // Extract font URLs from the CSS content + extractFontUrlsFromCss(css, cssUrl, fontUrls); + } catch (error) { + console.error(`Error processing CSS from ${cssUrl}:`, error); + } + } + + /** + * Extract font URLs from CSS content + * + * @param {string} css - The CSS content to process + * @param {string} cssUrl - The URL of the CSS file (for resolving relative paths) + * @param {Set} fontUrls - Set to store found font URLs + */ + function extractFontUrlsFromCss(css, cssUrl, fontUrls) { + // Get the base URL for resolving relative paths + const baseUrl = new URL(cssUrl).origin; + + // Match @font-face blocks + const fontFaceRegex = /@font-face\s*{[^}]*}/g; + const urlRegex = /url\(['"]?([^'"\)]+)['"]?\)/g; + const fontFamilyRegex = /font-family\s*:\s*['"]?([^'";]*)['"]?/; + + let fontFaceMatch; + while ((fontFaceMatch = fontFaceRegex.exec(css)) !== null) { + const fontFaceBlock = fontFaceMatch[0]; + + // Extract font-family name + const familyMatch = fontFaceBlock.match(fontFamilyRegex); + const fontFamily = familyMatch ? familyMatch[1].trim() : 'Unknown Font'; + + // Clean up the CSS rule for display + const cleanRule = fontFaceBlock.replace(/\s+/g, ' ').trim(); + + // Extract all URLs from this @font-face block + let urlMatch; + while ((urlMatch = urlRegex.exec(fontFaceBlock)) !== null) { + try { + let fontUrl = urlMatch[1].trim(); + + // Skip data: URLs + if (fontUrl.startsWith('data:')) { + console.log('Skipping data: URL font'); + continue; + } + + // Only process known font file types + if (!fontUrl.match(/\.(woff2?|ttf|otf|eot)(\?.*)?$/i)) { + continue; + } + + // Resolve relative URLs + if (fontUrl.startsWith('//')) { + fontUrl = 'https:' + fontUrl; + } else if (!fontUrl.startsWith('http')) { + fontUrl = new URL(fontUrl, cssUrl).href; + } + + const filename = fontUrl.split('/').pop().split('?')[0]; + console.log(`Found font in CSS: ${fontUrl} (${fontFamily})`); + + fontUrls.add({ + url: fontUrl, + family: fontFamily, + filename: filename, + cssRule: cleanRule + }); + } catch (error) { + console.error('Error processing font URL:', urlMatch[1], error); + } + } + } + } + + /** + * Find direct font links in HTML. + * Checks two types of links: + * 1. Preload links with as="font" + * 2. Regular <a> tags pointing to font files + * + * @param {Document} doc - Parsed HTML document + * @param {string} baseUrl - Base URL for resolving relative paths + * @param {Set} fontUrls - Set to store found font URLs + */ + function extractDirectFontLinks(doc, baseUrl, fontUrls) { + // Check for preload links + doc.querySelectorAll('link[rel="preload"][as="font"], link[rel="stylesheet"]').forEach(link => { + const href = link.getAttribute('href'); + if (href && href.match(/\.(woff|woff2|ttf|otf|css)(\?.*)?$/i)) { + try { + const absoluteUrl = new URL(href, baseUrl).href; + if (href.match(/\.css(\?.*)?$/i)) { + processCssUrl(absoluteUrl, fontUrls, baseUrl); + } else { + const filename = href.split('/').pop().split('?')[0]; + let fontFamilyName = 'Unknown Font'; + if (link.dataset.fontFamily) { + fontFamilyName = link.dataset.fontFamily; + } + + console.log(`Found preloaded font: ${absoluteUrl} (${fontFamilyName})`); + fontUrls.add({ + url: absoluteUrl, + family: fontFamilyName, + filename: filename, + cssRule: `@font-face { font-family: "${fontFamilyName}"; src: url("${absoluteUrl}") format("${getFormatFromFilename(filename)}"); }` + }); + } + } catch (error) { + console.error('Error resolving font URL:', href, error); + } + } + }); + } + + /** + * Get the format string for a font file based on its filename + * @param {string} filename + * @returns {string} + */ + function getFormatFromFilename(filename) { + const ext = filename.split('.').pop().toLowerCase(); + switch (ext) { + case 'woff2': + return 'woff2'; + case 'woff': + return 'woff'; + case 'ttf': + return 'truetype'; + case 'otf': + return 'opentype'; + default: + return ext; + } + } + + /** + * The V of MVC + * + * - Auto-preview for 3 or fewer fonts + * - Manual preview toggle for 4+ fonts + * - Download buttons + * - Font information display + * - CSS rule display + * - Preview with multiple sizes + * + * @param {Array} urls - Array of font data objects to display + */ + async function displayFontUrls(urls) { + const resultsDiv = document.getElementById('results'); + resultsDiv.innerHTML = ''; + + let fontsArray = Array.isArray(urls) ? urls : Array.from(urls); + + const fontFamilies = new Map(); + fontsArray.forEach(fontData => { + if (!fontFamilies.has(fontData.family)) { + fontFamilies.set(fontData.family, { + variants: [], + // Keep the first CSS rule as the base rule + cssRule: fontData.cssRule + }); + } + fontFamilies.get(fontData.family).variants.push(fontData); + }); + + fontsArray = Array.from(fontFamilies.entries()).map(([family, data]) => ({ + family, + variants: data.variants, + cssRule: data.cssRule + })); + + if (fontsArray.length === 0) { + resultsDiv.innerHTML = '<p>No fonts found on this webpage.</p>'; + return; + } + + const container = document.createElement('div'); + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '2rem'; + container.style.maxWidth = '800px'; + container.style.margin = '0 auto'; + + const shouldAutoPreview = fontsArray.length < 4; + + for (let fontData of fontsArray) { + const fontItem = document.createElement('div'); + fontItem.style.border = '2px solid var(--dark)'; + fontItem.style.padding = '1rem'; + fontItem.style.background = 'var(--beige)'; + fontItem.style.position = 'relative'; + + const accentBar = document.createElement('div'); + accentBar.style.position = 'absolute'; + accentBar.style.top = '0'; + accentBar.style.left = '0'; + accentBar.style.right = '0'; + accentBar.style.height = '4px'; + accentBar.style.background = 'var(--accent)'; + fontItem.appendChild(accentBar); + + const fontInfo = document.createElement('div'); + fontInfo.style.marginTop = '0.5rem'; + + const fontName = document.createElement('h3'); + fontName.style.margin = '0'; + fontName.style.textTransform = 'uppercase'; + fontName.textContent = fontData.family; + fontInfo.appendChild(fontName); + + // Show all available formats + const formatContainer = document.createElement('div'); + formatContainer.style.display = 'flex'; + formatContainer.style.gap = '0.5rem'; + formatContainer.style.flexWrap = 'wrap'; + formatContainer.style.marginTop = '0.5rem'; + + const uniqueFormats = new Set(fontData.variants.map(v => v.filename.split('.').pop().toUpperCase())); + uniqueFormats.forEach(format => { + const formatBadge = document.createElement('div'); + formatBadge.style.display = 'inline-block'; + formatBadge.style.background = 'var(--dark)'; + formatBadge.style.color = 'var(--beige)'; + formatBadge.style.padding = '0.2rem 0.5rem'; + formatBadge.style.fontSize = '0.8rem'; + formatBadge.textContent = format; + formatContainer.appendChild(formatBadge); + }); + fontInfo.appendChild(formatContainer); + + const previewContainer = document.createElement('div'); + previewContainer.style.marginTop = '1rem'; + previewContainer.style.padding = '1rem'; + previewContainer.style.border = '1px dashed var(--dark)'; + + // Assume hidden if not auto-previewing + if (!shouldAutoPreview) { + previewContainer.style.display = 'none'; + } + + const previewLabel = document.createElement('div'); + previewLabel.style.fontWeight = 'bold'; + previewLabel.style.marginBottom = '0.5rem'; + previewLabel.textContent = 'Preview'; + previewContainer.appendChild(previewLabel); + + // Create preview for each style variation + const previewText = 'Society for me my misery<br>Since Gift of Thee --'; // Emily Dickinson + const styleVariations = document.createElement('div'); + styleVariations.style.display = 'flex'; + styleVariations.style.flexDirection = 'column'; + styleVariations.style.gap = '1rem'; + + // Preview for each variant + fontData.variants.forEach((variant, index) => { + const variantPreview = document.createElement('div'); + variantPreview.style.marginBottom = '1rem'; + variantPreview.id = `preview-${variant.filename}-${index}`; + variantPreview.innerHTML = previewText; + + // Extract style information from CSS rule + let styleInfo = 'Regular'; + if (variant.cssRule) { + const fontStyle = variant.cssRule.match(/font-style:\s*([^;]+)/); + const fontWeight = variant.cssRule.match(/font-weight:\s*([^;]+)/); + if (fontStyle || fontWeight) { + styleInfo = [ + fontWeight?.[1] || '', + fontStyle?.[1] !== 'normal' ? fontStyle?.[1] : '' + ].filter(Boolean).join(' ') || 'Regular'; + } + } + + const styleLabel = document.createElement('div'); + styleLabel.style.fontSize = '0.8rem'; + styleLabel.style.color = 'var(--dark)'; + styleLabel.style.marginBottom = '0.25rem'; + styleLabel.textContent = styleInfo; + + const variantContainer = document.createElement('div'); + variantContainer.appendChild(styleLabel); + variantContainer.appendChild(variantPreview); + styleVariations.appendChild(variantContainer); + }); + + previewContainer.appendChild(styleVariations); + + if (fontData.cssRule) { + const cssContainer = document.createElement('div'); + cssContainer.style.marginTop = '1rem'; + cssContainer.style.marginBottom = '1rem'; + cssContainer.style.padding = '1rem'; + cssContainer.style.background = 'var(--dark)'; + cssContainer.style.color = 'var(--beige)'; + cssContainer.style.borderRadius = '4px'; + cssContainer.style.position = 'relative'; + + const cssLabel = document.createElement('div'); + cssLabel.style.position = 'absolute'; + cssLabel.style.top = '-10px'; + cssLabel.style.left = '10px'; + cssLabel.style.background = 'var(--accent)'; + cssLabel.style.color = 'var(--dark)'; + cssLabel.style.padding = '0 0.5rem'; + cssLabel.style.fontSize = '0.8rem'; + cssLabel.style.fontWeight = 'bold'; + cssLabel.textContent = '@font-face'; + cssContainer.appendChild(cssLabel); + + const cssContent = document.createElement('pre'); + cssContent.style.margin = '0'; + cssContent.style.fontFamily = 'monospace'; + cssContent.style.fontSize = '0.9rem'; + cssContent.style.whiteSpace = 'pre-wrap'; + cssContent.style.wordBreak = 'break-all'; + + const allCssRules = fontData.variants.map(variant => { + if (!variant.cssRule) return ''; + return variant.cssRule + .replace(/{/, ' {\n ') + .replace(/;/g, ';\n ') + .replace(/}/g, '\n}') + .replace(/\s+}/g, '}') + .trim(); + }).join('\n\n'); + + cssContent.textContent = allCssRules; + cssContainer.appendChild(cssContent); + + fontInfo.appendChild(cssContainer); + } + + const sizeVariations = document.createElement('div'); + sizeVariations.style.borderTop = '1px solid var(--dark)'; + sizeVariations.style.paddingTop = '0.5rem'; + sizeVariations.style.marginTop = '0.5rem'; + + [12, 18, 24].forEach(size => { + const sizePreview = document.createElement('div'); + sizePreview.style.fontSize = `${size}px`; + sizePreview.textContent = `${size}px - The quick brown fox jumps over the lazy dog 0123456789`; + sizeVariations.appendChild(sizePreview); + }); + + previewContainer.appendChild(sizeVariations); + + const buttonContainer = document.createElement('div'); + buttonContainer.style.display = 'flex'; + buttonContainer.style.gap = '0.5rem'; + buttonContainer.style.marginTop = '1rem'; + buttonContainer.style.flexWrap = 'wrap'; + + const uniqueDownloads = new Map(); + fontData.variants.forEach(variant => { + if (!uniqueDownloads.has(variant.url)) { + uniqueDownloads.set(variant.url, { + filename: variant.filename, + url: variant.url + }); + } + }); + + uniqueDownloads.forEach(({filename, url}) => { + const downloadBtn = document.createElement('button'); + downloadBtn.textContent = `⬇ Download ${filename}`; + downloadBtn.style.flex = '1'; + downloadBtn.addEventListener('click', () => downloadFont(url, filename)); + buttonContainer.appendChild(downloadBtn); + }); + + if (!shouldAutoPreview) { + const previewBtn = document.createElement('button'); + previewBtn.textContent = '👁 Preview'; + previewBtn.style.flex = '1'; + + let isPreviewVisible = false; + previewBtn.addEventListener('click', async () => { + if (!isPreviewVisible) { + previewContainer.style.display = 'block'; + + const loadPromises = fontData.variants.map(async (variant, index) => { + const previewElement = document.getElementById(`preview-${variant.filename}-${index}`); + if (await previewFont(variant.url, fontData.family)) { + previewElement.style.fontFamily = fontData.family; + if (variant.cssRule) { + const fontStyle = variant.cssRule.match(/font-style:\s*([^;]+)/); + const fontWeight = variant.cssRule.match(/font-weight:\s*([^;]+)/); + if (fontStyle) previewElement.style.fontStyle = fontStyle[1]; + if (fontWeight) previewElement.style.fontWeight = fontWeight[1]; + } + } + }); + + await Promise.all(loadPromises); + + sizeVariations.querySelectorAll('div').forEach(div => { + div.style.fontFamily = fontData.family; + }); + + previewBtn.textContent = '👁 Hide Preview'; + isPreviewVisible = true; + } else { + previewContainer.style.display = 'none'; + previewBtn.textContent = '👁 Preview'; + isPreviewVisible = false; + } + }); + buttonContainer.appendChild(previewBtn); + } else { + // Auto-preview for all variants + setTimeout(async () => { + const loadPromises = fontData.variants.map(async (variant, index) => { + const previewElement = document.getElementById(`preview-${variant.filename}-${index}`); + if (await previewFont(variant.url, fontData.family)) { + previewElement.style.fontFamily = fontData.family; + if (variant.cssRule) { + const fontStyle = variant.cssRule.match(/font-style:\s*([^;]+)/); + const fontWeight = variant.cssRule.match(/font-weight:\s*([^;]+)/); + if (fontStyle) previewElement.style.fontStyle = fontStyle[1]; + if (fontWeight) previewElement.style.fontWeight = fontWeight[1]; + } + } + }); + + await Promise.all(loadPromises); + + sizeVariations.querySelectorAll('div').forEach(div => { + div.style.fontFamily = fontData.family; + }); + }, 100); + } + + fontItem.appendChild(fontInfo); + fontItem.appendChild(previewContainer); + fontItem.appendChild(buttonContainer); + container.appendChild(fontItem); + } + + resultsDiv.appendChild(container); + } + + function showError(message) { + errorDiv.textContent = message; + errorDiv.style.display = 'block'; + } +}); \ No newline at end of file diff --git a/html/immoral/bookmarklet.js b/html/immoral/bookmarklet.js new file mode 100644 index 0000000..7f61ec4 --- /dev/null +++ b/html/immoral/bookmarklet.js @@ -0,0 +1,564 @@ +(function() { + // Prevent multiple instances from running at once + if (window.immoralFontVacuum) { + alert('Web Font Vacuum is already running!'); + return; + } + window.immoralFontVacuum = true; + + const logCollector = { + logs: [], + group: function(label) { + this.logs.push(`\n### ${label}`); + }, + groupEnd: function() { + this.logs.push(`### End Group\n`); + }, + log: function(...args) { + this.logs.push(args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg + ).join(' ')); + }, + warn: function(...args) { + this.logs.push(`⚠️ ${args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg + ).join(' ')}`); + }, + error: function(...args) { + this.logs.push(`❌ ${args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg + ).join(' ')}`); + }, + getReport: function() { + return `Font Vacuum Report +================== +Time: ${new Date().toISOString()} +URL: ${window.location.href} + +${this.logs.join('\n')}`; + } + }; + + const styleRoot = document.createElement('div'); + styleRoot.className = 'fv-root'; + styleRoot.style.all = 'initial'; // Reset all styles + + const style = document.createElement('style'); + style.textContent = ` + .fv-root { + font: 16px system-ui, -apple-system, sans-serif; + color: #333333; + line-height: 1.4; + box-sizing: border-box; + } + + .fv-root * { + box-sizing: inherit; + font-family: inherit; + line-height: inherit; + color: inherit; + } + + .fv-container { + position: fixed; + top: 20px; + right: 20px; + width: 400px; + max-height: 90vh; + background: #f5f5f5; + z-index: 999999; + overflow-y: auto; + display: flex; + flex-direction: column; + border: 3px solid #333; + box-shadow: 8px 8px 0 #ff4d00; + } + + .fv-header { + padding: 1rem; + background: #333333; + color: #f5f5f5; + display: flex; + justify-content: space-between; + align-items: center; + cursor: move; + user-select: none; + flex-shrink: 0; + } + + .fv-header h1 { + margin: 0; + font-size: 1.1rem; + line-height: 1; + } + + .fv-close { + background: none; + border: none; + color: #f5f5f5; + cursor: pointer; + font-size: 1.5rem; + padding: 0; + margin: 0; + line-height: 1; + display: flex; + align-items: center; + } + + .fv-content { + padding: 1rem; + overflow-y: auto; + flex-grow: 1; + } + + .fv-footer { + padding: 0.75rem 1rem; + background: #333333; + color: #f5f5f5; + display: flex; + justify-content: flex-end; + align-items: center; + flex-shrink: 0; + } + + .fv-footer-button { + background: #555; + color: #f5f5f5; + border: none; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9em; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .fv-footer-button:hover { + background: #666; + } + + .fv-font-item { + margin-bottom: 1rem; + padding: 1rem; + border: 1px solid #ddd; + background: #ffffff; + } + + .fv-font-item h3 { + margin: 0 0 1rem 0; + padding: 0; + font-size: 1.1em; + font-weight: 600; + } + + .fv-preview { + margin: 1rem 0; + padding: 1rem; + border: 1px dashed #333; + background: #ffffff; + } + + .fv-button { + background: #333; + color: #f5f5f5; + border: none; + padding: 0.5rem 1rem; + cursor: pointer; + margin: 0.25rem 0.5rem 0.25rem 0; + font-size: 0.9em; + } + + .fv-button:last-child { + margin-right: 0; + } + + .fv-button:hover { + background: #444; + } + `; + styleRoot.appendChild(style); + document.body.appendChild(styleRoot); + + const container = document.createElement('div'); + container.className = 'fv-container'; + styleRoot.appendChild(container); + + let isDragging = false; + let currentX; + let currentY; + let initialX; + let initialY; + let xOffset = 0; + let yOffset = 0; + + const header = document.createElement('header'); + header.className = 'fv-header'; + header.innerHTML = ` + <h1>Web Font Vacuum</h1> + <button class="fv-close">×</button> + `; + + header.addEventListener('mousedown', dragStart); + document.addEventListener('mousemove', drag); + document.addEventListener('mouseup', dragEnd); + + function dragStart(e) { + initialX = e.clientX - xOffset; + initialY = e.clientY - yOffset; + if (e.target === header) { + isDragging = true; + } + } + + function drag(e) { + if (isDragging) { + e.preventDefault(); + currentX = e.clientX - initialX; + currentY = e.clientY - initialY; + xOffset = currentX; + yOffset = currentY; + container.style.transform = `translate(${currentX}px, ${currentY}px)`; + } + } + + function dragEnd() { + isDragging = false; + } + + header.querySelector('.fv-close').addEventListener('click', () => { + document.body.removeChild(styleRoot); + window.immoralFontVacuum = false; + }); + + const content = document.createElement('div'); + content.className = 'fv-content'; + + function extractFontUrls(cssText, baseUrl) { + const fontUrls = []; + const fontFaceRegex = /@font-face\s*{[^}]*}/g; + const urlRegex = /url\(['"]?([^'"\)]+)['"]?\)/g; + const fontFamilyRegex = /font-family\s*:\s*['"]?([^'";]*)['"]?/; + + function resolveUrl(url, base) { + try { + // Protocol-relative URLs + if (url.startsWith('//')) { + return `${location.protocol}${url}`; + } + // Absolute URLs + if (url.match(/^https?:\/\//)) { + return url; + } + // Root-relative URLs + if (url.startsWith('/')) { + return `${location.origin}${url}`; + } + // Relative URLs - use stylesheet URL as base if available + return new URL(url, base || location.href).href; + } catch (e) { + console.warn('Failed to resolve URL:', url, e); + return url; + } + } + + let fontFaceMatch; + while ((fontFaceMatch = fontFaceRegex.exec(cssText)) !== null) { + const fontFaceBlock = fontFaceMatch[0]; + const familyMatch = fontFaceBlock.match(fontFamilyRegex); + const fontFamily = familyMatch ? familyMatch[1].trim() : 'Unknown Font'; + + let urlMatch; + while ((urlMatch = urlRegex.exec(fontFaceBlock)) !== null) { + let fontUrl = urlMatch[1].trim(); + + // Skip data: URLs + if (fontUrl.startsWith('data:')) continue; + + // Only process known font file types + if (!fontUrl.match(/\.(woff2?|ttf|otf|eot)(\?.*)?$/i)) continue; + + // Resolve the URL relative to the stylesheet's URL + fontUrl = resolveUrl(fontUrl, baseUrl); + + fontUrls.push({ + family: fontFamily, + url: fontUrl, + filename: fontUrl.split('/').pop().split('?')[0], + cssRule: fontFaceBlock + }); + } + } + return fontUrls; + } + + function findFonts() { + const fonts = new Map(); + logCollector.group('Font Vacuum: Scanning Stylesheets'); + + logCollector.log(`Found ${document.styleSheets.length} stylesheets`); + + for (const sheet of document.styleSheets) { + try { + const baseUrl = sheet.href; + logCollector.group(`Stylesheet: ${baseUrl || 'inline'}`); + const cssRules = sheet.cssRules || sheet.rules; + logCollector.log(`- Rules found: ${cssRules.length}`); + + let cssText = ''; + let fontFaceCount = 0; + for (const rule of cssRules) { + if (rule.constructor.name === 'CSSFontFaceRule') { + fontFaceCount++; + } + cssText += rule.cssText + '\n'; + } + logCollector.log(`- @font-face rules found: ${fontFaceCount}`); + + const fontUrls = extractFontUrls(cssText, baseUrl); + logCollector.log(`- Font URLs extracted: ${fontUrls.length}`); + fontUrls.forEach(font => { + logCollector.log(` • ${font.family}: ${font.url}`); + if (!fonts.has(font.family)) { + fonts.set(font.family, { + variants: [], + cssRule: font.cssRule + }); + } + fonts.get(font.family).variants.push(font); + }); + logCollector.groupEnd(); + } catch (e) { + logCollector.warn(`Could not access stylesheet:`, sheet.href, e); + logCollector.groupEnd(); + } + } + + const results = Array.from(fonts.entries()).map(([family, data]) => ({ + family, + variants: data.variants, + cssRule: data.cssRule + })); + + logCollector.log('Final Results:', { + totalFamilies: results.length, + families: results.map(f => ({ + family: f.family, + variants: f.variants.length, + urls: f.variants.map(v => v.url) + })) + }); + logCollector.groupEnd(); + + return results; + } + + async function downloadFont(url, filename) { + try { + logCollector.group(`Font Vacuum: Downloading ${filename} from ${url}`); + + logCollector.log('Searching for existing font-face rule...'); + const existingFontRule = Array.from(document.styleSheets) + .flatMap(sheet => { + try { + return Array.from(sheet.cssRules); + } catch (e) { + return []; + } + }) + .find(rule => + rule.constructor.name === 'CSSFontFaceRule' && + rule.cssText.includes(url) + ); + + logCollector.log('Existing font-face rule found:', !!existingFontRule); + let response; + + if (existingFontRule) { + logCollector.log('Attempting to fetch using existing rule credentials...'); + const fontBlob = await fetch(url, { + mode: 'cors', + credentials: 'include', + headers: { + 'Origin': window.location.origin + } + }).then(r => r.blob()); + response = new Response(fontBlob); + } else { + logCollector.log('No existing rule found, attempting direct fetch...'); + response = await fetch(url, { + mode: 'cors', + credentials: 'include', + headers: { + 'Origin': window.location.origin + } + }); + } + + if (!response.ok) { + throw new Error(`Network response was not ok. Status: ${response.status}`); + } + + logCollector.log('Font fetched successfully, preparing download...'); + const blob = await response.blob(); + logCollector.log('Font blob size:', blob.size, 'bytes'); + + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + setTimeout(() => URL.revokeObjectURL(objectUrl), 100); + logCollector.log('Download initiated successfully'); + logCollector.groupEnd(); + return true; + } catch (error) { + logCollector.error('Error downloading font:', error); + logCollector.groupEnd(); + alert(`Error downloading font: ${error.message}\n\nTroubleshooting tips:\n1. Check the console for detailed logs\n2. Try using your browser's developer tools Network tab to find and download the font file directly\n3. Some sites may block direct font downloads`); + return false; + } + } + + async function previewFont(url, fontFamily) { + try { + logCollector.group(`Font Vacuum: Previewing ${fontFamily} from ${url}`); + + const existingFontRule = Array.from(document.styleSheets) + .flatMap(sheet => { + try { + return Array.from(sheet.cssRules); + } catch (e) { + return []; + } + }) + .find(rule => + rule.constructor.name === 'CSSFontFaceRule' && + rule.cssText.includes(url) + ); + + if (existingFontRule) { + logCollector.log('Using existing font-face rule for preview'); + logCollector.groupEnd(); + return true; + } + + logCollector.log('No existing rule found, attempting to load font...'); + const response = await fetch(url, { + mode: 'cors', + credentials: 'include', + headers: { + 'Origin': window.location.origin + } + }); + + if (!response.ok) { + throw new Error(`Network response was not ok. Status: ${response.status}`); + } + + const blob = await response.blob(); + logCollector.log('Font blob size:', blob.size, 'bytes'); + const fontUrl = URL.createObjectURL(blob); + + const fontFace = new FontFace(fontFamily, `url(${fontUrl})`, { + style: 'normal', + weight: '400', + display: 'swap' + }); + + const loadedFont = await fontFace.load(); + document.fonts.add(loadedFont); + URL.revokeObjectURL(fontUrl); + + logCollector.log('Font loaded successfully'); + logCollector.groupEnd(); + return true; + } catch (error) { + logCollector.error('Error loading font:', error); + logCollector.groupEnd(); + return false; + } + } + + const fonts = findFonts(); + + if (fonts.length === 0) { + content.innerHTML = '<p>No web fonts found on this page.</p>'; + } else { + fonts.forEach(fontData => { + const fontItem = document.createElement('div'); + fontItem.className = 'fv-font-item'; + + const fontName = document.createElement('h3'); + fontName.style.margin = '0 0 1rem 0'; + fontName.textContent = fontData.family; + fontItem.appendChild(fontName); + + const preview = document.createElement('div'); + preview.className = 'fv-preview'; + preview.innerHTML = '0123456789<br><br>Society for me my misery<br>Since Gift of Thee --<br><br>The quick brown fox jumps over the lazy dog!?'; + fontItem.appendChild(preview); + + const uniqueDownloads = new Map(); + fontData.variants.forEach(variant => { + if (!uniqueDownloads.has(variant.url)) { + uniqueDownloads.set(variant.url, { + filename: variant.filename, + url: variant.url + }); + } + }); + + const buttonContainer = document.createElement('div'); + buttonContainer.style.marginTop = '1rem'; + + uniqueDownloads.forEach(({filename, url}) => { + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'fv-button'; + downloadBtn.textContent = `⬇ Download ${filename}`; + downloadBtn.addEventListener('click', () => downloadFont(url, filename)); + buttonContainer.appendChild(downloadBtn); + }); + + fontItem.appendChild(buttonContainer); + + fontData.variants.forEach(async (variant) => { + if (await previewFont(variant.url, fontData.family)) { + preview.style.fontFamily = fontData.family; + if (variant.cssRule) { + const fontStyle = variant.cssRule.match(/font-style:\s*([^;]+)/); + const fontWeight = variant.cssRule.match(/font-weight:\s*([^;]+)/); + if (fontStyle) preview.style.fontStyle = fontStyle[1]; + if (fontWeight) preview.style.fontWeight = fontWeight[1]; + } + } + }); + + content.appendChild(fontItem); + }); + } + + const footer = document.createElement('div'); + footer.className = 'fv-footer'; + + const reportBtn = document.createElement('button'); + reportBtn.className = 'fv-footer-button'; + reportBtn.innerHTML = '<span>📋</span><span>Copy Debug Report</span>'; + reportBtn.addEventListener('click', () => { + const report = logCollector.getReport(); + navigator.clipboard.writeText(report).then(() => { + reportBtn.innerHTML = '<span>✅</span><span>Report Copied!</span>'; + setTimeout(() => { + reportBtn.innerHTML = '<span>📋</span><span>Copy Debug Report</span>'; + }, 2000); + }); + }); + + footer.appendChild(reportBtn); + container.appendChild(header); + container.appendChild(content); + container.appendChild(footer); + styleRoot.appendChild(container); +})(); \ No newline at end of file diff --git a/html/immoral/index.html b/html/immoral/index.html new file mode 100644 index 0000000..b7d3ca4 --- /dev/null +++ b/html/immoral/index.html @@ -0,0 +1,221 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>An Immoral Web Font Vacuum</title> + <meta name="description" content="Enter a URL to find, preview, and download web fonts (WOFF/TTF/WOFF2/OTF) present on the page."> + <style> + :root { + --beige: #f5f2e9; + --dark: #111111; + --accent: #ff4d00; + --grid-line: #ccbea7; + --container-bg: #ffffff; + --focus-outline: #2563eb; + } + + body { + font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif; + max-width: 900px; + margin: 0 auto; + padding: 1rem; + line-height: 1.5; + background: var(--beige); + color: var(--dark); + } + + h1, h2 { + text-transform: uppercase; + letter-spacing: 2px; + border-bottom: 3px solid var(--accent); + padding-bottom: 0.5rem; + font-weight: 900; + } + + .container { + background: var(--container-bg); + padding: 2rem; + border: 3px solid var(--dark); + box-shadow: 8px 8px 0 var(--dark); + margin-top: 2rem; + } + + .input-group { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + border: 2px solid var(--dark); + padding: 1rem; + background: var(--beige); + } + + @media (max-width: 600px) { + .input-group { + flex-direction: column; + gap: 0.75rem; + } + + .input-group button { + width: 100%; + } + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + input[type="url"] { + flex: 1; + padding: 0.75rem; + font-size: 1rem; + border: 2px solid var(--dark); + background: var(--beige); + font-family: 'Courier New', monospace; + } + + input[type="url"]:focus { + outline: 3px solid var(--focus-outline); + outline-offset: 2px; + } + + button { + padding: 0.75rem 1.5rem; + font-size: 1rem; + background: var(--dark); + color: var(--beige); + border: 2px solid var(--dark); + cursor: pointer; + text-transform: uppercase; + font-weight: bold; + font-family: 'Courier New', monospace; + transition: all 0.2s; + } + + button:hover, + button:focus-visible { + background: var(--accent); + transform: translateY(-2px); + outline: 3px solid var(--focus-outline); + outline-offset: 2px; + } + + button:focus:not(:focus-visible) { + outline: none; + } + + .error { + color: var(--accent); + padding: 1rem; + background: rgba(255, 77, 0, 0.1); + border: 2px solid var(--accent); + margin-top: 1rem; + display: none; + font-weight: bold; + } + + #results { + margin-top: 1rem; + } + + .immoral { + color: #cc0000; + text-shadow: + 0 0 1px #fff, + 0 0 3px #fff, + 0 0 5px #ff0000, + 2px 2px 0 #600; + font-weight: 900; + letter-spacing: 2px; + transform: skew(-5deg); + display: inline-block; + position: relative; + padding: 0 4px; + } + + .immoral::first-letter { + font-size: 1.2em; + text-shadow: + 0 0 1px #fff, + 0 0 3px #fff, + 0 0 5px #ff0000, + 2px 2px 0 #800; + } + + footer { + margin-top: 1rem; + margin-bottom: 2rem; + } + + footer abbr { + color: var(--accent); + text-decoration: none; + border-bottom: 1px dotted var(--dark); + } + + footer abbr:hover { + cursor: help; + } + </style> +</head> +<body> + <main class="container"> + <h1><span class="immoral">Immoral</span> Web Font Vacuum</h1> + <p>Enter a URL to find, preview, and download web fonts (WOFF/TTF/WOFF2/OTF) present on the page.</p> + <section id="urlForm"> + <form class="input-group" role="search" aria-label="Website URL search form" onsubmit="event.preventDefault();"> + <label for="urlInput" class="sr-only">Website URL</label> + <input + type="url" + id="urlInput" + name="urlInput" + placeholder="Enter website URL (e.g., https://example.com)" + required + aria-required="true" + aria-describedby="urlHint" + > + <span id="urlHint" class="sr-only">Enter the full website URL including https:// or http://</span> + <button + id="analyzeBtn" + type="submit" + aria-label="Find all web fonts on the website" + >Find Fonts</button> + </form> + </section> + <section id="error" class="error" role="alert" aria-live="polite"></section> + <section id="results" role="region" aria-label="Font analysis results"></section> + <section class="bookmarklet-section" style="margin: 2rem 0; padding: 1rem; border: 2px dashed var(--dark); background: var(--beige);"> + <h2>Web Font Vacuum Bookmarklet</h2> + <p>Drag this link to your bookmarks bar to vacuum web fonts from any webpage:</p> + <p style="text-align: center;"> + <a href="javascript:(function(){ + const script = document.createElement('script'); + script.src = 'https://smallandnearlysilent.com/immoral/bookmarklet.js'; + document.body.appendChild(script); + })()" + class="bookmarklet-link" + style="display: inline-block; padding: 0.5rem 1rem; background: var(--dark); color: var(--beige); text-decoration: none; border-radius: 4px; font-weight: bold;" + onclick="event.preventDefault(); alert('Drag this to your bookmarks bar!');"> + Web Font Vacuum + </a> + </p> + <p> + Use the bookmarklet on any webpage to find and download its fonts directly. + </p> + </section> + </main> + <footer> + <p class="immoral">A note for those among us on the web who don't love having to pipe web traffic through random, mostly unknown services:</p> + <p>Because of <abbr title="Cross-Origin Resource Sharing">CORS</abbr> I've had to funnel requests through a CORS proxy service, and, because I'm too lazy to host my own I'm using a random one I found after 11 minutes of searching...and because that one seemed unreliable I went ahead and found a few more to cycle through at every request. The CORS issue only comes to play when you use the form on this website. The bookmarklet runs in the same scope as the website that you run it against, so, no CORS issues at all. No weird, unknown and untrusted servers in the way. Bookmarklets, ftw.</p> + </footer> + <script src="app.js"></script> +</body> +</html> diff --git a/html/kgame/index.html b/html/kgame/index.html new file mode 100644 index 0000000..233c1d6 --- /dev/null +++ b/html/kgame/index.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>K-Grid Painter</title> + <link rel="stylesheet" href="style.css"> +</head> +<body> + <h1>K-Grid Painter</h1> + <p>Control the 50x50 grid below using simplified K-like commands.</p> + <p>The grid state is represented by the variable <code>G</code> (a list of 2500 integers, 0 or 1).</p> + <p>Example commands:</p> + <ul> + <li><code>G : 0</code> (Clear grid)</li> + <li><code>G : 1</code> (Fill grid)</li> + + <li><strong>Basic Operations:</strong></li> + <li><code>G @ 0 : 1</code> (Turn on cell at index 0)</li> + <li><code>G @ (!10) : 1</code> (Turn on first 10 cells - demonstrates ! operator)</li> + <li><code>G @ (100 + !50) : 1</code> (Turn on cells 100-149 - demonstrates + operator)</li> + <li><code>G @ (50 * !50) : 1</code> (Turn on first column - demonstrates * operator)</li> + <li><code>G @ (!2500) : (!2500) % 2</code> (Alternating vertical columns - demonstrates % operator)</li> + + <li><strong>Comparison Operators:</strong></li> + <li><code>G @ (!2500) : ((!2500) % 50) < 25</code> (Left half filled - demonstrates < operator)</li> + <li><code>G @ (!2500) : ((!2500) % 50) > 25</code> (Right half filled - demonstrates > operator)</li> + <li><code>G @ (!2500) : ((!2500) % 50) = 25</code> (Middle column - demonstrates = operator)</li> + + <li><strong>Complex Patterns:</strong></li> + <li><code>G @ (50 * !50 + 25) : 1</code> (Vertical line in middle - demonstrates row/col math)</li> + <li><code>G @ (!50 + 25 * 50) : 1</code> (Horizontal line in middle - demonstrates row/col math)</li> + <li><code>G @ (50 * !50 + !50) : 1</code> (Diagonal line from top-left - demonstrates row/col math)</li> + <li><code>G @ (50 * !50 + (49 - !50)) : 1</code> (Diagonal line from top-right - demonstrates - operator)</li> + <li><code>G @ (!2500) : ((!2500) / 50 + (!2500) % 50) % 2</code> (Checkerboard pattern - demonstrates / and % operators)</li> + + <li><strong>Unary Operators:</strong></li> + <li><code>G @ (!25) : ~(!25)</code> (First cell 1, rest 0 - demonstrates ~ not operator)</li> + <li><code>G @ (!25) : |(!25)</code> (Reverse sequence - demonstrates | reverse operator)</li> + <li><code>G @ (!25) : $(!25)</code> (Rotate sequence - demonstrates $ rotate operator)</li> + <li><code>G @ (!25) : #(!25)</code> (Reshape sequence - demonstrates # reshape operator)</li> + + <li><strong>Operator Composition:</strong></li> + <li><code>G @ (!25) : ~((!25) / 5 + (!25) % 5) % 2</code> (Inverted checkerboard - demonstrates ~ with math)</li> + <li><code>G @ (!25) : |((!25) / 5 + (!25) % 5) % 2</code> (Flipped checkerboard - demonstrates | with math)</li> + <li><code>G @ (!25) : $((!25) / 5 + (!25) % 5) % 2</code> (Rotated checkerboard - demonstrates $ with math)</li> + <li><code>G @ (!25) : #((!25) % 2)</code> (Alternating columns reshaped - demonstrates # with math)</li> + </ul> + + <canvas id="gridCanvas"></canvas> + <div class="input-area"> + <input type="text" id="kInput" placeholder="Enter K-like code (e.g., G @ !10 : 1)" size="60"> + <button id="runButton">Run</button> + </div> + <pre id="output"></pre> + + <script src="script.js"></script> +</body> +</html> \ No newline at end of file diff --git a/html/kgame/script.js b/html/kgame/script.js new file mode 100644 index 0000000..ed71eeb --- /dev/null +++ b/html/kgame/script.js @@ -0,0 +1,437 @@ +document.addEventListener('DOMContentLoaded', () => { + const GRID_SIZE = 50; + const CELL_SIZE = 10; // Adjust for desired visual size + const CANVAS_WIDTH = GRID_SIZE * CELL_SIZE; + const CANVAS_HEIGHT = GRID_SIZE * CELL_SIZE; + + const canvas = document.getElementById('gridCanvas'); + const ctx = canvas.getContext('2d'); + const input = document.getElementById('kInput'); + const runButton = document.getElementById('runButton'); + const output = document.getElementById('output'); + + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + + // --- Grid State --- + let G = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(0)); // 2D matrix grid state + + // --- Drawing --- + function drawGridLines() { + ctx.strokeStyle = '#eee'; // Light gray grid lines + ctx.lineWidth = 1; + + for (let i = 0; i <= GRID_SIZE; i++) { + // Vertical lines + ctx.beginPath(); + ctx.moveTo(i * CELL_SIZE + 0.5, 0); + ctx.lineTo(i * CELL_SIZE + 0.5, CANVAS_HEIGHT); + ctx.stroke(); + + // Horizontal lines + ctx.beginPath(); + ctx.moveTo(0, i * CELL_SIZE + 0.5); + ctx.lineTo(CANVAS_WIDTH, i * CELL_SIZE + 0.5); + ctx.stroke(); + } + } + + function drawCells() { + ctx.fillStyle = '#333'; // Color for 'on' cells + for (let row = 0; row < GRID_SIZE; row++) { + for (let col = 0; col < GRID_SIZE; col++) { + if (G[row][col] === 1) { + ctx.fillRect(col * CELL_SIZE, row * CELL_SIZE, CELL_SIZE, CELL_SIZE); + } + } + } + } + + function redraw() { + // Clear canvas + ctx.fillStyle = '#fff'; // Background color + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + drawGridLines(); + drawCells(); + } + + // Helper functions for array operations + const range = (n) => Array.from({length: n}, (_, i) => i); + const reshape = (arr, rows, cols) => { + const result = []; + for (let i = 0; i < rows; i++) { + result.push(arr.slice(i * cols, (i + 1) * cols)); + } + return result; + }; + const ravel = (arr) => arr.flat(); + const zip = (a, b) => a.map((x, i) => [x, b[i]]); + + // --- K-like Interpreter --- + function tokenize(code) { + // First, normalize whitespace + code = code.replace(/\s+/g, ' ').trim(); + + // Define all operators and special characters + const operators = ['+', '-', '*', '/', '%', '(', ')', '!', '@', ':', '=', '<', '>', "'", '|', '$', '#', '~']; + + // Add spaces around all operators + operators.forEach(op => { + // Use a regex that ensures we don't double-space + const regex = new RegExp(`\\${op}`, 'g'); + code = code.replace(regex, ` ${op} `); + }); + + // Normalize spaces again + code = code.replace(/\s+/g, ' ').trim(); + + // Split into tokens + const tokens = code.split(' '); + + // Filter out empty tokens and log for debugging + const filteredTokens = tokens.filter(t => t.length > 0); + console.log('Tokenized:', filteredTokens); + + return filteredTokens; + } + + function evaluateExpression(tokens) { + if (!tokens || tokens.length === 0) throw new Error("Empty expression"); + + function parseAtom() { + let token = tokens.shift(); + if (!token) throw new Error("Unexpected end of expression"); + + if (token === '(') { + const value = parseAddSub(); + if (tokens.length === 0) { + throw new Error("Missing closing parenthesis"); + } + const nextToken = tokens.shift(); + if (nextToken !== ')') { + throw new Error(`Expected closing parenthesis, got: ${nextToken}`); + } + return value; + } else if (token === '!') { // Iota (prefix) + const operand = parseAtom(); + if (typeof operand !== 'number' || !Number.isInteger(operand) || operand < 0) { + throw new Error("Operand for ! (iota) must be a non-negative integer"); + } + const maxIndex = GRID_SIZE * GRID_SIZE; + const result = Array.from({length: Math.min(operand, maxIndex)}, (_, i) => i); + console.log(`Iota generated array of length ${result.length}, first few values:`, result.slice(0, 5)); + return result; + } else if (token === '~') { // Not operator + const operand = parseAtom(); + if (Array.isArray(operand)) { + const result = operand.map(x => { + const val = x === 0 ? 1 : 0; + console.log(`Not operation: ${x} -> ${val}`); + return val; + }); + console.log(`Not operation on array, first few results:`, result.slice(0, 5)); + console.log(`Input array first few values:`, operand.slice(0, 5)); + return result; + } else { + const result = operand === 0 ? 1 : 0; + console.log(`Not operation (scalar): ${operand} -> ${result}`); + return result; + } + } else if (/^-?\d+$/.test(token)) { + return parseInt(token, 10); + } else { + throw new Error(`Unrecognized token: ${token}`); + } + } + + function parseUnary() { + let token = tokens[0]; + if (token === '|' || token === '$' || token === '#') { + tokens.shift(); + const operand = parseUnary(); + let result; + switch (token) { + case '|': + result = [...operand].reverse(); + break; + case '$': + if (!Array.isArray(operand)) { + throw new Error("Rotate operator ($) requires an array operand"); + } + const size = Math.sqrt(operand.length); + if (size * size !== operand.length) { + throw new Error("Rotate operator ($) requires a square array"); + } + result = []; + for (let col = 0; col < size; col++) { + for (let row = size - 1; row >= 0; row--) { + result.push(operand[row * size + col]); + } + } + break; + case '#': + if (!Array.isArray(operand)) { + throw new Error("Reshape operator (#) requires an array operand"); + } + result = []; + for (let i = 0; i < GRID_SIZE; i++) { + result.push(operand.slice(i * GRID_SIZE, (i + 1) * GRID_SIZE)); + } + result = result.flat(); + break; + } + return result; + } + return parseAtom(); + } + + function applyOperation(a, b, op) { + const isAList = Array.isArray(a); + const isBList = Array.isArray(b); + + const scalarOp = (x, y) => { + let result; + switch (op) { + case '+': result = x + y; break; + case '-': result = x - y; break; + case '*': result = x * y; break; + case '%': result = y === 0 ? 0 : x % y; break; + case '/': result = y === 0 ? 0 : Math.floor(x / y); break; + case '<': result = x < y ? 1 : 0; break; + case '>': result = x > y ? 1 : 0; break; + case '=': result = x === y ? 1 : 0; break; + default: throw new Error(`Unknown operator: ${op}`); + } + return result; + }; + + // Handle scalar operations + if (!isAList && !isBList) { + return scalarOp(a, b); + } + + // Handle array operations + const arrayOp = (arr, val) => { + if (Array.isArray(arr)) { + const result = arr.map(x => arrayOp(x, val)); + if (op === '/' || op === '%') { + console.log(`Array operation ${op} with ${val}, first few results:`, result.slice(0, 5)); + } + return result; + } + return scalarOp(arr, val); + }; + + if (isAList && !isBList) { + const result = arrayOp(a, b); + if (op === '+') { + console.log(`Array + scalar operation, first few values:`, { + array: a.slice(0, 5), + scalar: b, + result: result.slice(0, 5) + }); + } + return result; + } else if (!isAList && isBList) { + const result = arrayOp(b, a); + if (op === '+') { + console.log(`Scalar + array operation, first few values:`, { + scalar: a, + array: b.slice(0, 5), + result: result.slice(0, 5) + }); + } + return result; + } else { + // Both are arrays + if (a.length !== b.length) { + throw new Error(`List length mismatch for operator ${op}: ${a.length} vs ${b.length}`); + } + const result = a.map((x, i) => { + const val = arrayOp(x, b[i]); + if (op === '+') { + console.log(`Adding values at index ${i}: ${x} + ${b[i]} = ${val}`); + } + return val; + }); + if (op === '+') { + console.log(`Array addition, first few results:`, result.slice(0, 5)); + } + return result; + } + } + + function parseMulDivMod() { + let left = parseUnary(); + while (tokens.length > 0 && (tokens[0] === '*' || tokens[0] === '%' || tokens[0] === '/')) { + const op = tokens.shift(); + const right = parseUnary(); + left = applyOperation(left, right, op); + } + return left; + } + + function parseAddSub() { + let left = parseMulDivMod(); + while (tokens.length > 0 && (tokens[0] === '+' || tokens[0] === '-')) { + const op = tokens.shift(); + const right = parseMulDivMod(); + left = applyOperation(left, right, op); + } + return left; + } + + function parseComparison() { + let left = parseAddSub(); + while (tokens.length > 0 && (tokens[0] === '<' || tokens[0] === '>' || tokens[0] === '=')) { + const op = tokens.shift(); + const right = parseAddSub(); + left = applyOperation(left, right, op); + } + return left; + } + + function parseNot() { + let left = parseComparison(); + while (tokens.length > 0 && tokens[0] === '~') { + tokens.shift(); + left = Array.isArray(left) ? left.map(x => x === 0 ? 1 : 0) : (left === 0 ? 1 : 0); + } + return left; + } + + return parseNot(); + } + + // Main execution function + function executeK(code) { + code = code.trim(); + if (!code) return; + + try { + if (code === 'G : 0') { + G = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(0)); + setOutput("Grid cleared.", "success"); + return; + } + if (code === 'G : 1') { + G = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(1)); + setOutput("Grid filled.", "success"); + return; + } + + const assignMatch = code.match(/^G\s*@\s*(.+?)\s*:\s*(.+)$/); + if (assignMatch) { + const indexExpr = assignMatch[1].trim(); + const valueExpr = assignMatch[2].trim(); + const steps = []; + + // Parse and evaluate indices + steps.push(`1. Evaluating indices expression: ${indexExpr}`); + const indices = evaluateExpression(tokenize(indexExpr)); + steps.push(` → Generated ${indices.length} indices`); + + // Parse and evaluate values + steps.push(`2. Evaluating values expression: ${valueExpr}`); + const values = evaluateExpression(tokenize(valueExpr)); + steps.push(` → Generated ${Array.isArray(values) ? values.length : 1} values`); + + const indicesArray = Array.isArray(indices) ? indices : [indices]; + const valuesArray = Array.isArray(values) ? values : [values]; + + if (indicesArray.length === 0) { + setOutput("Warning: Assignment applied to empty index list.", "info", steps); + return; + } + + // Vectorized assignment + const assignments = indicesArray.reduce((count, idx, i) => { + const row = Math.floor(idx / GRID_SIZE); + const col = idx % GRID_SIZE; + + if (row >= 0 && row < GRID_SIZE && col >= 0 && col < GRID_SIZE) { + const valueToAssign = valuesArray[i % valuesArray.length]; + if (count < 5) { // Only log first few assignments + console.log(`Assignment [${row},${col}]: ${valueToAssign} (from index ${idx})`); + } + G[row][col] = valueToAssign % 2; + return count + 1; + } + return count; + }, 0); + + steps.push(`3. Assignment complete:`); + steps.push(` → Applied ${assignments} assignments to the grid`); + steps.push(` → Each value was taken modulo 2 to ensure binary (0/1) values`); + + setOutput(`OK. Performed ${assignments} assignments.`, "success", steps); + } else { + const result = evaluateExpression(tokenize(code)); + setOutput(`Evaluated: ${JSON.stringify(result)}`, "info", [ + `1. Evaluated expression: ${code}`, + `2. Result: ${JSON.stringify(result)}` + ]); + } + } catch (error) { + setOutput(`Error: ${error.message}`, "error", [ + `1. Error occurred while executing: ${code}`, + `2. Error details: ${error.message}` + ]); + console.error("K execution error:", error); + } + } + + // --- Output Helper --- + function setOutput(message, type = "info", steps = []) { + const outputDiv = document.getElementById('output'); + + // Create a container for the message and steps + const container = document.createElement('div'); + container.className = type; + + // Add the main message + const messageDiv = document.createElement('div'); + messageDiv.textContent = message; + container.appendChild(messageDiv); + + // Add steps if provided + if (steps.length > 0) { + const stepsDiv = document.createElement('div'); + stepsDiv.className = 'steps'; + steps.forEach(step => { + const stepDiv = document.createElement('div'); + stepDiv.className = 'step'; + stepDiv.textContent = step; + stepsDiv.appendChild(stepDiv); + }); + container.appendChild(stepsDiv); + } + + // Clear previous output and add new content + outputDiv.innerHTML = ''; + outputDiv.appendChild(container); + } + + // --- Event Listeners --- + function handleRun() { + const code = input.value; + executeK(code); + redraw(); + // Optional: Clear input after running + // input.value = ''; + } + + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + handleRun(); + } + }); + + runButton.addEventListener('click', handleRun); + + + // --- Initial Draw --- + setOutput("Grid initialized. Enter commands below.", "info"); + redraw(); +}); \ No newline at end of file diff --git a/html/kgame/style.css b/html/kgame/style.css new file mode 100644 index 0000000..dfb0f33 --- /dev/null +++ b/html/kgame/style.css @@ -0,0 +1,90 @@ +body { + font-family: sans-serif; + display: flex; + flex-direction: column; + align-items: center; + margin: 1em; + background-color: #f4f4f4; +} + +h1 { + margin-bottom: 0.5em; +} + +p, ul { + max-width: 600px; + text-align: left; + margin-bottom: 0.5em; + } + +li { + margin-bottom: 0.3em; +} + +code { + font-family: monospace; + background-color: #e0e0e0; + padding: 0.1em 0.3em; + border-radius: 3px; +} + +canvas { + border: 1px solid #333; + margin-top: 1em; + background-color: #fff; + /* Prevent blurry rendering */ + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +.input-area { + margin-top: 1em; + display: flex; + align-items: center; +} + +#kInput { + padding: 8px; + font-size: 1em; + font-family: monospace; + margin-right: 5px; + border: 1px solid #ccc; + border-radius: 4px; +} + +#runButton { + padding: 8px 15px; + font-size: 1em; + cursor: pointer; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 4px; +} + +#runButton:hover { + background-color: #45a049; +} + +#output { + margin-top: 1em; + padding: 10px; + background-color: #e0e0e0; + border: 1px solid #ccc; + min-height: 3em; + width: 500px; /* Adjust as needed */ + white-space: pre-wrap; /* Wrap long lines */ + word-wrap: break-word; /* Break long words */ + font-family: monospace; + color: #d00; /* Default to error color */ +} + +#output.success { + color: #080; /* Green for success */ +} + +#output.info { + color: #333; /* Default black/gray for info */ +} \ No newline at end of file diff --git a/html/space/game.js b/html/space/game.js new file mode 100644 index 0000000..ecd7abc --- /dev/null +++ b/html/space/game.js @@ -0,0 +1,45 @@ +// Main game entry point +import { initRenderer, render } from './renderer.js'; +import { initInput, updateInput } from './input.js'; +import { initPhysics, updatePhysics } from './physics.js'; +import { initGameState, updateGameState } from './gameState.js'; + +// Game state +let lastTime = 0; +let isRunning = true; + +// Initialize all systems +function init() { + console.log('Initializing game...'); + initRenderer(); + initInput(); + initPhysics(); + initGameState(); + console.log('Game initialized'); +} + +// Main game loop using requestAnimationFrame +function gameLoop(timestamp) { + if (!isRunning) return; + + const deltaTime = timestamp - lastTime; + lastTime = timestamp; + + // Update game systems + updateInput(); + updatePhysics(deltaTime); + updateGameState(deltaTime); + render(); + + // Debug output + if (Math.random() < 0.01) { // Only log occasionally to avoid spam + console.log('Game loop running, deltaTime:', deltaTime); + } + + requestAnimationFrame(gameLoop); +} + +// Start the game +console.log('Starting game...'); +init(); +requestAnimationFrame(gameLoop); \ No newline at end of file diff --git a/html/space/gameState.js b/html/space/gameState.js new file mode 100644 index 0000000..620bc1f --- /dev/null +++ b/html/space/gameState.js @@ -0,0 +1,169 @@ +// Game state module for managing game objects and logic +import { inputState } from './input.js'; +import { getPlayerState } from './physics.js'; + +// Game objects +const planets = []; +const enemyShips = []; +const projectiles = []; +let lastEnemySpawn = 0; + +// Space dimensions +const SPACE_SIZE = 10000; // Increased from implicit 2000 +const PLANET_DISTANCE = 5000; // Increased from 1000 +const ENEMY_SPAWN_DISTANCE = 3000; // Increased from 500 +const ENEMY_SPAWN_INTERVAL = 5000; // 5 seconds +const MAX_ENEMIES = 5; + +// Initialize game state +export function initGameState() { + // Create planets + planets.push({ + position: { x: PLANET_DISTANCE, y: 0, z: 0 }, + radius: 500, // Increased from 200 + color: '#3498db' + }); + + planets.push({ + position: { x: -PLANET_DISTANCE, y: PLANET_DISTANCE/2, z: -PLANET_DISTANCE }, + radius: 400, // Increased from 150 + color: '#e74c3c' + }); + + // Reset other state + enemyShips.length = 0; + projectiles.length = 0; + lastEnemySpawn = Date.now(); + + // Create initial enemy ships + for (let i = 0; i < 5; i++) { + createEnemyShip(); + } +} + +// Create a new enemy ship +function createEnemyShip() { + const distance = ENEMY_SPAWN_DISTANCE + Math.random() * ENEMY_SPAWN_DISTANCE; + const angle = Math.random() * Math.PI * 2; + const height = (Math.random() - 0.5) * ENEMY_SPAWN_DISTANCE; + + enemyShips.push({ + position: { + x: Math.cos(angle) * distance, + y: height, + z: Math.sin(angle) * distance + }, + velocity: { + x: (Math.random() - 0.5) * 0.5, // Reduced from 2 + y: (Math.random() - 0.5) * 0.5, // Reduced from 2 + z: (Math.random() - 0.5) * 0.5 // Reduced from 2 + }, + health: 100 + }); +} + +// Update game state +export function updateGameState(deltaTime) { + const currentTime = Date.now(); + const player = getPlayerState(); + + // Spawn enemies + if (currentTime - lastEnemySpawn > ENEMY_SPAWN_INTERVAL && + enemyShips.length < MAX_ENEMIES) { + spawnEnemy(); + lastEnemySpawn = currentTime; + } + + // Update projectiles + projectiles.forEach((projectile, index) => { + projectile.position.x += projectile.velocity.x * deltaTime; + projectile.position.y += projectile.velocity.y * deltaTime; + projectile.position.z += projectile.velocity.z * deltaTime; + + // Remove if too old + if (currentTime - projectile.createdAt > 5000) { + projectiles.splice(index, 1); + } + }); + + // Update enemy ships + enemyShips.forEach((ship, index) => { + // Move ships + ship.position.x += ship.velocity.x * deltaTime; + ship.position.y += ship.velocity.y * deltaTime; + ship.position.z += ship.velocity.z * deltaTime; + + // Check if ship is too far away + const distance = Math.sqrt( + Math.pow(ship.position.x - player.position.x, 2) + + Math.pow(ship.position.y - player.position.y, 2) + + Math.pow(ship.position.z - player.position.z, 2) + ); + + if (distance > SPACE_SIZE) { + enemyShips.splice(index, 1); + createEnemyShip(); + } + }); + + // Handle firing + if (inputState.firePrimary) { + createProjectile('primary'); + } + if (inputState.fireSecondary) { + createProjectile('secondary'); + } +} + +// Create a new projectile +export function createProjectile(type) { + const player = getPlayerState(); + const speed = type === 'primary' ? 10 : 7.5; // Reduced from 20/15 + const damage = type === 'primary' ? 25 : 10; + + const cosY = Math.cos(player.rotation.y); + const sinY = Math.sin(player.rotation.y); + const cosX = Math.cos(player.rotation.x); + const sinX = Math.sin(player.rotation.x); + + projectiles.push({ + type, + position: { ...player.position }, + velocity: { + x: sinY * cosX * speed, + y: sinX * speed, + z: cosY * cosX * speed + }, + damage, + createdAt: Date.now() + }); +} + +// Spawn a new enemy ship +function spawnEnemy() { + const angle = Math.random() * Math.PI * 2; + const distance = ENEMY_SPAWN_DISTANCE; + + enemyShips.push({ + position: { + x: Math.cos(angle) * distance, + y: 0, + z: Math.sin(angle) * distance + }, + velocity: { + x: (Math.random() - 0.5) * 0.5, + y: (Math.random() - 0.5) * 0.5, + z: (Math.random() - 0.5) * 0.5 + }, + health: 100 + }); +} + +// Get game state for rendering +export function getGameState() { + return { + planets: [...planets], + enemyShips: [...enemyShips], + projectiles: [...projectiles] + }; +} \ No newline at end of file diff --git a/html/space/index.html b/html/space/index.html new file mode 100644 index 0000000..9db977d --- /dev/null +++ b/html/space/index.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Space</title> + <style> + body { + margin: 0; + overflow: hidden; + background: black; + } + canvas { + display: block; + } + </style> +</head> +<body> + <canvas id="gameCanvas"></canvas> + <script type="module" src="game.js"></script> +</body> +</html> \ No newline at end of file diff --git a/html/space/input.js b/html/space/input.js new file mode 100644 index 0000000..19ea56c --- /dev/null +++ b/html/space/input.js @@ -0,0 +1,83 @@ +// Input handling module +import { updatePlayerControls } from './physics.js'; + +let keys = {}; +let mouseX = 0; +let mouseY = 0; + +// Input state that other modules can read +export const inputState = { + thrust: 0, // forward/backward thrust (W/S) + strafe: 0, // left/right strafe (A/D) + yaw: 0, // left/right rotation (arrow keys) + pitch: 0, // up/down rotation (arrow keys) + firePrimary: false, + fireSecondary: false +}; + +// Initialize input handlers +export function initInput() { + // Keyboard event listeners + document.addEventListener('keydown', (e) => { + keys[e.key.toLowerCase()] = true; + }); + + document.addEventListener('keyup', (e) => { + keys[e.key.toLowerCase()] = false; + }); + + // Mouse movement for heading + document.addEventListener('mousemove', (e) => { + // Calculate mouse position relative to center of canvas + const canvas = document.querySelector('canvas'); + const rect = canvas.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + mouseX = (e.clientX - centerX) / (rect.width / 2); + mouseY = (e.clientY - centerY) / (rect.height / 2); + }); + + // Mouse click for primary weapon + document.addEventListener('mousedown', (e) => { + if (e.button === 0) { // Left click + keys['fire'] = true; + } + }); + + document.addEventListener('mouseup', (e) => { + if (e.button === 0) { // Left click + keys['fire'] = false; + } + }); + + // E key for secondary weapon + document.addEventListener('keydown', (e) => { + if (e.key.toLowerCase() === 'e') { + keys['secondary'] = true; + } + }); + + document.addEventListener('keyup', (e) => { + if (e.key.toLowerCase() === 'e') { + keys['secondary'] = false; + } + }); +} + +// Update controls based on current input state +export function updateInput() { + const controls = { + thrust: keys[' '] || false, // Space bar for thrust + up: keys['w'] || false, // W for upward strafe + down: keys['s'] || false, // S for downward strafe + left: keys['a'] || false, // A for left strafe + right: keys['d'] || false, // D for right strafe + fire: keys['fire'] || false, + secondary: keys['secondary'] || false, + mouseX, + mouseY + }; + + updatePlayerControls(controls); +} \ No newline at end of file diff --git a/html/space/physics.js b/html/space/physics.js new file mode 100644 index 0000000..d4dfe55 --- /dev/null +++ b/html/space/physics.js @@ -0,0 +1,253 @@ +// Physics module for handling movement and collisions +import { inputState } from './input.js'; +import { createProjectile } from './gameState.js'; + +// Constants +const MAX_THRUST = 0.5; // Reduced from 2 +const THRUST_ACCELERATION = 0.01; // Reduced from 0.05 +const DECELERATION = 0.001; // Reduced from 0.01 +const BASE_ROTATION_SPEED = 0.001; // Reduced from 0.005 +const ROTATION_ACCELERATION = 0.0005; // Reduced from 0.002 +const ROTATION_DECELERATION = 0.0002; // Reduced from 0.001 +const MOUSE_SENSITIVITY = 0.03; // Increased from 0.01 for sharper turns +const MAX_SPEED = 1.0; // Maximum speed in any direction + +// Weapon constants +export const PRIMARY_COOLDOWN = 100; // ms between primary shots +export const SECONDARY_COOLDOWN = 2000; // ms between secondary shots +export const PRIMARY_BURST_COUNT = 3; // Number of shots in primary burst +export const PRIMARY_BURST_DELAY = 50; // ms between burst shots + +// Player state +let playerState = { + position: { x: 0, y: 0, z: 0 }, + velocity: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0 }, + thrust: 0, + strafe: 0, + weapons: { + primary: { + lastFired: 0, + burstCount: 0, + burstTimer: 0 + }, + secondary: { + lastFired: 0 + } + } +}; + +// Initialize physics +export function initPhysics() { + // Reset player state + playerState.position = { x: 0, y: 0, z: 0 }; + playerState.velocity = { x: 0, y: 0, z: 0 }; + playerState.rotation = { x: 0, y: 0 }; + playerState.thrust = 0; + playerState.strafe = 0; +} + +// Helper function to limit speed in a direction +function limitSpeed(velocity, maxSpeed) { + const speed = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y + velocity.z * velocity.z); + if (speed > maxSpeed) { + const scale = maxSpeed / speed; + velocity.x *= scale; + velocity.y *= scale; + velocity.z *= scale; + } +} + +// Update player controls +export function updatePlayerControls(controls) { + // Handle thrust (space bar) + if (controls.thrust) { + playerState.thrust = Math.min(playerState.thrust + THRUST_ACCELERATION, MAX_THRUST); + } else { + // Apply deceleration when no thrust input + if (playerState.thrust > 0) { + playerState.thrust = Math.max(playerState.thrust - DECELERATION, 0); + } + } + + // Handle vertical strafing (W/S) + if (controls.up) { + playerState.verticalStrafe = Math.min(playerState.verticalStrafe + THRUST_ACCELERATION, MAX_THRUST); + } else if (controls.down) { + playerState.verticalStrafe = Math.max(playerState.verticalStrafe - THRUST_ACCELERATION, -MAX_THRUST); + } else { + // Apply deceleration when no vertical strafe input + if (playerState.verticalStrafe > 0) { + playerState.verticalStrafe = Math.max(playerState.verticalStrafe - DECELERATION, 0); + } else if (playerState.verticalStrafe < 0) { + playerState.verticalStrafe = Math.min(playerState.verticalStrafe + DECELERATION, 0); + } + } + + // Handle horizontal strafing (A/D) + if (controls.left) { + playerState.horizontalStrafe = Math.min(playerState.horizontalStrafe + THRUST_ACCELERATION, MAX_THRUST); + } else if (controls.right) { + playerState.horizontalStrafe = Math.max(playerState.horizontalStrafe - THRUST_ACCELERATION, -MAX_THRUST); + } else { + // Apply deceleration when no horizontal strafe input + if (playerState.horizontalStrafe > 0) { + playerState.horizontalStrafe = Math.max(playerState.horizontalStrafe - DECELERATION, 0); + } else if (playerState.horizontalStrafe < 0) { + playerState.horizontalStrafe = Math.min(playerState.horizontalStrafe + DECELERATION, 0); + } + } + + // Handle mouse-based rotation with smoothing + const targetRotationY = controls.mouseX * MOUSE_SENSITIVITY; + const targetRotationX = controls.mouseY * MOUSE_SENSITIVITY; + + // Smooth rotation using lerp with faster response + playerState.rotation.y += (targetRotationY - playerState.rotation.y) * 0.2; + playerState.rotation.x += (targetRotationX - playerState.rotation.x) * 0.2; + + // Clamp pitch rotation + playerState.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, playerState.rotation.x)); + + // Handle weapons with cooldowns + const currentTime = Date.now(); + + // Primary weapon (burst fire) + if (controls.fire) { + const primary = playerState.weapons.primary; + if (currentTime - primary.lastFired >= PRIMARY_COOLDOWN && primary.burstCount === 0) { + primary.burstCount = PRIMARY_BURST_COUNT; + primary.burstTimer = currentTime; + firePrimaryWeapon(); + primary.lastFired = currentTime; + } + } + + // Secondary weapon (single shot with cooldown) + if (controls.secondary && currentTime - playerState.weapons.secondary.lastFired >= SECONDARY_COOLDOWN) { + fireSecondaryWeapon(); + playerState.weapons.secondary.lastFired = currentTime; + } + + // Handle burst fire timing + const primary = playerState.weapons.primary; + if (primary.burstCount > 0 && currentTime - primary.burstTimer >= PRIMARY_BURST_DELAY) { + firePrimaryWeapon(); + primary.burstCount--; + primary.burstTimer = currentTime; + } +} + +// Update physics +export function updatePhysics(deltaTime) { + // Calculate forward and right vectors based on rotation + const forward = { + x: Math.sin(playerState.rotation.y) * Math.cos(playerState.rotation.x), + y: -Math.sin(playerState.rotation.x), + z: Math.cos(playerState.rotation.y) * Math.cos(playerState.rotation.x) + }; + + const right = { + x: Math.cos(playerState.rotation.y), + y: 0, + z: -Math.sin(playerState.rotation.y) + }; + + const up = { x: 0, y: 1, z: 0 }; + + // Apply thrust in forward direction + const thrustVelocity = { + x: forward.x * playerState.thrust * deltaTime, + y: forward.y * playerState.thrust * deltaTime, + z: forward.z * playerState.thrust * deltaTime + }; + + // Apply horizontal strafe + const horizontalStrafeVelocity = { + x: right.x * playerState.horizontalStrafe * deltaTime, + y: 0, + z: right.z * playerState.horizontalStrafe * deltaTime + }; + + // Apply vertical strafe + const verticalStrafeVelocity = { + x: 0, + y: up.y * playerState.verticalStrafe * deltaTime, + z: 0 + }; + + // Add velocities + playerState.velocity.x += thrustVelocity.x + horizontalStrafeVelocity.x + verticalStrafeVelocity.x; + playerState.velocity.y += thrustVelocity.y + horizontalStrafeVelocity.y + verticalStrafeVelocity.y; + playerState.velocity.z += thrustVelocity.z + horizontalStrafeVelocity.z + verticalStrafeVelocity.z; + + // Limit total speed + limitSpeed(playerState.velocity, MAX_SPEED); + + // Apply velocity to position + playerState.position.x += playerState.velocity.x * deltaTime; + playerState.position.y += playerState.velocity.y * deltaTime; + playerState.position.z += playerState.velocity.z * deltaTime; + + // Apply friction/drag + const drag = 0.99; + playerState.velocity.x *= drag; + playerState.velocity.y *= drag; + playerState.velocity.z *= drag; +} + +// Weapon firing +function firePrimaryWeapon() { + const forward = { + x: Math.sin(playerState.rotation.y) * Math.cos(playerState.rotation.x), + y: -Math.sin(playerState.rotation.x), + z: Math.cos(playerState.rotation.y) * Math.cos(playerState.rotation.x) + }; + + createProjectile({ + position: { ...playerState.position }, + velocity: { + x: forward.x * 10 + playerState.velocity.x, + y: forward.y * 10 + playerState.velocity.y, + z: forward.z * 10 + playerState.velocity.z + }, + type: 'primary' + }); +} + +function fireSecondaryWeapon() { + const forward = { + x: Math.sin(playerState.rotation.y) * Math.cos(playerState.rotation.x), + y: -Math.sin(playerState.rotation.x), + z: Math.cos(playerState.rotation.y) * Math.cos(playerState.rotation.x) + }; + + createProjectile({ + position: { ...playerState.position }, + velocity: { + x: forward.x * 5 + playerState.velocity.x, + y: forward.y * 5 + playerState.velocity.y, + z: forward.z * 5 + playerState.velocity.z + }, + type: 'secondary' + }); +} + +// Get current player state +export function getPlayerState() { + return playerState; +} + +// Get weapon cooldown states +export function getWeaponStates() { + const currentTime = Date.now(); + return { + primary: { + cooldown: Math.max(0, PRIMARY_COOLDOWN - (currentTime - playerState.weapons.primary.lastFired)), + burstCount: playerState.weapons.primary.burstCount + }, + secondary: { + cooldown: Math.max(0, SECONDARY_COOLDOWN - (currentTime - playerState.weapons.secondary.lastFired)) + } + }; +} \ No newline at end of file diff --git a/html/space/renderer.js b/html/space/renderer.js new file mode 100644 index 0000000..04646cf --- /dev/null +++ b/html/space/renderer.js @@ -0,0 +1,358 @@ +// Renderer module using HTML5 Canvas +import { getPlayerState, getWeaponStates } from './physics.js'; +import { getGameState } from './gameState.js'; + +// Import weapon constants +import { + PRIMARY_COOLDOWN, + SECONDARY_COOLDOWN, + PRIMARY_BURST_COUNT, + PRIMARY_BURST_DELAY +} from './physics.js'; + +let canvas; +let ctx; +let width; +let height; + +// Star field +let starfield = []; // Declare starfield array +const NUM_STARS = 2000; // Increased from 1000 +const STAR_FIELD_DEPTH = 20000; // Increased from 2000 + +// HUD constants +const HUD_COLOR = '#00ff00'; +const HUD_ALPHA = 0.7; +const RADAR_RADIUS = 100; +const RADAR_CENTER_X = 100; +const RADAR_CENTER_Y = 100; +const RADAR_SCALE = 0.1; // Scale factor for radar display +const TARGET_LOCK_COLOR = '#ff0000'; +const TARGET_LOCK_THRESHOLD = 20; // Pixels from center to consider locked + +// Initialize the renderer +export function initRenderer() { + console.log('Initializing renderer...'); + canvas = document.getElementById('gameCanvas'); + ctx = canvas.getContext('2d'); + + // Set canvas size + width = canvas.width = window.innerWidth; + height = canvas.height = window.innerHeight; + + // Initialize starfield + console.log('Creating starfield with', NUM_STARS, 'stars...'); + starfield = Array.from({ length: NUM_STARS }, () => ({ + x: (Math.random() - 0.5) * STAR_FIELD_DEPTH, + y: (Math.random() - 0.5) * STAR_FIELD_DEPTH, + z: Math.random() * STAR_FIELD_DEPTH, + size: Math.random() * 2 + 1 + })); + console.log('Starfield initialized'); +} + +// Project 3D point to 2D screen coordinates +function projectPoint(x, y, z) { + if (z <= 0) return null; // Behind camera + + const scale = 2000 / z; // Increased scale factor + return { + x: width/2 + x * scale, + y: height/2 + y * scale, + scale + }; +} + +// Check if any enemy ship is in targeting range +function getTargetLock(player, gameState) { + const centerX = width / 2; + const centerY = height / 2; + + for (const ship of gameState.enemyShips) { + const projected = projectPoint(ship.x - player.x, ship.y - player.y, ship.z - player.z); + if (projected) { + const distance = Math.sqrt( + Math.pow(projected.x - centerX, 2) + + Math.pow(projected.y - centerY, 2) + ); + + if (distance < TARGET_LOCK_THRESHOLD) { + return { + ship, + distance + }; + } + } + } + return null; +} + +// Draw radar/minimap +function drawRadar(player, gameState, targetLock) { + // Save context + ctx.save(); + + // Set radar style + ctx.strokeStyle = targetLock ? TARGET_LOCK_COLOR : HUD_COLOR; + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 1; + + // Draw radar background + ctx.beginPath(); + ctx.arc(RADAR_CENTER_X, RADAR_CENTER_Y, RADAR_RADIUS, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Draw radar grid + ctx.beginPath(); + ctx.moveTo(RADAR_CENTER_X - RADAR_RADIUS, RADAR_CENTER_Y); + ctx.lineTo(RADAR_CENTER_X + RADAR_RADIUS, RADAR_CENTER_Y); + ctx.moveTo(RADAR_CENTER_X, RADAR_CENTER_Y - RADAR_RADIUS); + ctx.lineTo(RADAR_CENTER_X, RADAR_CENTER_Y + RADAR_RADIUS); + ctx.stroke(); + + // Draw objects on radar + ctx.fillStyle = HUD_COLOR; + + // Draw planets + gameState.planets.forEach(planet => { + const dx = (planet.x - player.x) * RADAR_SCALE; + const dy = (planet.z - player.z) * RADAR_SCALE; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < RADAR_RADIUS) { + ctx.beginPath(); + ctx.arc( + RADAR_CENTER_X + dx, + RADAR_CENTER_Y + dy, + 5, + 0, + Math.PI * 2 + ); + ctx.fill(); + } + }); + + // Draw enemy ships + gameState.enemyShips.forEach(ship => { + const dx = (ship.x - player.x) * RADAR_SCALE; + const dy = (ship.z - player.z) * RADAR_SCALE; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < RADAR_RADIUS) { + ctx.beginPath(); + ctx.arc( + RADAR_CENTER_X + dx, + RADAR_CENTER_Y + dy, + 4, + 0, + Math.PI * 2 + ); + ctx.fill(); + } + }); + + // Restore context + ctx.restore(); +} + +// Draw speed and direction indicators +function drawSpeedIndicator(player, targetLock) { + ctx.save(); + ctx.fillStyle = targetLock ? TARGET_LOCK_COLOR : HUD_COLOR; + ctx.font = '14px monospace'; + + // Calculate speed from velocity components + const speed = Math.sqrt( + player.vx * player.vx + + player.vy * player.vy + + player.vz * player.vz + ); + + // Draw speed + ctx.fillText(`Speed: ${speed.toFixed(2)}`, 20, height - 40); + + // Draw direction (using x and z components for heading) + const direction = Math.atan2(player.vx, player.vz); + ctx.fillText(`Heading: ${(direction * 180 / Math.PI).toFixed(1)}°`, 20, height - 20); + + ctx.restore(); +} + +// Draw enhanced targeting reticle +function drawTargetingReticle(player, gameState) { + ctx.save(); + + // Check for target lock + const targetLock = getTargetLock(player, gameState); + const currentColor = targetLock ? TARGET_LOCK_COLOR : HUD_COLOR; + + ctx.strokeStyle = currentColor; + ctx.lineWidth = 1; + + // Outer circle + ctx.beginPath(); + ctx.arc(width/2, height/2, 20, 0, Math.PI * 2); + ctx.stroke(); + + // Inner crosshair + ctx.beginPath(); + ctx.moveTo(width/2 - 10, height/2); + ctx.lineTo(width/2 + 10, height/2); + ctx.moveTo(width/2, height/2 - 10); + ctx.lineTo(width/2, height/2 + 10); + ctx.stroke(); + + // Target brackets + ctx.beginPath(); + ctx.moveTo(width/2 - 30, height/2 - 30); + ctx.lineTo(width/2 - 20, height/2 - 30); + ctx.lineTo(width/2 - 20, height/2 - 20); + ctx.moveTo(width/2 + 30, height/2 - 30); + ctx.lineTo(width/2 + 20, height/2 - 30); + ctx.lineTo(width/2 + 20, height/2 - 20); + ctx.moveTo(width/2 - 30, height/2 + 30); + ctx.lineTo(width/2 - 20, height/2 + 30); + ctx.lineTo(width/2 - 20, height/2 + 20); + ctx.moveTo(width/2 + 30, height/2 + 30); + ctx.lineTo(width/2 + 20, height/2 + 30); + ctx.lineTo(width/2 + 20, height/2 + 20); + ctx.stroke(); + + // Draw target lock indicator if locked + if (targetLock) { + // Draw pulsing circle around target + const pulseSize = 30 + Math.sin(Date.now() * 0.01) * 5; + ctx.beginPath(); + ctx.arc(width/2, height/2, pulseSize, 0, Math.PI * 2); + ctx.stroke(); + + // Draw target distance + ctx.fillStyle = currentColor; + ctx.font = '14px monospace'; + ctx.fillText(`Target Lock: ${targetLock.distance.toFixed(1)}`, width/2 - 50, height/2 + 50); + } + + ctx.restore(); +} + +// Draw weapon cooldown indicators +function drawWeaponCooldowns() { + const weaponStates = getWeaponStates(); + ctx.save(); + ctx.fillStyle = HUD_COLOR; + ctx.font = '14px monospace'; + + // Primary weapon cooldown (bottom left) + const primaryCooldown = weaponStates.primary.cooldown / PRIMARY_COOLDOWN; + ctx.fillText('Primary:', 20, height - 80); + ctx.fillStyle = `rgba(0, 255, 0, ${primaryCooldown})`; + ctx.fillRect(20, height - 70, 100, 10); + ctx.strokeStyle = HUD_COLOR; + ctx.strokeRect(20, height - 70, 100, 10); + + // Secondary weapon cooldown (bottom left, below primary) + const secondaryCooldown = weaponStates.secondary.cooldown / SECONDARY_COOLDOWN; + ctx.fillStyle = HUD_COLOR; + ctx.fillText('Secondary:', 20, height - 50); + ctx.fillStyle = `rgba(0, 255, 0, ${secondaryCooldown})`; + ctx.fillRect(20, height - 40, 100, 10); + ctx.strokeStyle = HUD_COLOR; + ctx.strokeRect(20, height - 40, 100, 10); + + // Draw burst indicator for primary weapon + if (weaponStates.primary.burstCount > 0) { + ctx.fillStyle = HUD_COLOR; + ctx.fillText(`Burst: ${weaponStates.primary.burstCount}`, 20, height - 20); + } + + ctx.restore(); +} + +// Main render function +export function render() { + const player = getPlayerState(); + const gameState = getGameState(); + const targetLock = getTargetLock(player, gameState); + + // Clear canvas + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw starfield + let starsRendered = 0; + starfield.forEach(star => { + // Calculate star position relative to player + let relativeX = star.x - player.x; + let relativeY = star.y - player.y; + let relativeZ = star.z - player.z; + + // Apply player rotation + let rotatedX = relativeX * Math.cos(player.rotation) - relativeY * Math.sin(player.rotation); + let rotatedY = relativeX * Math.sin(player.rotation) + relativeY * Math.cos(player.rotation); + let rotatedZ = relativeZ; + + // Project to screen coordinates + if (rotatedZ > 0) { + const projected = projectPoint(rotatedX, rotatedY, rotatedZ); + if (projected) { + const brightness = Math.min(1, 2000 / rotatedZ); + ctx.fillStyle = `rgba(255, 255, 255, ${brightness})`; + ctx.beginPath(); + ctx.arc(projected.x, projected.y, star.size * brightness, 0, Math.PI * 2); + ctx.fill(); + starsRendered++; + } + } + }); + + // Debug output + if (Math.random() < 0.01) { // Only log occasionally to avoid spam + console.log('Stars rendered:', starsRendered); + } + + // Draw planets + gameState.planets.forEach(planet => { + const projected = projectPoint(planet.x - player.x, planet.y - player.y, planet.z - player.z); + if (projected) { + const radius = planet.radius * projected.scale; + ctx.fillStyle = planet.color; + ctx.beginPath(); + ctx.arc(projected.x, projected.y, radius, 0, Math.PI * 2); + ctx.fill(); + } + }); + + // Draw enemy ships + gameState.enemyShips.forEach(ship => { + const projected = projectPoint(ship.x - player.x, ship.y - player.y, ship.z - player.z); + if (projected) { + const size = 20 * projected.scale; + ctx.fillStyle = '#ff0000'; + ctx.beginPath(); + ctx.moveTo(projected.x, projected.y - size); + ctx.lineTo(projected.x + size, projected.y + size); + ctx.lineTo(projected.x - size, projected.y + size); + ctx.closePath(); + ctx.fill(); + } + }); + + // Draw projectiles + gameState.projectiles.forEach(projectile => { + const projected = projectPoint(projectile.x - player.x, projectile.y - player.y, projectile.z - player.z); + if (projected) { + const size = 3 * projected.scale; + ctx.fillStyle = projectile.type === 'primary' ? '#ffff00' : '#00ffff'; + ctx.beginPath(); + ctx.arc(projected.x, projected.y, size, 0, Math.PI * 2); + ctx.fill(); + } + }); + + // Draw HUD elements + drawRadar(player, gameState, targetLock); + drawSpeedIndicator(player, targetLock); + drawTargetingReticle(player, gameState); + drawWeaponCooldowns(); +} \ No newline at end of file diff --git a/html/voice-memos/index.html b/html/voice-memos/index.html index a74a6df..af4aaef 100644 --- a/html/voice-memos/index.html +++ b/html/voice-memos/index.html @@ -3,6 +3,9 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="theme-color" content="#ff3b30"> + <meta name="apple-mobile-web-app-capable" content="yes"> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <title>Voice Memos</title> <style> :root { @@ -13,15 +16,24 @@ --medium-gray: #e5e5ea; --dark-gray: #8e8e93; --border-radius: 12px; + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); + } + + * { + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; } body { - font-family: sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background-color: beige; color: var(--text-color); + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; } .container { @@ -62,6 +74,54 @@ align-items: center; justify-content: center; min-width: 100px; + touch-action: manipulation; + } + + button:active { + transform: scale(0.96); + } + + /* Mobile styles */ + @media screen and (max-width: 600px) { + body { + padding: 12px; + } + + .container { + padding: 16px; + padding-bottom: calc(16px + var(--safe-area-inset-bottom)); + margin-bottom: env(safe-area-inset-bottom); + } + + .controls { + flex-direction: column; + gap: 16px; + padding: 0; + } + + button { + width: 100%; + padding: 16px 20px; + font-size: 16px; + min-width: unset; + min-height: 48px; + } + + select { + height: 48px; + font-size: 16px; + padding: 12px 16px; + } + + .waveform-container { + height: 100px; + margin: 16px 0; + } + + .status { + font-size: 15px; + padding: 8px 0; + } } #startBtn { @@ -103,6 +163,7 @@ background-repeat: no-repeat; background-position: right 12px top 50%; background-size: 12px auto; + touch-action: manipulation; } .waveform-container { diff --git a/js/leibovitz/ChicagoFLF.ttf b/js/leibovitz/ChicagoFLF.ttf new file mode 100644 index 0000000..60691e1 --- /dev/null +++ b/js/leibovitz/ChicagoFLF.ttf Binary files differdiff --git a/js/leibovitz/android-icon-144x144.png b/js/leibovitz/android-icon-144x144.png new file mode 100644 index 0000000..ae37a7e --- /dev/null +++ b/js/leibovitz/android-icon-144x144.png Binary files differdiff --git a/js/leibovitz/android-icon-192x192.png b/js/leibovitz/android-icon-192x192.png new file mode 100644 index 0000000..4fd03e4 --- /dev/null +++ b/js/leibovitz/android-icon-192x192.png Binary files differdiff --git a/js/leibovitz/android-icon-36x36.png b/js/leibovitz/android-icon-36x36.png new file mode 100644 index 0000000..d5fbc51 --- /dev/null +++ b/js/leibovitz/android-icon-36x36.png Binary files differdiff --git a/js/leibovitz/android-icon-48x48.png b/js/leibovitz/android-icon-48x48.png new file mode 100644 index 0000000..ae93a97 --- /dev/null +++ b/js/leibovitz/android-icon-48x48.png Binary files differdiff --git a/js/leibovitz/android-icon-72x72.png b/js/leibovitz/android-icon-72x72.png new file mode 100644 index 0000000..89d3e98 --- /dev/null +++ b/js/leibovitz/android-icon-72x72.png Binary files differdiff --git a/js/leibovitz/android-icon-96x96.png b/js/leibovitz/android-icon-96x96.png new file mode 100644 index 0000000..a4fcd87 --- /dev/null +++ b/js/leibovitz/android-icon-96x96.png Binary files differdiff --git a/js/leibovitz/apple-icon-114x114.png b/js/leibovitz/apple-icon-114x114.png new file mode 100644 index 0000000..2a2af04 --- /dev/null +++ b/js/leibovitz/apple-icon-114x114.png Binary files differdiff --git a/js/leibovitz/apple-icon-120x120.png b/js/leibovitz/apple-icon-120x120.png new file mode 100644 index 0000000..dd9823f --- /dev/null +++ b/js/leibovitz/apple-icon-120x120.png Binary files differdiff --git a/js/leibovitz/apple-icon-144x144.png b/js/leibovitz/apple-icon-144x144.png new file mode 100644 index 0000000..ae37a7e --- /dev/null +++ b/js/leibovitz/apple-icon-144x144.png Binary files differdiff --git a/js/leibovitz/apple-icon-152x152.png b/js/leibovitz/apple-icon-152x152.png new file mode 100644 index 0000000..c43bf96 --- /dev/null +++ b/js/leibovitz/apple-icon-152x152.png Binary files differdiff --git a/js/leibovitz/apple-icon-180x180.png b/js/leibovitz/apple-icon-180x180.png new file mode 100644 index 0000000..f7435e7 --- /dev/null +++ b/js/leibovitz/apple-icon-180x180.png Binary files differdiff --git a/js/leibovitz/apple-icon-57x57.png b/js/leibovitz/apple-icon-57x57.png new file mode 100644 index 0000000..7f5dfa5 --- /dev/null +++ b/js/leibovitz/apple-icon-57x57.png Binary files differdiff --git a/js/leibovitz/apple-icon-60x60.png b/js/leibovitz/apple-icon-60x60.png new file mode 100644 index 0000000..3a6a826 --- /dev/null +++ b/js/leibovitz/apple-icon-60x60.png Binary files differdiff --git a/js/leibovitz/apple-icon-72x72.png b/js/leibovitz/apple-icon-72x72.png new file mode 100644 index 0000000..89d3e98 --- /dev/null +++ b/js/leibovitz/apple-icon-72x72.png Binary files differdiff --git a/js/leibovitz/apple-icon-76x76.png b/js/leibovitz/apple-icon-76x76.png new file mode 100644 index 0000000..9dc77b1 --- /dev/null +++ b/js/leibovitz/apple-icon-76x76.png Binary files differdiff --git a/js/leibovitz/apple-icon-precomposed.png b/js/leibovitz/apple-icon-precomposed.png new file mode 100644 index 0000000..8e17e9c --- /dev/null +++ b/js/leibovitz/apple-icon-precomposed.png Binary files differdiff --git a/js/leibovitz/apple-icon.png b/js/leibovitz/apple-icon.png new file mode 100644 index 0000000..8e17e9c --- /dev/null +++ b/js/leibovitz/apple-icon.png Binary files differdiff --git a/js/leibovitz/balance.js b/js/leibovitz/balance.js new file mode 100644 index 0000000..aeff62e --- /dev/null +++ b/js/leibovitz/balance.js @@ -0,0 +1,103 @@ +/** + * White balance management module implementing temperature-based color adjustment. + * + * Implements white balance adjustment using temperature-based RGB channel scaling. + * Provides non-linear temperature adjustment for natural color correction. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: temperature adjustment algorithm + * - Command Pattern: state reset operations + * + * White balance adjustment process: + * 1. Convert temperature to ratio relative to neutral (6500K) + * 2. Apply non-linear scaling (0.2 factor) to red and blue channels + * 3. Warmer temps (<6500K) increase red, decrease blue + * 4. Cooler temps (>6500K) increase blue, decrease red + * + * Features: + * - Temperature-based color adjustment + * - Non-linear response curve + * - Preserves green channel + * - Real-time updates + */ + +const BalanceManager = { + // Private state + _observers: new Set(), + _slider: null, + _value: null, + + /** + * Initializes the balance manager and sets up UI controls + */ + init() { + this._slider = document.getElementById('balance-slider'); + this._value = document.getElementById('balance-value'); + this._setupEventListeners(); + }, + + _setupEventListeners() { + this._slider.addEventListener('input', () => { + const value = this._slider.value; + this._value.textContent = `${value}K`; + this._notifyObservers(); + }); + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this.getCurrentBalance())); + }, + + /** + * Subscribes to balance state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + /** + * Gets the current white balance temperature + * @returns {number} Current temperature in Kelvin (2000K-10000K) + */ + getCurrentBalance() { + return parseInt(this._slider.value); + }, + + /** + * Applies white balance adjustment to an image + * And implements temperature-based RGB channel scaling with non-linear response + * @param {ImageData} imageData - Source image data + * @returns {ImageData} White balanced image data + */ + applyBalance(imageData) { + const balance = this.getCurrentBalance(); + if (!balance || balance === 6500) return imageData; // 6500K is neutral + + const data = imageData.data; + const temperature = balance / 6500; // Convert to temperature ratio + + for (let i = 0; i < data.length; i += 4) { + // Adjust red and blue channels based on temperature + // Warmer (lower K) increases red, decreases blue + // Cooler (higher K) increases blue, decreases red + data[i] = Math.min(255, data[i] * (1 + (temperature - 1) * 0.2)); // Red + data[i + 2] = Math.min(255, data[i + 2] * (1 + (1 - temperature) * 0.2)); // Blue + } + + return imageData; + }, + + /** + * Resets balance effect to default state + */ + reset() { + this._slider.value = 6500; + this._value.textContent = '6500K'; + this._notifyObservers(); + } +}; \ No newline at end of file diff --git a/js/leibovitz/blur.js b/js/leibovitz/blur.js new file mode 100644 index 0000000..bc6cddf --- /dev/null +++ b/js/leibovitz/blur.js @@ -0,0 +1,167 @@ +/** + * Blur management module implementing optimized box blur algorithm. + * + * Implements a two-pass box blur algorithm with boundary optimization. + * Uses block-based processing for improved performance on large images. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: blur algorithm implementation + * - Command Pattern: state reset operations + * + * The blur implementation uses a two-pass approach: + * 1. Horizontal pass: Applies blur along rows + * 2. Vertical pass: Applies blur along columns + * + * Features: + * - Boundary optimization for performance + * - Block-based processing + * - Two-pass implementation for better performance + * - Edge clamping to prevent artifacts + */ + +const BlurManager = { + // Private state + _currentBlur: 0, // Default blur (no blur) + _observers: new Set(), + _slider: null, + _value: null, + + init() { + this._slider = document.getElementById('blur-slider'); + this._value = document.getElementById('blur-value'); + this._setupEventListeners(); + }, + + _setupEventListeners() { + this._slider.addEventListener('input', () => { + const value = this._slider.value; + this._value.textContent = `${value}%`; + this._currentBlur = parseInt(value); + this._notifyObservers(); + }); + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this._currentBlur)); + }, + + /** + * Subscribes to blur state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + getCurrentBlur() { + return this._currentBlur; + }, + + /** + * Applies optimized box blur to an image + * And implements two-pass blur with content-aware boundary detection + * Uses separate horizontal and vertical passes, which is more performant + * @param {ImageData} imageData - Source image data + * @param {number} radius - Blur radius + * @returns {ImageData} Blurred image data + */ + applyBlur(imageData, radius) { + if (!radius) return imageData; + + const { data, width, height } = imageData; + const tempData = new Uint8ClampedArray(data); + + // Calculate the actual image boundaries + let minX = width, minY = height, maxX = 0, maxY = 0; + let hasContent = false; + + // Find the actual image boundaries + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + if (data[i + 3] > 0) { // Check alpha channel + hasContent = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (!hasContent) return imageData; + + // Add padding to boundaries to prevent edge artifacts + minX = Math.max(0, minX - radius); + minY = Math.max(0, minY - radius); + maxX = Math.min(width - 1, maxX + radius); + maxY = Math.min(height - 1, maxY + radius); + + // First pass: horizontal blur + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + let r = 0, g = 0, b = 0, a = 0; + let count = 0; + + for (let dx = -radius; dx <= radius; dx++) { + const nx = x + dx; + if (nx >= 0 && nx < width) { + const i = (y * width + nx) * 4; + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + a += data[i + 3]; + count++; + } + } + + // Store horizontal blur result + const i = (y * width + x) * 4; + tempData[i] = r / count; + tempData[i + 1] = g / count; + tempData[i + 2] = b / count; + tempData[i + 3] = a / count; + } + } + + // Second pass: vertical blur + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + let r = 0, g = 0, b = 0, a = 0; + let count = 0; + + for (let dy = -radius; dy <= radius; dy++) { + const ny = y + dy; + if (ny >= 0 && ny < height) { + const i = (ny * width + x) * 4; + r += tempData[i]; + g += tempData[i + 1]; + b += tempData[i + 2]; + a += tempData[i + 3]; + count++; + } + } + + // Store final blur result + const i = (y * width + x) * 4; + data[i] = r / count; + data[i + 1] = g / count; + data[i + 2] = b / count; + data[i + 3] = a / count; + } + } + + return imageData; + }, + + reset() { + this._currentBlur = 0; + this._slider.value = 0; + this._value.textContent = '0%'; + this._notifyObservers(); + } +}; \ No newline at end of file diff --git a/js/leibovitz/browserconfig.xml b/js/leibovitz/browserconfig.xml new file mode 100644 index 0000000..c554148 --- /dev/null +++ b/js/leibovitz/browserconfig.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig> \ No newline at end of file diff --git a/js/leibovitz/color.js b/js/leibovitz/color.js new file mode 100644 index 0000000..78f4ebc --- /dev/null +++ b/js/leibovitz/color.js @@ -0,0 +1,245 @@ +/** + * Color tint management module implementing HSL-based color manipulation. + * + * Implements color tinting using HSL color space transformation with circular interpolation. + * Features noise reduction and smooth blending for high-quality results. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: color manipulation algorithms + * - Command Pattern: state reset operations + * + * Color manipulation process: + * 1. Convert RGB to HSL color space + * 2. Apply circular interpolation for hue blending + * 3. Smooth blending for saturation and lightness + * 4. Noise reduction through value rounding + * 5. Convert back to RGB color space + * + * Features: + * - Circular interpolation for natural hue transitions + * - Noise reduction through value rounding + * - Smooth blending with quadratic easing + * - HSL color space for better color manipulation + */ + +const ColorManager = { + // Private state + _currentColor: null, + _observers: new Set(), + _colorInput: null, + + init() { + this._setupEventListeners(); + }, + + _setupEventListeners() { + this._colorInput = document.getElementById('color-tint'); + this._colorInput.addEventListener('input', (e) => { + this._currentColor = e.target.value; + this._notifyObservers(); + }); + + const resetButton = document.getElementById('reset-color'); + resetButton.addEventListener('click', () => { + // Reset color tint + this._currentColor = null; + this._colorInput.value = '#ffffff'; + this._notifyObservers(); + + // Reset contrast + ContrastManager.reset(); + + // Reset blur + BlurManager.reset(); + + // Reset white balance to default (6500K) + const balanceSlider = document.getElementById('balance-slider'); + const balanceValue = document.getElementById('balance-value'); + if (balanceSlider && balanceValue) { + balanceSlider.value = 6500; + balanceValue.textContent = '6500K'; + } + }); + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this._currentColor)); + }, + + /** + * Subscribes to color state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + getCurrentColor() { + return this._currentColor; + }, + + /** + * Applies color tint to an image using HSL color space + * Uses noise reduction and smooth blending for quality + * @param {ImageData} imageData - Source image data + * @param {string} color - Hex color value + * @returns {ImageData} Tinted image data + */ + applyTint(imageData, color) { + if (!color) return imageData; + + const { data } = imageData; + const [tintR, tintG, tintB] = this._hexToRgb(color); + + // Convert tint color to HSL for better color manipulation + const [tintH, tintS, tintL] = this._rgbToHsl(tintR, tintG, tintB); + + // Apply tint to each pixel with reduced noise + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // Convert pixel to HSL + const [h, s, l] = this._rgbToHsl(r, g, b); + + // Blend the tint color with the original color + // This tries to create a more natural LUT effect + const blendFactor = 0.15; // Reduced from 0.3 to 0.15 for smoother effect + + // Smooth blending for hue (circular interpolation) + const newH = this._blendHue(h, tintH, blendFactor); + + // Smooth blending for saturation and lightness with noise reduction + const newS = this._smoothBlend(s, tintS, blendFactor); + const newL = this._smoothBlend(l, tintL, blendFactor); + + // Convert back to RGB with noise reduction + const [newR, newG, newB] = this._hslToRgb(newH, newS, newL); + + // Apply noise reduction by rounding to nearest multiple of 4 + data[i] = Math.round(newR / 4) * 4; + data[i + 1] = Math.round(newG / 4) * 4; + data[i + 2] = Math.round(newB / 4) * 4; + } + + return imageData; + }, + + /** + * Converts hex color to RGB values + * @param {string} hex - Hex color string + * @returns {Array} RGB values [r, g, b] + */ + _hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16) + ] : null; + }, + + /** + * Converts RGB to HSL color space + * @param {number} r - Red component + * @param {number} g - Green component + * @param {number} b - Blue component + * @returns {Array} HSL values [h, s, l] + */ + _rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + + return [h, s, l]; + }, + + /** + * Converts HSL to RGB color space + * @param {number} h - Hue component + * @param {number} s - Saturation component + * @param {number} l - Lightness component + * @returns {Array} RGB values [r, g, b] + */ + _hslToRgb(h, s, l) { + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; + }, + + /** + * Blends two hue values with circular interpolation + * @param {number} h1 - First hue value + * @param {number} h2 - Second hue value + * @param {number} factor - Blend factor + * @returns {number} Blended hue value + */ + _blendHue(h1, h2, factor) { + const diff = h2 - h1; + if (Math.abs(diff) > 0.5) { + if (h1 > h2) { + return h1 + (h2 + 1 - h1) * factor; + } else { + return h1 + (h2 - (h1 + 1)) * factor; + } + } + return h1 + diff * factor; + }, + + /** + * Smooth blending with noise reduction + * Uses cubic easing function for smooth transitions + * @param {number} v1 - First value + * @param {number} v2 - Second value + * @param {number} factor - Blend factor + * @returns {number} Blended value + */ + _smoothBlend(v1, v2, factor) { + // Apply a smooth easing function + const t = factor * factor * (3 - 2 * factor); + return v1 + (v2 - v1) * t; + } +}; \ No newline at end of file diff --git a/js/leibovitz/contrast.js b/js/leibovitz/contrast.js new file mode 100644 index 0000000..c2b1a28 --- /dev/null +++ b/js/leibovitz/contrast.js @@ -0,0 +1,100 @@ +/** + * Contrast management module implementing linear contrast adjustment. + * + * Implements contrast adjustment using a linear scaling algorithm. + * Provides real-time contrast control with immediate visual feedback. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: contrast adjustment algorithm + * - Command Pattern: state reset operations + * + * Contrast adjustment process: + * 1. Calculate contrast factor using formula: (259 * (contrast + 255)) / (255 * (259 - contrast)) + * 2. Apply linear scaling to each color channel + * 3. Maintain color balance while adjusting contrast + * + * Features: + * - Linear contrast adjustment + * - Per-channel processing + * - Real-time updates + * - Preserves color relationships + */ + +const ContrastManager = { + // Private state + _currentContrast: 1.0, // Default contrast (no change) + _observers: new Set(), + _slider: null, + + /** + * Initializes the contrast manager and sets up UI controls + */ + init() { + this._setupEventListeners(); + }, + + /** + * Sets up event listeners for UI controls + */ + _setupEventListeners() { + this._slider = document.getElementById('contrast-slider'); + this._slider.addEventListener('input', (e) => { + this._currentContrast = parseFloat(e.target.value); + document.getElementById('contrast-value').textContent = this._currentContrast; + this._notifyObservers(); + }); + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this._currentContrast)); + }, + + /** + * Subscribes to contrast state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + getCurrentContrast() { + return this._currentContrast; + }, + + /** + * Applies contrast adjustment to an image + * Implements linear contrast adjustment algorithm + * @param {ImageData} imageData - Source image data + * @param {number} contrast - Contrast value + * @returns {ImageData} Contrasted image data + */ + applyContrast(imageData, contrast) { + if (!contrast || contrast === 1.0) return imageData; + + const { data } = imageData; + const factor = (259 * (contrast + 255)) / (255 * (259 - contrast)); + + for (let i = 0; i < data.length; i += 4) { + // Apply contrast to each color channel + for (let c = 0; c < 3; c++) { + const pixel = data[i + c]; + data[i + c] = factor * (pixel - 128) + 128; + } + } + + return imageData; + }, + + /** + * Resets contrast effect to default state + */ + reset() { + this._currentContrast = 1.0; + this._slider.value = 0; // Reset slider to middle position + this._notifyObservers(); + } +}; \ No newline at end of file diff --git a/js/leibovitz/dither.js b/js/leibovitz/dither.js new file mode 100644 index 0000000..e74f1be --- /dev/null +++ b/js/leibovitz/dither.js @@ -0,0 +1,613 @@ +/** + * Dithering management module implementing multiple dithering algorithms. + * + * Implements a couple dithering algorithms with block-based processing. + * Block-based processing is faster, and has better performance. + * Supports multiple dithering patterns with configurable block sizes. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: dithering algorithm selection + * - Command Pattern: state reset operations + * + * Supported dithering algorithms: + * - Floyd-Steinberg: Error diffusion with standard distribution pattern + * - Ordered: Matrix-based threshold dithering + * - Atkinson: Error diffusion with 1/8 error distribution + * - Bayer: Pattern-based threshold dithering + * + * Each color channel (Red, Green, Blue) has 4 possible values: + * - 0 -> Black/None + * - 85 -> Low + * - 170 -> Medium + * - 255 -> Full + * + * Features: + * - Block-based processing for performance + * - Multiple dithering algorithms + * - Configurable block sizes + * - Error diffusion patterns + */ + +const DitherManager = { + // Private state + _currentMode: 'none', + _observers: new Set(), + _modeSelect: null, + _pixelSizeControl: null, + currentBlockSize: 4, + + /** + * Initializes the dither manager and sets up UI controls + */ + init() { + this._setupEventListeners(); + this._pixelSizeControl = document.getElementById('pixel-size-control'); + }, + + _setupEventListeners() { + this._modeSelect = document.getElementById('dither-select'); + this._modeSelect.addEventListener('change', (e) => { + this._currentMode = e.target.value; + // Show/hide pixel size control based on dithering mode + this._pixelSizeControl.style.display = + this._currentMode === 'none' ? 'none' : 'flex'; + this._notifyObservers(); + }); + + // Only add the event listener if the element actually exists + const blockSizeSlider = document.getElementById('block-size-slider'); + if (blockSizeSlider) { + blockSizeSlider.addEventListener('input', (e) => { + this.currentBlockSize = parseInt(e.target.value); + document.getElementById('block-size-value').textContent = + `${this.currentBlockSize}px`; + // Notify observers instead of directly calling processFrame + this._notifyObservers(); + }); + } + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this._currentMode)); + }, + + /** + * Subscribes to dithering state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + getCurrentMode() { + return this._currentMode; + }, + + /** + * Applies selected dithering algorithm to image data + * @param {ImageData} imageData - Source image data + * @param {string} mode - Selected dithering algorithm + * @returns {ImageData} Processed image data + */ + applyDither(imageData, mode) { + if (!mode || mode === 'none') return imageData; + + const { data } = imageData; + const width = imageData.width; + const height = imageData.height; + + switch (mode) { + case 'floyd-steinberg': + return this._floydSteinbergDither(data, width, height); + case 'ordered': + return this._orderedDither(data, width, height); + case 'atkinson': + return this._atkinsonDither(data, width, height); + case 'bayer': + return this._bayerDither(data, width, height); + default: + return imageData; + } + }, + + /** + * Quantizes a value to create chunkier output + * @param {number} value - Input value + * @param {number} levels - Number of quantization levels + * @returns {number} Quantized value + */ + _quantize(value, levels = 4) { + const step = 255 / (levels - 1); + return Math.round(value / step) * step; + }, + + /** + * Applies Floyd-Steinberg dithering algorithm + * Uses a 4x4 error distribution pattern for smoother results + * @param {Uint8ClampedArray} data - Image data + * @param {number} width - Image width + * @param {number} height - Image height + * @returns {ImageData} Dithered image data + */ + _floydSteinbergDither(data, width, height) { + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; + const blockSize = this.currentBlockSize; + + // Process in blocks, block by block + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Apply dithering to the block average + for (let c = 0; c < 3; c++) { + const oldPixel = blockAvg[c]; + const quantizedPixel = this._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } + } + + // Distribute error to neighboring blocks + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y, c, error * 7/16, width, blockSize, height); + } + if (y + blockSize < height) { + if (x - blockSize >= 0) { + this._distributeBlockError(newData, x - blockSize, y + blockSize, c, error * 3/16, width, blockSize, height); + } + this._distributeBlockError(newData, x, y + blockSize, c, error * 5/16, width, blockSize, height); + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y + blockSize, c, error * 1/16, width, blockSize, height); + } + } + } + } + } + + return new ImageData(newData, width, height); + }, + + /** + * Distributes error to neighboring blocks + * @param {Uint8ClampedArray} data - Image data + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {number} channel - Color channel + * @param {number} error - Error value to distribute + * @param {number} width - Image width + * @param {number} blockSize - Size of processing blocks + * @param {number} height - Image height + */ + _distributeBlockError(data, x, y, channel, error, width, blockSize, height) { + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + data[idx + channel] = Math.max(0, Math.min(255, data[idx + channel] + error)); + } + } + }, + + /** + * Applies ordered dithering using a Bayer matrix + * And implements threshold-based dithering with block processing + * Also uses a 4x4 Bayer matrix pattern for structured dithering + * @param {Uint8ClampedArray} data - Image data + * @param {number} width - Image width + * @param {number} height - Image height + * @returns {ImageData} Dithered image data + */ + _orderedDither(data, width, height) { + const newData = new Uint8ClampedArray(data); + const matrix = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5] + ]; + const matrixSize = 4; + const threshold = 128; + const levels = 4; + const blockSize = this.currentBlockSize; + + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Get matrix value for this block + const matrixX = Math.floor(x / blockSize) % matrixSize; + const matrixY = Math.floor(y / blockSize) % matrixSize; + const matrixValue = matrix[matrixY][matrixX] * 16; + + // Apply dithering to the block + for (let c = 0; c < 3; c++) { + const quantizedPixel = this._quantize(blockAvg[c], levels); + const newPixel = quantizedPixel + matrixValue > threshold ? 255 : 0; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } + } + } + } + } + + return new ImageData(newData, width, height); + }, + + /** + * Applies Atkinson dithering algorithm + * @param {Uint8ClampedArray} data - Image data + * @param {number} width - Image width + * @param {number} height - Image height + * @returns {ImageData} Dithered image data + */ + _atkinsonDither(data, width, height) { + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; + const blockSize = this.currentBlockSize; + + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Apply dithering to the block average + for (let c = 0; c < 3; c++) { + const oldPixel = blockAvg[c]; + const quantizedPixel = this._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } + } + + // Distribute error to neighboring blocks (Atkinson pattern) + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y, c, error / 8, width, blockSize, height); + if (x + blockSize * 2 < width) { + this._distributeBlockError(newData, x + blockSize * 2, y, c, error / 8, width, blockSize, height); + } + } + if (y + blockSize < height) { + if (x - blockSize >= 0) { + this._distributeBlockError(newData, x - blockSize, y + blockSize, c, error / 8, width, blockSize, height); + } + this._distributeBlockError(newData, x, y + blockSize, c, error / 8, width, blockSize, height); + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y + blockSize, c, error / 8, width, blockSize, height); + } + } + if (y + blockSize * 2 < height && x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y + blockSize * 2, c, error / 8, width, blockSize, height); + } + } + } + } + + return new ImageData(newData, width, height); + }, + + /** + * Applies Bayer dithering algorithm + * @param {Uint8ClampedArray} data - Image data + * @param {number} width - Image width + * @param {number} height - Image height + * @returns {ImageData} Dithered image data + */ + _bayerDither(data, width, height) { + const newData = new Uint8ClampedArray(data); + const blockSize = this.currentBlockSize; + + const bayerMatrix = [ + [ 0, 8, 2, 10], + [12, 4, 14, 6 ], + [ 3, 11, 1, 9 ], + [15, 7, 13, 5 ] + ]; + + const scaleFactor = 16; + + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Get matrix value for this block position + const matrixX = Math.floor(x / blockSize) % 4; + const matrixY = Math.floor(y / blockSize) % 4; + const threshold = (bayerMatrix[matrixY][matrixX] * scaleFactor); + + // Apply dithering to the block + for (let c = 0; c < 3; c++) { + const normalizedPixel = blockAvg[c]; + const newPixel = normalizedPixel > threshold ? 255 : 0; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } + } + } + } + } + + // Preserve alpha channel + for (let i = 3; i < newData.length; i += 4) { + newData[i] = data[i]; + } + + return new ImageData(newData, width, height); + } +}; + +// Legacy functions for backward compatibility +function bayerDither(imageData, width) { + const data = new Uint8ClampedArray(imageData.data); + const height = imageData.data.length / 4 / width; + const blockSize = DitherManager.currentBlockSize; + + // 8x8 Bayer matrix normalized to 0-1 range + const bayerMatrix = [ + [ 0, 48, 12, 60, 3, 51, 15, 63], + [32, 16, 44, 28, 35, 19, 47, 31], + [ 8, 56, 4, 52, 11, 59, 7, 55], + [40, 24, 36, 20, 43, 27, 39, 23], + [ 2, 50, 14, 62, 1, 49, 13, 61], + [34, 18, 46, 30, 33, 17, 45, 29], + [10, 58, 6, 54, 9, 57, 5, 53], + [42, 26, 38, 22, 41, 25, 37, 21] + ].map(row => row.map(x => x / 64)); + + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += data[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Get threshold from Bayer matrix for this block + const matrixX = Math.floor(x / blockSize) % 8; + const matrixY = Math.floor(y / blockSize) % 8; + const threshold = bayerMatrix[matrixY][matrixX]; + + // Apply threshold to the block + for (let c = 0; c < 3; c++) { + const normalizedPixel = blockAvg[c] / 255; + const newPixel = normalizedPixel > threshold ? 255 : 0; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + data[idx + c] = newPixel; + } + } + } + } + } + + // Preserve alpha channel + for (let i = 3; i < data.length; i += 4) { + data[i] = imageData.data[i]; + } + + return data; +} + +function applyDithering(imageData, width) { + const method = document.getElementById('dither-select').value; + return DitherManager.applyDither(imageData, method); +} + +function floydSteinbergDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + + for (let c = 0; c < 3; c++) { + const oldPixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + newData[idx + c] = newPixel; + + if (x + 1 < width) { + newData[idx + 4 + c] += error * 7 / 16; + } + if (y + 1 === height) continue; + if (x > 0) { + newData[idx + width * 4 - 4 + c] += error * 3 / 16; + } + newData[idx + width * 4 + c] += error * 5 / 16; + if (x + 1 < width) { + newData[idx + width * 4 + 4 + c] += error * 1 / 16; + } + } + } + } + + return new ImageData(newData, width, height); +} + +function atkinsonDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + + for (let c = 0; c < 3; c++) { + const oldPixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + newData[idx + c] = newPixel; + + if (x + 1 < width) { + newData[idx + 4 + c] += error / 8; + } + if (x + 2 < width) { + newData[idx + 8 + c] += error / 8; + } + if (y + 1 === height) continue; + if (x > 0) { + newData[idx + width * 4 - 4 + c] += error / 8; + } + newData[idx + width * 4 + c] += error / 8; + if (x + 1 < width) { + newData[idx + width * 4 + 4 + c] += error / 8; + } + if (y + 2 === height) continue; + if (x + 1 < width) { + newData[idx + width * 8 + 4 + c] += error / 8; + } + } + } + } + + return new ImageData(newData, width, height); +} + +function orderedDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const matrix = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5] + ]; + const matrixSize = 4; + const threshold = 128; + const levels = 4; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const matrixX = x % matrixSize; + const matrixY = y % matrixSize; + const matrixValue = matrix[matrixY][matrixX] * 16; + + for (let c = 0; c < 3; c++) { + const pixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(pixel, levels); + newData[idx + c] = quantizedPixel + matrixValue > threshold ? 255 : 0; + } + } + } + + return new ImageData(newData, width, height); +} \ No newline at end of file diff --git a/js/leibovitz/favicon-16x16.png b/js/leibovitz/favicon-16x16.png new file mode 100644 index 0000000..9293108 --- /dev/null +++ b/js/leibovitz/favicon-16x16.png Binary files differdiff --git a/js/leibovitz/favicon-32x32.png b/js/leibovitz/favicon-32x32.png new file mode 100644 index 0000000..b6b0694 --- /dev/null +++ b/js/leibovitz/favicon-32x32.png Binary files differdiff --git a/js/leibovitz/favicon-96x96.png b/js/leibovitz/favicon-96x96.png new file mode 100644 index 0000000..a4fcd87 --- /dev/null +++ b/js/leibovitz/favicon-96x96.png Binary files differdiff --git a/js/leibovitz/favicon.ico b/js/leibovitz/favicon.ico new file mode 100644 index 0000000..8ce4e6e --- /dev/null +++ b/js/leibovitz/favicon.ico Binary files differdiff --git a/js/leibovitz/index.html b/js/leibovitz/index.html new file mode 100644 index 0000000..6207f07 --- /dev/null +++ b/js/leibovitz/index.html @@ -0,0 +1,548 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Leibovitz</title> + <meta name="description" content="Leibovitz is a web-based camera that lets you make fun photos."> + <link rel="apple-touch-icon" sizes="57x57" href="apple-icon-57x57.png"> + <link rel="apple-touch-icon" sizes="60x60" href="apple-icon-60x60.png"> + <link rel="apple-touch-icon" sizes="72x72" href="apple-icon-72x72.png"> + <link rel="apple-touch-icon" sizes="76x76" href="apple-icon-76x76.png"> + <link rel="apple-touch-icon" sizes="114x114" href="apple-icon-114x114.png"> + <link rel="apple-touch-icon" sizes="120x120" href="apple-icon-120x120.png"> + <link rel="apple-touch-icon" sizes="144x144" href="apple-icon-144x144.png"> + <link rel="apple-touch-icon" sizes="152x152" href="apple-icon-152x152.png"> + <link rel="apple-touch-icon" sizes="180x180" href="apple-icon-180x180.png"> + <link rel="icon" type="image/png" sizes="192x192" href="android-icon-192x192.png"> + <link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="96x96" href="favicon-96x96.png"> + <link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png"> + <link rel="manifest" href="manifest.json"> + <meta name="msapplication-TileColor" content="#ffffff"> + <meta name="msapplication-TileImage" content="ms-icon-144x144.png"> + <meta name="theme-color" content="#ffffff"> + <style> + @font-face { + font-family: 'ChicagoFLF'; + src: url('./ChicagoFLF.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + } + body, html { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background-color: beige; + overflow: hidden; + font-family: 'ChicagoFLF', sans-serif; + font-size: 16px; + } + .preview-container { + flex: 1; + position: relative; + margin: 0; + min-height: 0; + padding-top: 28px; + } + #canvas { + width: 100%; + height: 100%; + object-fit: contain; + display: none; + background-color: transparent; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } + .slide-controls { + position: absolute; + bottom: 20px; + left: 0; + right: 0; + display: none; + justify-content: space-around; + align-items: center; + background-color: rgba(255, 255, 255, 0.8); + padding: 10px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 10; + margin: 0 20px; + } + .slide-controls.visible { + display: flex; + } + .slider-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + flex: 1; + max-width: 200px; + } + .slider-group label { + font-size: 12px; + color: #666; + font-family: 'ChicagoFLF', sans-serif; + } + .slider-group input[type="range"] { + width: 100%; + height: 4px; + -webkit-appearance: none; + background: rgba(0, 0, 0, 0.2); + border-radius: 2px; + outline: none; + } + .slider-group input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: teal; + border-radius: 0; + cursor: pointer; + } + .slider-group input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + background: teal; + border-radius: 0; + cursor: pointer; + border: none; + } + .slider-group .value { + font-size: 12px; + color: #666; + font-family: 'ChicagoFLF', sans-serif; + } + .side-control { + position: absolute; + top: 50%; + height: 100%; + width: auto; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 1em; + padding: 1em 0; + background-color: rgba(255, 255, 255, 0.8); + z-index: 10; + } + .side-control.left { + left: 0; + } + .side-control.right { + right: 0; + } + + .vertical-slider { + transform: rotate(-90deg); + width: 200px; + margin: 90px -80px; + cursor: pointer; + } + input[type="range"]::-webkit-slider-thumb, .vertical-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + background: teal; + border-radius: 0; + cursor: pointer; + } + input[type="range"]::-webkit-slider-runnable-track, .vertical-slider::-webkit-slider-runnable-track { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.8); + border-radius: 2px; + } + input[type="range"]::-moz-range-thumb, .vertical-slider::-moz-range-thumb { + width: 20px; + height: 20px; + background: teal; + border-radius: 0; + cursor: pointer; + border: none; + } + input[type="range"]::-moz-range-track, .vertical-slider::-moz-range-track { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.8); + border-radius: 2px; + } + .vertical-label { + transform: rotate(90deg); + font-size: 12px; + color: #666; + user-select: none; + white-space: nowrap; + margin: 20px 0; + font-family: 'ChicagoFLF', sans-serif; + padding: 0 5px; + } + #blur-value { + font-size: 12px; + color: #666; + user-select: none; + white-space: nowrap; + font-family: 'ChicagoFLF', sans-serif; + padding: 0 5px; + } + #contrast-value { + font-size: 12px; + color: #666; + user-select: none; + white-space: nowrap; + font-family: 'ChicagoFLF', sans-serif; + padding: 0 5px; + } + #controls { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0; + padding: 0; + background-color: rgba(255, 255, 255, 0.8); + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); + flex-shrink: 0; + } + #settings-container { + width: 100%; + display: none; + flex-direction: column; + gap: 10px; + align-items: center; + background-color: rgba(255, 255, 255, 0.8); + padding: 10px; + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); + flex-shrink: 0; + position: relative; + } + #settings-container.visible { + display: flex; + } + #offline-status { + position: fixed; + top: 0; + left: 0; + right: 0; + width: 100%; + font-size: 12px; + color: #666; + display: none; + font-family: 'ChicagoFLF', sans-serif; + background-color: rgba(255, 255, 255, 0.8); + text-align: center; + padding: 4px; + z-index: 1000; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + #offline-status.visible { + display: block; + } + .top-controls { + display: flex; + gap: 10px; + align-items: center; + width: 100%; + justify-content: center; + flex-wrap: nowrap; + } + .color-controls { + display: flex; + gap: 5px; + align-items: center; + flex-shrink: 0; + } + #reset-color { + padding: 8px; + font-size: 18px; + background: none; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + font-family: 'ChicagoFLF', sans-serif; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + } + input[type="color"] { + width: 40px; + height: 40px; + padding: 0; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + } + select { + padding: 10px 15px; + font-family: 'ChicagoFLF', sans-serif; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #f0f0f0; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + line-height: 1.2; + color: #333; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%23333'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 16px; + padding-right: 35px; + } + select:focus { + outline: none; + border-color: teal; + box-shadow: 0 0 0 2px rgba(0, 128, 128, 0.2); + } + #dither-select { + min-width: 200px; + max-width: none; + flex-shrink: 0; + } + button.capture { + background-color: teal; + color: #FFFFFF; + padding: 10px 20px; + font-family: 'ChicagoFLF', sans-serif; + } + #toggle-camera { + padding: 10px 20px; + font-family: 'ChicagoFLF', sans-serif; + border-right: 1px solid rgba(0, 0, 0, 0.1); + } + #toggle-camera.hidden { + display: none; + } + button, select, input[type="color"] { + padding: 10px; + font-size: 18px; + cursor: pointer; + font-family: 'ChicagoFLF', sans-serif; + } + + button:hover, button:focus { + outline: none; + } + + button.capture:disabled { + background-color: #ccc; + color: #5c5c5c; + } + + .contrast-control { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 100%; + max-width: 300px; + margin: 0 auto; + padding: 0 10px; + } + .contrast-control label { + font-size: 12px; + color: #666; + width: 100%; + text-align: center; + font-family: 'ChicagoFLF', sans-serif; + } + .contrast-control input[type="range"] { + width: 100%; + } + .contrast-control .slider-container { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + } + .contrast-control .slider-container input[type="range"] { + flex: 1; + } + #block-size-value { + font-size: 12px; + color: #666; + min-width: 40px; + text-align: right; + font-family: 'ChicagoFLF', sans-serif; + } + #focus-container { + display: none; + } + #toggle-camera, button.capture, #edit-image { + font-size: 18px; + padding: 20px; + font-family: 'ChicagoFLF', sans-serif; + border-radius: 0; + text-align: center; + flex: 1; + } + button.capture:disabled { + background-color: #ccc; + color: #5c5c5c; + } + + @media (max-width: 600px) { + #controls { + flex-direction: column; + } + + #toggle-camera, button.capture, #edit-image { + width: 100%; + border-right: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + + #toggle-camera:last-child, button.capture:last-child, #edit-image:last-child { + border-bottom: none; + } + + .slide-controls { + flex-direction: column; + gap: 12px; + padding: 15px; + } + .slider-group { + width: 100%; + max-width: none; + flex-direction: row; + align-items: center; + gap: 12px; + } + .slider-group label { + min-width: 80px; + text-align: left; + font-size: 12px; + color: #666; + } + .slider-group input[type="range"] { + flex: 1; + } + .slider-group .value { + min-width: 40px; + text-align: right; + font-size: 12px; + color: #666; + } + select { + width: 100%; + max-width: 100%; + } + .top-controls { + flex-wrap: nowrap; + width: auto; + gap: 5px; + } + + #dither-select { + min-width: 150px; + max-width: none; + } + + .color-controls { + flex-shrink: 0; + } + } + #edit-image { + display: block; + padding: 20px; + font-family: 'ChicagoFLF', sans-serif; + border-radius: 0; + text-align: center; + background-color: teal; + color: #FFFFFF; + } + #edit-image.hidden { + display: none; + } + </style> +</head> +<body> + +<div class="preview-container"> + <canvas id="canvas"></canvas> + <div class="slide-controls"> + <div class="slider-group"> + <label for="blur-slider">Blur</label> + <input type="range" id="blur-slider" min="0" max="20" value="0" step="1"> + <span class="value" id="blur-value">0%</span> + </div> + <div class="slider-group"> + <label for="contrast-slider">Contrast</label> + <input type="range" id="contrast-slider" min="-255" max="255" value="0" step="1"> + <span class="value" id="contrast-value">0</span> + </div> + <div class="slider-group"> + <label for="balance-slider">Balance</label> + <input type="range" id="balance-slider" min="2000" max="10000" value="6500" step="100"> + <span class="value" id="balance-value">6500K</span> + </div> + <div class="slider-group" id="focus-control" style="display: none;"> + <label for="focus-slider">Focus</label> + <input type="range" id="focus-slider" min="0" max="100" step="1" value="50" disabled> + <span class="value" id="focus-value">50%</span> + </div> + <div class="slider-group" id="pixel-size-control" style="display: none;"> + <label for="block-size-slider">Pixel Size</label> + <input type="range" id="block-size-slider" min="1" max="12" value="4" step="1"> + <span class="value" id="block-size-value">4px</span> + </div> + </div> +</div> + +<div id="settings-container"> + <div id="offline-status">Offline Mode</div> + <div class="top-controls"> + <select id="dither-select"> + <option value="none">No Dithering</option> + <option value="floyd-steinberg">Floyd-Steinberg</option> + <option value="ordered">Ordered</option> + <option value="atkinson">Atkinson</option> + <option value="bayer">Bayer</option> + </select> + <div class="color-controls"> + <input type="color" id="color-tint" title="Color Tint"> + <button id="reset-color" title="Reset Color Tint">↺</button> + </div> + </div> +</div> + +<div id="controls"> + <button id="toggle-camera">Camera On</button> + <button id="edit-image" class="edit-image">Upload Image</button> + <button id="capture" disabled class="capture">Capture Image</button> + <input type="file" id="image-input" accept="image/*" style="display: none;"> +</div> + +<script src="dither.js"></script> +<script src="contrast.js"></script> +<script src="color.js"></script> +<script src="blur.js"></script> +<script src="balance.js"></script> +<script src="leibovitz.js"></script> +<script> +// Add offline status handling +window.addEventListener('online', () => { + document.getElementById('offline-status').classList.remove('visible'); +}); + +window.addEventListener('offline', () => { + document.getElementById('offline-status').classList.add('visible'); +}); + +// Check initial online status +if (!navigator.onLine) { + document.getElementById('offline-status').classList.add('visible'); +} +</script> +</body> +</html> diff --git a/js/leibovitz/leibovitz.js b/js/leibovitz/leibovitz.js new file mode 100644 index 0000000..5cd6f2d --- /dev/null +++ b/js/leibovitz/leibovitz.js @@ -0,0 +1,446 @@ +/** + * Start here. + * + * Susan Sontag: + * > The camera makes everyone a tourist in other people's reality, + * > and eventually in one's own. + * + * Uses multiple design patterns for state management and applying effects: + * - Observer Pattern: state management and effect application across modules + * - State Pattern: mode management (camera/edit) + * - Factory Pattern: UI initialization and media device creation + * - Strategy Pattern: algorithm selection when applying an effect + * - Command Pattern: canvas operations and state reset + * - Chain of Responsibility: sequential effect application + * + * + * Separate manager modules for each effect: + * - ColorManager: HSL-based color manipulation + * - DitherManager: multiple dithering algorithms + * - ContrastManager: linear contrast adjustment + * - BlurManager: optimized box blur + * - BalanceManager: temperature-based color adjustment + * + * + */ + +const canvas = document.getElementById('canvas'); +const ctx = canvas.getContext('2d'); +const video = document.createElement('video'); +const toggleCameraButton = document.getElementById('toggle-camera'); +const captureButton = document.getElementById('capture'); +const editImageButton = document.getElementById('edit-image'); +const imageInput = document.getElementById('image-input'); +const focusControl = document.getElementById('focus-control'); +const focusSlider = document.getElementById('focus-slider'); +const focusValue = document.getElementById('focus-value'); +const slideControls = document.querySelector('.slide-controls'); + +let cameraOn = false; +let stream = null; +let track = null; +let isEditMode = false; +let originalImage = null; // Store the original image for edit mode + +// Initialize managers +ColorManager.init(); +DitherManager.init(); +ContrastManager.init(); +BlurManager.init(); +BalanceManager.init(); + +/** + * Updates visibility of controls based on camera/edit mode state + */ +function updateSliderControlsVisibility() { + const settingsContainer = document.getElementById('settings-container'); + if (cameraOn || isEditMode) { + slideControls.classList.add('visible'); + settingsContainer.classList.add('visible'); + } else { + slideControls.classList.remove('visible'); + settingsContainer.classList.remove('visible'); + } +} + +/** + * Updates canvas dimensions while maintaining aspect ratio + */ +function updateCanvasSize() { + const container = document.querySelector('.preview-container'); + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + if (video.videoWidth && video.videoHeight) { + const videoAspect = video.videoWidth / video.videoHeight; + const containerAspect = containerWidth / containerHeight; + + if (containerAspect > videoAspect) { + canvas.height = containerHeight; + canvas.width = containerHeight * videoAspect; + } else { + canvas.width = containerWidth; + canvas.height = containerWidth / videoAspect; + } + } else { + canvas.width = containerWidth; + canvas.height = containerHeight; + } +} + +window.addEventListener('resize', () => { + if (cameraOn || isEditMode) { + updateCanvasSize(); + if (isEditMode && originalImage) { + applyEffects(); + } + } +}); + +updateCanvasSize(); + +function clearCanvas() { + const container = document.querySelector('.preview-container'); + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + canvas.width = containerWidth; + canvas.height = containerHeight; + ctx.clearRect(0, 0, containerWidth, containerHeight); + ctx.setTransform(1, 0, 0, 1, 0, 0); + canvas.style.display = 'none'; +} + +/** + * Initializes camera access and sets up video stream + * Implements the Factory pattern for media device creation + * Uses the State pattern for mode management and UI state + */ +function startCamera() { + if (isEditMode) { + if (!confirm('Switching to camera mode will discard your current image and edits. Continue?')) { + return; + } + isEditMode = false; + originalImage = null; + clearCanvas(); + } + + navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } }) + .then(s => { + stream = s; + video.srcObject = stream; + video.play(); + canvas.style.display = 'block'; + captureButton.disabled = false; + captureButton.active = true; + editImageButton.classList.add('hidden'); + toggleCameraButton.classList.add('hidden'); + isEditMode = false; + originalImage = null; + updateSliderControlsVisibility(); + + track = stream.getVideoTracks()[0]; + const settings = track.getSettings(); + + // Feature detection for focus control + // Relatively untested because I don't have a device with focus control + if ('focusDistance' in settings) { + focusControl.style.display = 'flex'; + focusSlider.disabled = false; + focusSlider.value = settings.focusDistance || focusSlider.min; + focusValue.textContent = `${focusSlider.value}%`; + + focusSlider.addEventListener('input', () => { + const value = focusSlider.value; + focusValue.textContent = `${value}%`; + track.applyConstraints({ + advanced: [{ focusDistance: value }] + }); + }); + } else { + console.warn('Focus control is not supported on this device.'); + focusControl.style.display = 'none'; + } + + // Animation loop using requestAnimationFrame + video.addEventListener('play', function() { + function step() { + if (!cameraOn) return; + drawVideoProportional(); + applyContrast(); + applyColorTint(); + applyBlur(); + applyBalance(); + applyDither(); + requestAnimationFrame(step); + } + requestAnimationFrame(step); + }); + }) + .catch(err => { + console.error('Error accessing camera: ', err); + toggleCameraButton.classList.remove('hidden'); + }); +} + +/** + * Stops camera stream and resets UI state + */ +function stopCamera() { + if (stream) { + stream.getTracks().forEach(track => track.stop()); + video.pause(); + clearCanvas(); + captureButton.disabled = true; + captureButton.active = false; + focusSlider.disabled = true; + focusControl.style.display = 'none'; + stream = null; + editImageButton.classList.remove('hidden'); + toggleCameraButton.classList.remove('hidden'); + updateSliderControlsVisibility(); + } +} + +/** + * Loads and displays an image file + * Uses aspect ratio preservation strategy for responsive display + */ +function loadImage(file) { + const reader = new FileReader(); + reader.onload = function(e) { + const img = new Image(); + img.onload = function() { + clearCanvas(); + + const container = document.querySelector('.preview-container'); + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + const imgAspect = img.width / img.height; + const containerAspect = containerWidth / containerHeight; + + let canvasWidth, canvasHeight; + + if (containerAspect > imgAspect) { + canvasHeight = containerHeight; + canvasWidth = containerHeight * imgAspect; + } else { + canvasWidth = containerWidth; + canvasHeight = containerWidth / imgAspect; + } + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + originalImage = img; + + ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight); + canvas.style.display = 'block'; + captureButton.disabled = false; + captureButton.active = true; + updateSliderControlsVisibility(); + + function step() { + if (!isEditMode) return; + applyEffects(); + requestAnimationFrame(step); + } + requestAnimationFrame(step); + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); +} + +/** + * Sequentially applies all effects to the original image + */ +function applyEffects() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height); + applyContrast(); + applyColorTint(); + applyBlur(); + applyBalance(); + applyDither(); +} + +/** + * Draws video feed maintaining aspect ratio + */ +function drawVideoProportional() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const videoAspectRatio = video.videoWidth / video.videoHeight; + const canvasAspectRatio = canvas.width / canvas.height; + + let drawWidth, drawHeight; + + if (canvasAspectRatio > videoAspectRatio) { + drawHeight = canvas.height; + drawWidth = videoAspectRatio * drawHeight; + } else { + drawWidth = canvas.width; + drawHeight = drawWidth / videoAspectRatio; + } + + const offsetX = (canvas.width - drawWidth) / 2; + const offsetY = (canvas.height - drawHeight) / 2; + + ctx.drawImage(video, offsetX, offsetY, drawWidth, drawHeight); +} + +function applyColorTint() { + const currentColor = ColorManager.getCurrentColor(); + if (!currentColor) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const tintedImageData = ColorManager.applyTint(imageData, currentColor); + ctx.putImageData(tintedImageData, 0, 0); +} + +function applyBalance() { + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const balancedImageData = BalanceManager.applyBalance(imageData); + ctx.putImageData(balancedImageData, 0, 0); +} + +function applyContrast() { + const currentContrast = ContrastManager.getCurrentContrast(); + if (!currentContrast || currentContrast === 1.0) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const contrastedImageData = ContrastManager.applyContrast(imageData, currentContrast); + ctx.putImageData(contrastedImageData, 0, 0); +} + +function applyDither() { + const currentMode = DitherManager.getCurrentMode(); + if (!currentMode || currentMode === 'none') return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const ditheredImageData = DitherManager.applyDither(imageData, currentMode); + ctx.putImageData(ditheredImageData, 0, 0); +} + + +function applyBlur() { + const currentBlur = BlurManager.getCurrentBlur(); + if (!currentBlur) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const blurredImageData = BlurManager.applyBlur(imageData, currentBlur); + ctx.putImageData(blurredImageData, 0, 0); +} + +/** + * Captures the current canvas state with effects + */ +captureButton.addEventListener('click', () => { + const currentColor = ColorManager.getCurrentColor(); + const borderWidth = 4; + + const captureCanvas = document.createElement('canvas'); + const captureCtx = captureCanvas.getContext('2d'); + + captureCanvas.width = canvas.width + (borderWidth * 2); + captureCanvas.height = canvas.height + (borderWidth * 2); + + if (currentColor) { + captureCtx.fillStyle = currentColor; + captureCtx.fillRect(0, 0, captureCanvas.width, captureCanvas.height); + } + + captureCtx.drawImage(canvas, borderWidth, borderWidth); + + const link = document.createElement('a'); + link.download = 'captured-image.png'; + link.href = captureCanvas.toDataURL('image/png'); + link.click(); +}); + +toggleCameraButton.addEventListener('click', () => { + cameraOn = !cameraOn; + if (cameraOn) { + startCamera(); + toggleCameraButton.textContent = 'Camera Off'; + } else { + stopCamera(); + toggleCameraButton.textContent = 'Camera On'; + } +}); + +editImageButton.addEventListener('click', () => { + if (!cameraOn) { + imageInput.click(); + } +}); + +imageInput.addEventListener('change', (e) => { + if (e.target.files && e.target.files[0]) { + isEditMode = true; + loadImage(e.target.files[0]); + } +}); + +/** + * Service Worker registration for offline functionality + */ +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/service-worker.js') + .then(registration => { + console.log('ServiceWorker registration successful with scope: ', registration.scope); + }, err => { + console.log('ServiceWorker registration failed: ', err); + }); + }); +} + +ColorManager._setupEventListeners(); + +function resetEffects() { + if (isEditMode && originalImage) { + applyEffects(); + } +} + +/** + * Reset handlers for each effect manager + */ +BlurManager.reset = function() { + this._currentBlur = 0; + this._slider.value = 0; + this._value.textContent = '0%'; + this._notifyObservers(); + resetEffects(); +}; + +ContrastManager.reset = function() { + this._currentContrast = 1.0; + this._slider.value = 0; + document.getElementById('contrast-value').textContent = '0'; + this._notifyObservers(); + resetEffects(); +}; + +ColorManager.reset = function() { + this._currentColor = null; + this._colorInput.value = '#ffffff'; + this._notifyObservers(); + resetEffects(); +}; + +BalanceManager.reset = function() { + this.balanceSlider.value = 6500; + this.balanceValue.textContent = '6500K'; + resetEffects(); +}; + +DitherManager.reset = function() { + this._currentMode = 'none'; + this._modeSelect.value = 'none'; + this._notifyObservers(); + resetEffects(); +}; \ No newline at end of file diff --git a/js/leibovitz/manifest.json b/js/leibovitz/manifest.json new file mode 100644 index 0000000..1ddc0b2 --- /dev/null +++ b/js/leibovitz/manifest.json @@ -0,0 +1,71 @@ +{ + "name": "Leibovitz", + "short_name": "Leibovitz", + "description": "A web-based camera that lets you make fun photos", + "start_url": ".", + "display": "standalone", + "background_color": "#f5f5dc", + "theme_color": "#f5f5dc", + "orientation": "portrait", + "icons": [ + { + "src": "android-icon-36x36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75" + }, + { + "src": "android-icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0" + }, + { + "src": "android-icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5" + }, + { + "src": "android-icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0" + }, + { + "src": "android-icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0" + }, + { + "src": "android-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0" + }, + { + "src": "apple-icon-180x180.png", + "sizes": "180x180", + "type": "image/png" + } + ], + "categories": ["photo", "camera", "art"], + "prefer_related_applications": false, + "shortcuts": [ + { + "name": "Take Photo", + "short_name": "Camera", + "description": "Open camera to take a photo", + "url": "?action=camera", + "icons": [{ "src": "android-icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Edit Photo", + "short_name": "Edit", + "description": "Open photo editor", + "url": "?action=edit", + "icons": [{ "src": "android-icon-96x96.png", "sizes": "96x96" }] + } + ] +} \ No newline at end of file diff --git a/js/leibovitz/ms-icon-144x144.png b/js/leibovitz/ms-icon-144x144.png new file mode 100644 index 0000000..ae37a7e --- /dev/null +++ b/js/leibovitz/ms-icon-144x144.png Binary files differdiff --git a/js/leibovitz/ms-icon-150x150.png b/js/leibovitz/ms-icon-150x150.png new file mode 100644 index 0000000..d9edbdb --- /dev/null +++ b/js/leibovitz/ms-icon-150x150.png Binary files differdiff --git a/js/leibovitz/ms-icon-310x310.png b/js/leibovitz/ms-icon-310x310.png new file mode 100644 index 0000000..9512221 --- /dev/null +++ b/js/leibovitz/ms-icon-310x310.png Binary files differdiff --git a/js/leibovitz/ms-icon-70x70.png b/js/leibovitz/ms-icon-70x70.png new file mode 100644 index 0000000..45b1734 --- /dev/null +++ b/js/leibovitz/ms-icon-70x70.png Binary files differdiff --git a/js/leibovitz/service-worker.js b/js/leibovitz/service-worker.js new file mode 100644 index 0000000..9ac287e --- /dev/null +++ b/js/leibovitz/service-worker.js @@ -0,0 +1,89 @@ +const CACHE_NAME = 'leibovitz-cache-v1'; +const urlsToCache = [ + '.', + 'index.html', + 'leibovitz.js', + 'blur.js', + 'contrast.js', + 'color.js', + 'balance.js', + 'dither.js', + 'service-worker.js', + 'ChicagoFLF.ttf', + 'manifest.json', + // Icons + 'android-icon-192x192.png', + 'android-icon-512x512.png', + 'favicon.ico', + 'favicon-16x16.png', + 'favicon-32x32.png', + 'favicon-96x96.png', + 'apple-icon-57x57.png', + 'apple-icon-60x60.png', + 'apple-icon-72x72.png', + 'apple-icon-76x76.png', + 'apple-icon-114x114.png', + 'apple-icon-120x120.png', + 'apple-icon-144x144.png', + 'apple-icon-152x152.png' +]; + +// Install event - cache all necessary files +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + return cache.addAll(urlsToCache); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + if (cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + }) + ); + }) + ); +}); + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', event => { + event.respondWith( + caches.match(event.request) + .then(response => { + // Return cached response if found + if (response) { + return response; + } + + // Clone the request because it can only be used once + const fetchRequest = event.request.clone(); + + // Try to fetch from network + return fetch(fetchRequest).then(response => { + // Check if we received a valid response + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + // Clone the response because it can only be used once + const responseToCache = response.clone(); + + // Cache the fetched response + caches.open(CACHE_NAME) + .then(cache => { + cache.put(event.request, responseToCache); + }); + + return response; + }); + }) + ); +}); \ No newline at end of file diff --git a/js/pipe.js b/js/pipe.js index 69ccae3..ace5fb9 100644 --- a/js/pipe.js +++ b/js/pipe.js @@ -1,2 +1,6 @@ const pipe = (...args) => args.reduce((acc, el) => el(acc)); +/* alt implementation +const pipe = (...fns) => (initialValue) => + fns.reduce((acc, fn) => fn(acc), initialValue); +*/ \ No newline at end of file diff --git a/js/sentiment/sentiment/index.html b/js/sentiment/sentiment/index.html index 90698e4..f84d42c 100644 --- a/js/sentiment/sentiment/index.html +++ b/js/sentiment/sentiment/index.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Sentiment Analyzer</title> + <title>Naive Sentiment Analyzer</title> <link href="https://smallandnearlysilent.com/sentiment/PressStart2P-Regular.ttf" rel="stylesheet"> <style> @font-face { @@ -172,7 +172,6 @@ </style> </head> <body> - <!-- Pixel art corners --> <div class="pixel-corner top-left"></div> <div class="pixel-corner top-right"></div> <div class="pixel-corner bottom-left"></div> @@ -182,58 +181,33 @@ <div class="window"> <p> - This bookmarklet analyzes the emotional tone of any webpage. + This bookmarklet analyzes the emotional tone of any webpage...badly. </p> </div> <div class="installation"> <h2>Installation guide</h2> - <p><strong>Drag this power-up to your inventory:</strong></p> + <p><strong>Drag this bookmarklet to your bookmarks bar:</strong></p> <a class="bookmarklet" href="javascript:void function(){let e=document.createElement('script');e.src='https://smallandnearlysilent.com/sentiment/sentiment.browser.js',e.onload=function(){analyzePage()},document.head.appendChild(e)}();"> Sentiment </a> - - <p><strong>Installation steps:</strong></p> - <ol> - <li>Open your bookmarks (<code>ctrl/cmd + shift + b</code>)</li> - <li>Drag the bookmarklet to your bookmarks bar</li> - <li>Cook</li> - </ol> </div> <h2>How to use it</h2> <ol> - <li>Navigate to target webpage</li> + <li>Navigate to a web page</li> <li>Click the bookmarklet in your bookmarks bar</li> <li>It'll analyze the page and display the results in a popup</li> </ol> - <div class="window"> - <h3>Results include:</h3> - <ul> - <li>Emotional alignment</li> - <li>Power level</li> - <li>Sentiment score</li> - <li>Word statistics</li> - <li>Positive/negative ratio</li> - </ul> - </div> - <div class="warning"> - <h3>Important notice</h3> + <h3>Considerations</h3> <ul> - <li>Optimal targets: articles & blog posts</li> + <li>Primarily works with articles and blog posts</li> <li>English text only</li> - <li>Some sites may have shields up</li> - <li>No data collection - stealth mode</li> </ul> </div> - <h2>Security Considerations</h2> - <p> - Everything runs locally. No data is transmitted anywhere. - </p> - <footer> <p> <a href="sentiment.browser.js" target="_blank">VIEW THE SCRIPT</a> @@ -243,7 +217,7 @@ <script> document.querySelector('.bookmarklet').addEventListener('click', function(e) { e.preventDefault(); - alert('COMMAND INVALID!\nDRAG TO INVENTORY (BOOKMARKS) INSTEAD!'); + alert('COMMAND INVALID!\nDRAG TO BOOKMARKS INSTEAD OF CLICKING!'); }); </script> </body> |