diff options
33 files changed, 7914 insertions, 38 deletions
@@ -7,3 +7,18 @@ https://laravel.com/api/11.x/ https://laravel.com/docs/11.x/blade +#### TODO: +- writing + - add drag-drop file to upload and then generate appropriate html to embed that file + - auto capitalization https://marked.js.org/using_pro + - index filter and sort + - quickbuttons, for adding in snippets of things like embedding an image + - update markup on a timer instead of every character (and/or see if i can prevent it from making unnecessary network requests like loading embedded files) + +- general + - import any model via json + - links + - break out the canvas cursor renderer from main.js + +- user dashboard system +
\ No newline at end of file diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..7335629 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,54 @@ +<?php + +namespace App\Http\Controllers; + +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; + +class DashboardController extends Controller +{ + /** + * Show the user dashboard. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function index() + { + $user = Auth::user(); + + // You can add any additional data preparation here + // before passing to the view + + return view('dashboard'); + } + + /** + * Show activity statistics. + * + * @return \Illuminate\Http\JsonResponse + */ + public function statistics() + { + $user = Auth::user(); + + $stats = [ + 'files' => [ + 'count' => $user->files()->count(), + 'size' => $user->files()->sum('size'), + 'recent' => $user->files()->latest()->take(5)->get() + ], + 'writings' => [ + 'count' => $user->writings()->count(), + 'recent' => $user->writings()->latest()->take(5)->get() + ], + 'storage' => [ + 'used' => $user->getStorageUsed(), + 'total' => $user->storage_quota, + 'percent' => min(100, round(($user->getStorageUsed() / max(1, $user->storage_quota)) * 100)) + ] + ]; + + return response()->json($stats); + } + +} diff --git a/app/Http/Controllers/FileController.php b/app/Http/Controllers/FileController.php index c56b6f2..5283ad5 100644 --- a/app/Http/Controllers/FileController.php +++ b/app/Http/Controllers/FileController.php @@ -3,63 +3,259 @@ namespace App\Http\Controllers; use App\Models\File; +use App\Models\Tag; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Auth; +use Carbon\Carbon; class FileController extends Controller { /** - * Display a listing of the resource. + * Display a listing of files. */ - public function index() + public function index(Request $request) { - // + $baseDir = 'storage/uploads/'; + $currentDir = $baseDir; + + // Directory handling logic (similar to f.blade.php) + if ($request->has('dir')) { + $dir = $request->dir; + // Security checks... + $currentDir = "${baseDir}/${dir}"; + } + + // Get file listings + $contents = scandir($currentDir); + $fileData = []; + + foreach ($contents as $item) { + if ($item !== "." && $item !== "..") { + $path = $currentDir . '/' . $item; + $isDir = is_dir($path); + + if (!$isDir) { + // Try to find file in database to get tags + $fileModel = File::where('filename', $item)->first(); + $tags = $fileModel ? $fileModel->tags : collect(); + } else { + $tags = collect(); + } + + // Build file info + $fileData[] = [ + 'name' => $item, + 'path' => $path, + 'is_dir' => $isDir, + 'size' => $isDir ? 0 : filesize($path), + 'modified' => filemtime($path), + 'extension' => $isDir ? '' : pathinfo($item, PATHINFO_EXTENSION), + 'tags' => $tags + ]; + } + } + + // Get all available tags for the tagging UI + $allTags = Tag::orderBy('label')->get(); + + return view('files.index', compact('fileData', 'allTags', 'currentDir')); } /** - * Show the form for creating a new resource. + * Store a newly created file in storage. */ - public function create() + public function store(Request $request) { - // + $dir = 'uploads'; + if ($request->has('current_dir')) { + // Extract relative directory path if provided + $dir = str_replace('storage/', '', $request->current_dir); + } + + // Validate files + $maxsize = config('filesystems.max_upload_size', 10240); // Default to 10MB + $validated = $request->validate([ + 'f' => "required|array|max:${maxsize}", + 'f.*' => "required|file|max:${maxsize}" + ]); + + $results = [ + 'num_files' => count($request->file('f')), + 'num_uploaded' => 0, + 'files' => [] + ]; + + // Process tags + $tags = []; + if ($request->has('tags') && !empty($request->tags)) { + $tags = array_map('trim', explode(',', $request->tags)); + } + + foreach ($request->file('f') as $file) { + $filename = $file->getClientOriginalName(); + + // Avoid duplicate filenames by adding timestamp + if (Storage::disk('public')->exists("${dir}/${filename}")) { + $filename = Carbon::now()->timestamp . '_' . $filename; + } + + // Store the file + $path = $file->storeAs($dir, $filename, 'public'); + + if ($path) { + // Create file record in database + $fileModel = File::create([ + 'filename' => $filename, + 'path' => $path, + 'source' => $request->ip(), + 'description' => '', + 'md5' => md5_file($file->getRealPath()), + 'size' => $file->getSize(), + 'mime_type' => $file->getMimeType(), + 'user_id' => Auth::id() + ]); + + // Add tags if provided + if (!empty($tags)) { + $fileModel->addTags($tags); + } + + $results['num_uploaded']++; + $results['files'][] = [ + 'filename' => $filename, + 'path' => $path, + 'id' => $fileModel->id + ]; + } + } + + // Return based on requested response format + if ($request->input('response_format') === 'html') { + return redirect()->back()->with('success', "{$results['num_uploaded']} files uploaded successfully"); + } + + return response()->json($results); } /** - * Store a newly created resource in storage. + * Add tags to an existing file */ - public function store(Request $request) + public function addTags(Request $request, $fileId) { - // + $file = File::findOrFail($fileId); + + $validated = $request->validate([ + 'tags' => 'required|string' + ]); + + $tags = array_map('trim', explode(',', $validated['tags'])); + $file->addTags($tags); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'tags' => $file->tags + ]); + } + + return redirect()->back()->with('success', 'Tags added successfully'); } /** - * Display the specified resource. + * Remove a tag from a file */ - public function show(File $file) + public function removeTag(Request $request, $fileId, $tagId) { - // + $file = File::findOrFail($fileId); + $tag = Tag::findOrFail($tagId); + + $file->tags()->detach($tagId); + $tag->decrement('refs'); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true + ]); + } + + return redirect()->back()->with('success', 'Tag removed'); } /** - * Show the form for editing the specified resource. + * Display the specified file info */ - public function edit(File $file) + public function show(File $file) { - // + return view('files.show', compact('file')); } /** - * Update the specified resource in storage. + * Update the specified file's info */ public function update(Request $request, File $file) { - // + $validated = $request->validate([ + 'description' => 'nullable|string', + 'tags' => 'nullable|string' + ]); + + $file->description = $validated['description'] ?? $file->description; + $file->save(); + + if (isset($validated['tags'])) { + $tags = array_map('trim', explode(',', $validated['tags'])); + $file->tags()->detach(); + $file->addTags($tags); + } + + return redirect()->back()->with('success', 'File updated successfully'); } /** - * Remove the specified resource from storage. + * Remove the specified file */ public function destroy(File $file) { - // + // Remove the file from storage + if (Storage::disk('public')->exists($file->path)) { + Storage::disk('public')->delete($file->path); + } + + // Delete from database (will auto detach tags) + $file->delete(); + + return redirect()->route('files.index')->with('success', 'File deleted successfully'); + } + + /** + * Download a file + */ + public function download($filename) + { + $file = File::where('filename', $filename)->first(); + + if (!$file) { + // Try to find file in storage even if not in DB + $path = 'uploads/' . $filename; + if (Storage::disk('public')->exists($path)) { + return Storage::disk('public')->download($path); + } + + abort(404, 'File not found'); + } + + return Storage::disk('public')->download($file->path); + } + + /** + * List files by tag + */ + public function byTag($tagId) + { + $tag = Tag::findOrFail($tagId); + $files = $tag->files()->paginate(20); + + return view('files.by-tag', compact('tag', 'files')); } -} +}
\ No newline at end of file diff --git a/app/Http/Controllers/SiteController.php b/app/Http/Controllers/SiteController.php index 2a2df47..722cc91 100644 --- a/app/Http/Controllers/SiteController.php +++ b/app/Http/Controllers/SiteController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Storage; use Carbon\Carbon; @@ -10,6 +11,29 @@ use Carbon\Carbon; class SiteController extends Controller { + public static $MODELS = [ + 'User', + 'File', + 'Link', + 'Tag', + 'Writing', + 'Inventory', + 'Item', + 'Role', + 'Transaction' + ]; + public static $MODELS_LC_PLURAL = [ + 'users', + 'files', + 'links', + 'tags', + 'writings', + 'inventories', + 'items', + 'roles', + 'transactions' + ]; + //returns an env mapping to be used by client. pretty much a subset of laravel app's env, but might also have addition things public static function jsenv(){ $vars = [ @@ -105,17 +129,29 @@ class SiteController extends Controller //todo 2/14 public function importItems(Request $request) { + if (Auth::user()->role != 0) { //admin + return response()->json([ + 'success' => false, + 'message' => 'Unauthorized' + ], 401); + } if (isset($request->modelType)){ //model type must be specified as a string $modelClass = 'App\\Models\\' . ucfirst($request->modelType); if (class_exists($modelClass)){ $jsonFile = $request->file('jsonFile'); - var_dump($jsonFile); - return; - + if ($jsonFile == null){ + return response()->json([ + 'success' => false, + 'message' => 'No file uploaded' + ], 400); + } + $f = str($jsonFile->get()); + $items = json_decode($f); //this should be an array + //TODO verify valid json if (!is_array($items)) { return response()->json([ 'success' => false, - 'message' => 'Input must be an array of items' + 'message' => 'json in submitted file must be an array of items' ], 400); } @@ -125,8 +161,13 @@ class SiteController extends Controller foreach ($items as $item) { try { $instance = new $modelClass(); - $instance->fill($item); - $instance->save(); + foreach ($instance->fillable as $field) { + if (isset($item->$field)){ + $instance->$field = $item->$field; + } + } + $instance->user_id = Auth::user()->id; + $instance->saveOrFail(); $imported++; } catch (\Exception $e) { $failed++; diff --git a/app/Models/File.php b/app/Models/File.php index e73ce08..8fb08ab 100644 --- a/app/Models/File.php +++ b/app/Models/File.php @@ -9,7 +9,71 @@ class File extends Model { use AutoFillable; - public function tags(){ + protected $fillable = [ + 'filename', + 'path', + 'source', + 'description', + 'md5', + 'size', + 'mime_type', + 'user_id' + ]; + + public function tags() + { return $this->belongsToMany(Tag::class); } -} + + public function user() + { + return $this->belongsTo(User::class); + } + + // Helper methods for tags + public function addTags(array $tagNames) + { + $tagIds = []; + + foreach ($tagNames as $tagName) { + $tagName = trim($tagName); + if (empty($tagName)) continue; + + // Find or create tag + $tag = Tag::firstOrCreate( + ['label' => $tagName], + ['refs' => 0] + ); + + // Increment ref count if new relationship + if (!$this->tags->contains($tag->id)) { + $tag->increment('refs'); + } + + $tagIds[] = $tag->id; + } + + if (!empty($tagIds)) { + $this->tags()->syncWithoutDetaching($tagIds); + } + + return $this; + } + + public function removeTags(array $tagNames) + { + $tagsToRemove = Tag::whereIn('label', $tagNames)->get(); + + foreach ($tagsToRemove as $tag) { + $this->tags()->detach($tag->id); + $tag->decrement('refs'); + } + + return $this; + } + + public function getTagsString() + { + return $this->tags->pluck('label')->implode(', '); + } +}
\ No newline at end of file diff --git a/app/Models/Link.php b/app/Models/Link.php index 3373510..6f3648c 100644 --- a/app/Models/Link.php +++ b/app/Models/Link.php @@ -8,7 +8,7 @@ use App\Models\Traits\AutoFillable; class Link extends Model { //use AutoFillable; - protected $fillable = ['label', 'url', 'description', 'user_id']; + public $fillable = ['label', 'url', 'description', 'user_id']; public function tags(){ return $this->belongsToMany(Tag::class); diff --git a/app/Models/Role.php b/app/Models/Role.php index e9a5f86..78412c4 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -6,6 +6,14 @@ use Illuminate\Database\Eloquent\Model; class Role extends Model { + + protected $fillable = [ + 'name', + 'description', + 'max_filesize', + 'storage_quota' + ]; + public function users() { return $this->belongsToMany(User::class); diff --git a/app/Models/User.php b/app/Models/User.php index e908679..3c5e429 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -60,4 +60,19 @@ class User extends Authenticatable public function links(){ return $this->hasMany(Link::class); } + + public function getStorageQuota(){ + $roles = $this->roles(); + $quota = 0; + foreach ($roles as $r){ + if ($role->storage_quota > $quota){ + $quota = $role->storage_quota; + } + } + return $quota; + } + + public function getStorageUsed(){ + + } } diff --git a/database/migrations/2025_02_26_223431_roles_add_filefields.php b/database/migrations/2025_02_26_223431_roles_add_filefields.php new file mode 100644 index 0000000..22222b5 --- /dev/null +++ b/database/migrations/2025_02_26_223431_roles_add_filefields.php @@ -0,0 +1,35 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('roles', function (Blueprint $table) { + if (!Schema::hasColumn('roles', 'max_file_size')) { + $table->integer('max_filesize')->default(0); // in KB, 0 means use system default + } + + if (!Schema::hasColumn('roles', 'storage_quota')) { + $table->integer('storage_quota')->default(102400)->after('max_filesize'); // 100MB in KB + } + }); + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('roles', function (Blueprint $table) { + $table->dropColumn(['max_filesize', 'storage_quota']); + }); + } +}; diff --git a/database/migrations/2025_03_04_050210_link_url_unique.php b/database/migrations/2025_03_04_050210_link_url_unique.php new file mode 100644 index 0000000..1d75c06 --- /dev/null +++ b/database/migrations/2025_03_04_050210_link_url_unique.php @@ -0,0 +1,28 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('links', function (Blueprint $table) { + if (Schema::hasColumn('links', 'url')){ + $table->unique('url'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $table->dropUnique('url'); + } +}; diff --git a/database/migrations/99999_pivots.php b/database/migrations/99999_pivots.php index 94f155d..5cae45a 100644 --- a/database/migrations/99999_pivots.php +++ b/database/migrations/99999_pivots.php @@ -36,12 +36,12 @@ return new class extends Migration $table->integer('tag_id')->unsigned()->index(); $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade'); }); - /* TODO Schema::create('role_user', function (Blueprint $table) { + Schema::create('role_user', function (Blueprint $table) { $table->integer('user_id')->unsigned()->index(); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); $table->integer('role_id')->unsigned()->index(); $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); - });*/ + }); } /** diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index f129660..93969ed 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -12,6 +12,23 @@ class RoleSeeder extends Seeder */ public function run(): void { - // + $roles = [ + [ + 'name' => 'admin', + 'description' => 'Administrator', + 'max_filesize' => 1000000000, + 'storage_quota' => 1000000000 + ], + [ + 'name' => 'user', + 'description' => 'User', + 'max_filesize' => 10000000, + 'storage_quota' => 10000000 + ] + ]; + + foreach ($roles as $role) { + \App\Models\Role::create($role); + } } } diff --git a/resources/css/jstoys.css b/resources/css/jstoys.css new file mode 100644 index 0000000..add19e2 --- /dev/null +++ b/resources/css/jstoys.css @@ -0,0 +1,85 @@ +body { + margin: 0; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + font-family: Arial, sans-serif; + color: #FFF; +} + +canvas { + border: 1px solid #333; + margin-bottom: 20px; + background: #000; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 20px; + max-width: 800px; +} + +.control-group { + display: flex; + flex-direction: column; + align-items: center; +} + +label { + margin-bottom: 5px; +} + +button { + padding: 8px 16px; + background: #8B0000; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + margin: 0 5px; +} + +button:hover { + background: #A00000; +} + +#bloodCanvas { + cursor: crosshair; +} + +.action-buttons { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.status { + background: rgba(50, 50, 50, 0.8); + padding: 10px 20px; + border-radius: 4px; + margin-bottom: 20px; +} + +.status span { + font-weight: bold; +} + +.status.success { + color: #90ee90; +} + +.status.error { + color: #ff6666; +} + +.performance { + background: rgba(30, 30, 30, 0.8); + padding: 8px 16px; + border-radius: 4px; + margin-top: 10px; + font-family: monospace; +}
\ No newline at end of file diff --git a/resources/css/normalize.css b/resources/css/normalize.css new file mode 100755 index 0000000..81c6f31 --- /dev/null +++ b/resources/css/normalize.css @@ -0,0 +1,427 @@ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS text size adjust after orientation change, without disabling + * user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined for any HTML5 element in IE 8/9. + * Correct `block` display not defined for `details` or `summary` in IE 10/11 + * and Firefox. + * Correct `block` display not defined for `main` in IE 11. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} + +/** + * 1. Correct `inline-block` display not defined in IE 8/9. + * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. + */ + +audio, +canvas, +progress, +video { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address `[hidden]` styling not present in IE 8/9/10. + * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. + */ + +[hidden], +template { + display: none; +} + +/* Links + ========================================================================== */ + +/** + * Remove the gray background color from active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * Improve readability when focused and also mouse hovered in all browsers. + */ + +a:active, +a:hover { + outline: 0; +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Address styling not present in IE 8/9/10/11, Safari, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9/10. + */ + +img { + border: 0; +} + +/** + * Correct overflow not hidden in IE 9/10/11. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Grouping content + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari. + */ + +figure { + margin: 1em 40px; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +/** + * Contain overflow in all browsers. + */ + +pre { + overflow: auto; +} + +/** + * Address odd `em`-unit font size rendering in all browsers. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +/* Forms + ========================================================================== */ + +/** + * Known limitation: by default, Chrome and Safari on OS X allow very limited + * styling of `select`, unless a `border` property is set. + */ + +/** + * 1. Correct color not being inherited. + * Known issue: affects color of disabled elements. + * 2. Correct font properties not being inherited. + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + */ + +button, +input, +optgroup, +select, +textarea { + color: inherit; /* 1 */ + font: inherit; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address `overflow` set to `hidden` in IE 8/9/10/11. + */ + +button { + overflow: visible; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. + * Correct `select` style inheritance in Firefox. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +input { + line-height: normal; +} + +/** + * It's recommended that you don't attempt to style these elements. + * Firefox's implementation doesn't respect box-sizing, padding, or width. + * + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Fix the cursor style for Chrome's increment/decrement buttons. For certain + * `font-size` values of the `input`, it causes the cursor style of the + * decrement button to change from `default` to `text`. + */ + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome + * (include `-moz` to future-proof). + */ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; /* 2 */ + box-sizing: content-box; +} + +/** + * Remove inner padding and search cancel button in Safari and Chrome on OS X. + * Safari (but not Chrome) clips the cancel button when the search input has + * padding (and `textfield` appearance). + */ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9/10/11. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Remove default vertical scrollbar in IE 8/9/10/11. + */ + +textarea { + overflow: auto; +} + +/** + * Don't inherit the `font-weight` (applied by a rule above). + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + */ + +optgroup { + font-weight: bold; +} + +/* Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} + +td, +th { + padding: 0; +}
\ No newline at end of file diff --git a/resources/css/skeleton.css b/resources/css/skeleton.css new file mode 100755 index 0000000..c3fda1c --- /dev/null +++ b/resources/css/skeleton.css @@ -0,0 +1,419 @@ +/* +* Skeleton V2.0.4 +* Copyright 2014, Dave Gamache +* www.getskeleton.com +* Free to use under the MIT license. +* http://www.opensource.org/licenses/mit-license.php +* 12/29/2014 +*/ + + +/* Table of contents +–––––––––––––––––––––––––––––––––––––––––––––––––– +- Grid +- Base Styles +- Typography +- Links +- Buttons +- Forms +- Lists +- Code +- Tables +- Spacing +- Utilities +- Clearing +- Media Queries +*/ + + +/* Grid +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +.container { + position: relative; + width: 100%; + max-width: 960px; + margin: 0 auto; + padding: 0 20px; + box-sizing: border-box; } +.column, +.columns { + width: 100%; + float: left; + box-sizing: border-box; } + +/* For devices larger than 400px */ +@media (min-width: 400px) { + .container { + width: 85%; + padding: 0; } +} + +/* For devices larger than 550px */ +@media (min-width: 550px) { + .container { + width: 80%; } + .column, + .columns { + margin-left: 4%; } + .column:first-child, + .columns:first-child { + margin-left: 0; } + + .one.column, + .col1, + .one.columns { width: 4.66666666667%; } + .two.columns { width: 13.3333333333%; } + .three.columns { width: 22%; } + .four.columns { width: 30.6666666667%; } + .five.columns { width: 39.3333333333%; } + .six.columns { width: 48%; } + .seven.columns { width: 56.6666666667%; } + .eight.columns { width: 65.3333333333%; } + .nine.columns { width: 74.0%; } + .ten.columns { width: 82.6666666667%; } + .eleven.columns { width: 91.3333333333%; } + .twelve.columns { width: 100%; margin-left: 0; } + + .one-third.column { width: 30.6666666667%; } + .two-thirds.column { width: 65.3333333333%; } + + .one-half.column { width: 48%; } + + /* Offsets */ + .offset-by-one.column, + .offset-by-one.columns { margin-left: 8.66666666667%; } + .offset-by-two.column, + .offset-by-two.columns { margin-left: 17.3333333333%; } + .offset-by-three.column, + .offset-by-three.columns { margin-left: 26%; } + .offset-by-four.column, + .offset-by-four.columns { margin-left: 34.6666666667%; } + .offset-by-five.column, + .offset-by-five.columns { margin-left: 43.3333333333%; } + .offset-by-six.column, + .offset-by-six.columns { margin-left: 52%; } + .offset-by-seven.column, + .offset-by-seven.columns { margin-left: 60.6666666667%; } + .offset-by-eight.column, + .offset-by-eight.columns { margin-left: 69.3333333333%; } + .offset-by-nine.column, + .offset-by-nine.columns { margin-left: 78.0%; } + .offset-by-ten.column, + .offset-by-ten.columns { margin-left: 86.6666666667%; } + .offset-by-eleven.column, + .offset-by-eleven.columns { margin-left: 95.3333333333%; } + + .offset-by-one-third.column, + .offset-by-one-third.columns { margin-left: 34.6666666667%; } + .offset-by-two-thirds.column, + .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } + + .offset-by-one-half.column, + .offset-by-one-half.columns { margin-left: 52%; } + +} + + +/* Base Styles +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +/* NOTE +html is set to 62.5% so that all the REM measurements throughout Skeleton +are based on 10px sizing. So basically 1.5rem = 15px :) */ +html { + font-size: 62.5%; } +body { + font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ + line-height: 1.6; + font-weight: 400; + font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; + color: #222; } + + +/* Typography +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 2rem; + font-weight: 300; } +h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} +h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } +h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } +h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } +h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } +h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } + +/* Larger than phablet */ +@media (min-width: 550px) { + h1 { font-size: 5.0rem; } + h2 { font-size: 4.2rem; } + h3 { font-size: 3.6rem; } + h4 { font-size: 3.0rem; } + h5 { font-size: 2.4rem; } + h6 { font-size: 1.5rem; } +} + +p { + margin-top: 0; } + + +/* Links +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +a { + color: #1EAEDB; } +a:hover { + color: #0FA0CE; } + + +/* Buttons +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +.button, +button, +input[type="submit"], +input[type="reset"], +input[type="button"] { + display: inline-block; + height: 38px; + padding: 0 30px; + color: #555; + text-align: center; + font-size: 11px; + font-weight: 600; + line-height: 38px; + letter-spacing: .1rem; + text-transform: uppercase; + text-decoration: none; + white-space: nowrap; + background-color: transparent; + border-radius: 4px; + border: 1px solid #bbb; + cursor: pointer; + box-sizing: border-box; } +.button:hover, +button:hover, +input[type="submit"]:hover, +input[type="reset"]:hover, +input[type="button"]:hover, +.button:focus, +button:focus, +input[type="submit"]:focus, +input[type="reset"]:focus, +input[type="button"]:focus { + color: #333; + border-color: #888; + outline: 0; } +.button.button-primary, +button.button-primary, +input[type="submit"].button-primary, +input[type="reset"].button-primary, +input[type="button"].button-primary { + color: #FFF; + background-color: #33C3F0; + border-color: #33C3F0; } +.button.button-primary:hover, +button.button-primary:hover, +input[type="submit"].button-primary:hover, +input[type="reset"].button-primary:hover, +input[type="button"].button-primary:hover, +.button.button-primary:focus, +button.button-primary:focus, +input[type="submit"].button-primary:focus, +input[type="reset"].button-primary:focus, +input[type="button"].button-primary:focus { + color: #FFF; + background-color: #1EAEDB; + border-color: #1EAEDB; } + + +/* Forms +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +input[type="email"], +input[type="number"], +input[type="search"], +input[type="text"], +input[type="tel"], +input[type="url"], +input[type="password"], +textarea, +select { + height: 38px; + padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ + background-color: #fff; + border: 1px solid #D1D1D1; + border-radius: 4px; + box-shadow: none; + box-sizing: border-box; } +/* Removes awkward default styles on some inputs for iOS */ +input[type="email"], +input[type="number"], +input[type="search"], +input[type="text"], +input[type="tel"], +input[type="url"], +input[type="password"], +textarea { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } +textarea { + min-height: 65px; + padding-top: 6px; + padding-bottom: 6px; } +input[type="email"]:focus, +input[type="number"]:focus, +input[type="search"]:focus, +input[type="text"]:focus, +input[type="tel"]:focus, +input[type="url"]:focus, +input[type="password"]:focus, +textarea:focus, +select:focus { + border: 1px solid #33C3F0; + outline: 0; } +label, +legend { + display: block; + margin-bottom: .5rem; + font-weight: 600; } +fieldset { + padding: 0; + border-width: 0; } +input[type="checkbox"], +input[type="radio"] { + display: inline; } +label > .label-body { + display: inline-block; + margin-left: .5rem; + font-weight: normal; } + + +/* Lists +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +ul { + list-style: circle inside; } +ol { + list-style: decimal inside; } +ol, ul { + padding-left: 0; + margin-top: 0; } +ul ul, +ul ol, +ol ol, +ol ul { + margin: 1.5rem 0 1.5rem 3rem; + font-size: 90%; } +li { + margin-bottom: 1rem; } + + +/* Code +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +code { + padding: .2rem .5rem; + margin: 0 .2rem; + font-size: 90%; + white-space: nowrap; + background: #F1F1F1; + border: 1px solid #E1E1E1; + border-radius: 4px; } +pre > code { + display: block; + padding: 1rem 1.5rem; + white-space: pre; } + + +/* Tables +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +th, +td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid #E1E1E1; } +th:first-child, +td:first-child { + padding-left: 0; } +th:last-child, +td:last-child { + padding-right: 0; } + + +/* Spacing +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +button, +.button { + margin-bottom: 1rem; } +input, +textarea, +select, +fieldset { + margin-bottom: 1.5rem; } +pre, +blockquote, +dl, +figure, +table, +p, +ul, +ol, +form { + margin-bottom: 2.5rem; } + + +/* Utilities +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +.u-full-width { + width: 100%; + box-sizing: border-box; } +.u-max-full-width { + max-width: 100%; + box-sizing: border-box; } +.u-pull-right { + float: right; } +.u-pull-left { + float: left; } + + +/* Misc +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +hr { + margin-top: 3rem; + margin-bottom: 3.5rem; + border-width: 0; + border-top: 1px solid #E1E1E1; } + + +/* Clearing +–––––––––––––––––––––––––––––––––––––––––––––––––– */ + +/* Self Clearing Goodness */ +.container:after, +.row:after, +.u-cf { + content: ""; + display: table; + clear: both; } + + +/* Media Queries +–––––––––––––––––––––––––––––––––––––––––––––––––– */ +/* +Note: The best way to structure the use of media queries is to create the queries +near the relevant code. For example, if you wanted to change the styles for buttons +on small devices, paste the mobile query code up in the buttons section and style it +there. +*/ + + +/* Larger than mobile */ +@media (min-width: 400px) {} + +/* Larger than phablet (also point when grid becomes active) */ +@media (min-width: 550px) {} + +/* Larger than tablet */ +@media (min-width: 750px) {} + +/* Larger than desktop */ +@media (min-width: 1000px) {} + +/* Larger than Desktop HD */ +@media (min-width: 1200px) {} diff --git a/resources/css/style.css b/resources/css/style.css index b22c7a6..cdd0d3f 100644 --- a/resources/css/style.css +++ b/resources/css/style.css @@ -120,6 +120,12 @@ ul { font-size: 0.9em; } +form { + display: inline-flex; + flex-direction: column; + gap: 1rem; +} + /* Form Containers */ .form-container { background-color: #ffffff; diff --git a/resources/js/blood.js b/resources/js/blood.js new file mode 100644 index 0000000..c45663a --- /dev/null +++ b/resources/js/blood.js @@ -0,0 +1,593 @@ +class BloodEffect { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.width = canvas.width; + this.height = canvas.height; + + // Blood parameters + this.gravity = 0.15; + this.viscosity = 0.92; + this.particleCount = 100; + this.particleSize = 3; + this.sprayForce = 10; + this.splatterSize = 5; + this.spread = 0.3; + this.colorVariation = 15; + this.enableDrips = true; + this.enablePooling = true; + + // Animation state + this.paused = false; + this.animationId = null; + + // Particles and splatters + this.particles = []; + this.splatters = []; + this.pools = []; + this.drips = []; + + // Mouse interactions + this.mouseDown = false; + this.mouseX = 0; + this.mouseY = 0; + this.lastMouseX = 0; + this.lastMouseY = 0; + + // Initialize event listeners + this.initEvents(); + } + + initEvents() { + this.canvas.addEventListener('mousedown', (e) => { + this.mouseDown = true; + this.updateMousePosition(e); + this.lastMouseX = this.mouseX; + this.lastMouseY = this.mouseY; + this.createSpray(this.mouseX, this.mouseY, 0, 0); + }); + + this.canvas.addEventListener('mousemove', (e) => { + if (this.mouseDown) { + const lastX = this.mouseX; + const lastY = this.mouseY; + this.updateMousePosition(e); + + // Calculate velocity for direction + const velX = this.mouseX - lastX; + const velY = this.mouseY - lastY; + + // Create spray based on movement + if (Math.abs(velX) > 0.5 || Math.abs(velY) > 0.5) { + this.createSpray(this.mouseX, this.mouseY, velX, velY); + } + } + }); + + this.canvas.addEventListener('mouseup', () => { + this.mouseDown = false; + }); + + this.canvas.addEventListener('mouseleave', () => { + this.mouseDown = false; + }); + } + + updateMousePosition(e) { + const rect = this.canvas.getBoundingClientRect(); + this.mouseX = e.clientX - rect.left; + this.mouseY = e.clientY - rect.top; + } + + createSpray(x, y, velX, velY) { + // Direction influence from velocity + const dirX = velX === 0 ? 0 : Math.sign(velX); + const dirY = velY === 0 ? 0 : Math.sign(velY); + const speed = Math.sqrt(velX * velX + velY * velY); + + for (let i = 0; i < this.particleCount; i++) { + // Calculate random spray direction with influence from movement + const angle = Math.random() * Math.PI * 2; + const force = this.sprayForce * (0.5 + Math.random() * 0.5); + + // Base velocities with randomness + let vx = Math.cos(angle) * force * this.spread; + let vy = Math.sin(angle) * force * this.spread; + + // Add influence from mouse movement + if (speed > 1) { + vx += dirX * force * (1 - this.spread) * Math.random(); + vy += dirY * force * (1 - this.spread) * Math.random(); + } + + // Random size variation + const size = this.particleSize * (0.5 + Math.random()); + + // Random color variation (darker/lighter red) + const r = 120 + Math.floor(Math.random() * this.colorVariation); + const g = 0 + Math.floor(Math.random() * (this.colorVariation * 0.4)); + const b = 0 + Math.floor(Math.random() * (this.colorVariation * 0.2)); + const color = `rgb(${r}, ${g}, ${b})`; + + // Add blood particle + this.particles.push({ + x: x + (Math.random() - 0.5) * 5, + y: y + (Math.random() - 0.5) * 5, + vx: vx, + vy: vy, + size: size, + color: color, + gravity: this.gravity * (0.8 + Math.random() * 0.4), + life: 1.0, // Life percentage (1.0 = full life, 0.0 = dead) + decay: 0.01 + Math.random() * 0.02 + }); + } + } + + createRandomSpray() { + const x = Math.random() * this.width; + const y = Math.random() * (this.height / 2); + const velX = (Math.random() - 0.5) * 20; + const velY = Math.random() * 10; + this.createSpray(x, y, velX, velY); + } + + createBloodBurst() { + const x = this.width / 2 + (Math.random() - 0.5) * 200; + const y = this.height / 2 + (Math.random() - 0.5) * 100; + + // Create a more intense spray for burst + const oldParticleCount = this.particleCount; + const oldSprayForce = this.sprayForce; + + this.particleCount = this.particleCount * 3; + this.sprayForce = this.sprayForce * 1.5; + + this.createSpray(x, y, 0, 0); + + // Reset to original values + this.particleCount = oldParticleCount; + this.sprayForce = oldSprayForce; + } + + createSplatter(x, y, size, color) { + // Create a blood splatter at impact location + const splatterSize = size * this.splatterSize; + + this.splatters.push({ + x: x, + y: y, + size: splatterSize, + color: color, + // Random shapes for splatters + shape: Math.floor(Math.random() * 3), + angle: Math.random() * Math.PI * 2, + // Stretch factor for directional impact + stretchX: 0.5 + Math.random(), + stretchY: 0.5 + Math.random(), + // How much it has dried/darkened (0 = fresh, 1 = dried) + dried: 0 + }); + + // Possibly create a drip + if (this.enableDrips && Math.random() < 0.3) { + this.createDrip(x, y, color); + } + + // Possibly create a pool below the splatter + if (this.enablePooling && y > this.height * 0.7 && Math.random() < 0.5) { + this.createPool(x, y, splatterSize * 1.5, color); + } + } + + createDrip(x, y, color) { + const length = 10 + Math.random() * 30; + const width = 2 + Math.random() * 4; + + this.drips.push({ + x: x, + y: y, + targetY: y + length, + width: width, + color: color, + progress: 0, + speed: 0.005 + Math.random() * 0.01, // How fast it drips down + dried: 0 + }); + } + + createPool(x, y, size, color) { + this.pools.push({ + x: x, + y: y, + currentSize: 0, + targetSize: size * (1 + Math.random()), + growSpeed: 0.1 + Math.random() * 0.2, + color: color, + dried: 0 + }); + } + + updatePhysics() { + // Update particles + for (let i = this.particles.length - 1; i >= 0; i--) { + const particle = this.particles[i]; + + // Apply physics + particle.vy += particle.gravity; + particle.vx *= this.viscosity; + particle.vy *= this.viscosity; + + // Update position + particle.x += particle.vx; + particle.y += particle.vy; + + // Check for collisions with bottom or sides + if (particle.y > this.height - particle.size) { + // Bottom collision - create splatter + this.createSplatter(particle.x, this.height, particle.size, particle.color); + this.particles.splice(i, 1); + } else if (particle.x < 0 || particle.x > this.width) { + // Side collision - create splatter + const x = particle.x < 0 ? 0 : this.width; + this.createSplatter(x, particle.y, particle.size, particle.color); + this.particles.splice(i, 1); + } else { + // Decay life + particle.life -= particle.decay; + if (particle.life <= 0) { + this.particles.splice(i, 1); + } + } + } + + // Update drips + for (let i = this.drips.length - 1; i >= 0; i--) { + const drip = this.drips[i]; + + // Grow drip down + drip.progress += drip.speed; + if (drip.progress >= 1) { + // When drip reaches target, it stops + drip.progress = 1; + // Slowly dry + drip.dried += 0.001; + + // Remove very dried drips + if (drip.dried > 0.7) { + this.drips.splice(i, 1); + } + } + } + + // Update pools + for (let i = 0; i < this.pools.length; i++) { + const pool = this.pools[i]; + + // Grow pool + if (pool.currentSize < pool.targetSize) { + pool.currentSize += pool.growSpeed; + } else { + // Slowly dry + pool.dried += 0.0005; + } + } + + // Update splatters + for (let i = 0; i < this.splatters.length; i++) { + // Slowly dry + this.splatters[i].dried += 0.0002; + } + } + + render() { + // Draw particles + for (const particle of this.particles) { + this.ctx.beginPath(); + this.ctx.fillStyle = particle.color; + this.ctx.globalAlpha = particle.life; + this.ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + this.ctx.fill(); + } + + // Draw splatters + for (const splatter of this.splatters) { + // Darker color as it dries + const color = this.adjustColorForDrying(splatter.color, splatter.dried); + this.ctx.fillStyle = color; + this.ctx.globalAlpha = 1; + + this.ctx.save(); + this.ctx.translate(splatter.x, splatter.y); + this.ctx.rotate(splatter.angle); + this.ctx.scale(splatter.stretchX, splatter.stretchY); + + // Different splatter shapes + switch (splatter.shape) { + case 0: + // Circular splatter + this.ctx.beginPath(); + this.ctx.arc(0, 0, splatter.size, 0, Math.PI * 2); + this.ctx.fill(); + break; + case 1: + // Star-like splatter + this.ctx.beginPath(); + const points = 5 + Math.floor(Math.random() * 4); + for (let i = 0; i < points * 2; i++) { + const radius = i % 2 === 0 ? splatter.size : splatter.size * 0.5; + const angle = (i * Math.PI) / points; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + if (i === 0) this.ctx.moveTo(x, y); + else this.ctx.lineTo(x, y); + } + this.ctx.closePath(); + this.ctx.fill(); + break; + case 2: + // Irregular blob + this.ctx.beginPath(); + const segments = 8 + Math.floor(Math.random() * 5); + for (let i = 0; i <= segments; i++) { + const angle = (i / segments) * Math.PI * 2; + const radius = splatter.size * (0.7 + Math.random() * 0.6); + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + if (i === 0) this.ctx.moveTo(x, y); + else this.ctx.bezierCurveTo( + x - Math.random() * 10, y - Math.random() * 10, + x + Math.random() * 10, y + Math.random() * 10, + x, y + ); + } + this.ctx.closePath(); + this.ctx.fill(); + break; + } + + this.ctx.restore(); + } + + // Draw drips + for (const drip of this.drips) { + const color = this.adjustColorForDrying(drip.color, drip.dried); + this.ctx.fillStyle = color; + this.ctx.globalAlpha = 1; + + // Calculate current height based on progress + const currentY = drip.y + (drip.targetY - drip.y) * drip.progress; + + // Draw drip as elongated teardrop + this.ctx.beginPath(); + this.ctx.moveTo(drip.x - drip.width/2, drip.y); + this.ctx.bezierCurveTo( + drip.x - drip.width, drip.y + (currentY - drip.y) * 0.3, + drip.x - drip.width/3, currentY - drip.width, + drip.x, currentY + ); + this.ctx.bezierCurveTo( + drip.x + drip.width/3, currentY - drip.width, + drip.x + drip.width, drip.y + (currentY - drip.y) * 0.3, + drip.x + drip.width/2, drip.y + ); + this.ctx.closePath(); + this.ctx.fill(); + } + + // Draw pools + for (const pool of this.pools) { + const color = this.adjustColorForDrying(pool.color, pool.dried); + this.ctx.fillStyle = color; + this.ctx.globalAlpha = 1; + + // Draw pool as irregular ellipse + this.ctx.beginPath(); + const segments = 12; + for (let i = 0; i <= segments; i++) { + const angle = (i / segments) * Math.PI * 2; + // Make pool wider than tall + const radiusX = pool.currentSize * (1.2 + Math.sin(angle * 3) * 0.2); + const radiusY = pool.currentSize * (0.6 + Math.cos(angle * 2) * 0.1); + const x = pool.x + Math.cos(angle) * radiusX; + const y = pool.y + Math.sin(angle) * radiusY; + if (i === 0) this.ctx.moveTo(x, y); + else this.ctx.bezierCurveTo( + x - Math.random() * 5, y - Math.random() * 2, + x + Math.random() * 5, y + Math.random() * 2, + x, y + ); + } + this.ctx.closePath(); + this.ctx.fill(); + } + + // Reset alpha + this.ctx.globalAlpha = 1; + } + + adjustColorForDrying(color, driedAmount) { + // Extract RGB components + const rgbMatch = color.match(/\d+/g); + if (!rgbMatch || rgbMatch.length < 3) return color; + + const r = parseInt(rgbMatch[0]); + const g = parseInt(rgbMatch[1]); + const b = parseInt(rgbMatch[2]); + + // Calculate dried color (darker, more brown) + const driedR = Math.max(0, Math.floor(r - (driedAmount * 80))); + const driedG = Math.max(0, Math.floor(g - (driedAmount * 10))); + const driedB = Math.max(0, Math.floor(b - (driedAmount * 10))); + + return `rgb(${driedR}, ${driedG}, ${driedB})`; + } + + animate() { + if (this.paused) return; + + // Update physics + this.updatePhysics(); + + // Render + this.render(); + + // Schedule next frame + this.animationId = requestAnimationFrame(() => this.animate()); + } + + start() { + this.paused = false; + if (!this.animationId) { + this.animate(); + } + } + + pause() { + this.paused = true; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + togglePause() { + if (this.paused) { + this.start(); + } else { + this.pause(); + } + } + + clear() { + this.ctx.clearRect(0, 0, this.width, this.height); + this.particles = []; + this.splatters = []; + this.pools = []; + this.drips = []; + } + + // Parameter setters + setGravity(value) { + this.gravity = value; + } + + setViscosity(value) { + this.viscosity = value; + } + + setParticleCount(value) { + this.particleCount = value; + } + + setParticleSize(value) { + this.particleSize = value; + } + + setSprayForce(value) { + this.sprayForce = value; + } + + setSplatterSize(value) { + this.splatterSize = value; + } + + setSpread(value) { + this.spread = value; + } + + setEnableDrips(value) { + this.enableDrips = value; + } + + setEnablePooling(value) { + this.enablePooling = value; + } + + setColorVariation(value) { + this.colorVariation = value; + } + } + + // Initialize the blood effect + const canvas = document.getElementById('sprayCanvas'); + const bloodEffect = new BloodEffect(canvas); + + // Start the animation + bloodEffect.start(); + + // Set up UI controls + const gravitySlider = document.getElementById('gravitySlider'); + const viscositySlider = document.getElementById('viscositySlider'); + const particleCountSlider = document.getElementById('particleCountSlider'); + const particleSizeSlider = document.getElementById('particleSizeSlider'); + const sprayForceSlider = document.getElementById('sprayForceSlider'); + const splatterSizeSlider = document.getElementById('splatterSizeSlider'); + const spreadSlider = document.getElementById('spreadSlider'); + const dripToggle = document.getElementById('dripToggle'); + const poolToggle = document.getElementById('poolToggle'); + const colorVariationSlider = document.getElementById('colorVariationSlider'); + + const clearBtn = document.getElementById('clearBtn'); + const randomSprayBtn = document.getElementById('randomSprayBtn'); + const burstBtn = document.getElementById('burstBtn'); + const pauseBtn = document.getElementById('pauseBtn'); + + // Event listeners for sliders + gravitySlider.addEventListener('input', function() { + bloodEffect.setGravity(parseFloat(this.value)); + }); + + viscositySlider.addEventListener('input', function() { + bloodEffect.setViscosity(parseFloat(this.value)); + }); + + particleCountSlider.addEventListener('input', function() { + bloodEffect.setParticleCount(parseInt(this.value)); + }); + + particleSizeSlider.addEventListener('input', function() { + bloodEffect.setParticleSize(parseInt(this.value)); + }); + + sprayForceSlider.addEventListener('input', function() { + bloodEffect.setSprayForce(parseInt(this.value)); + }); + + splatterSizeSlider.addEventListener('input', function() { + bloodEffect.setSplatterSize(parseInt(this.value)); + }); + + spreadSlider.addEventListener('input', function() { + bloodEffect.setSpread(parseFloat(this.value)); + }); + + dripToggle.addEventListener('change', function() { + bloodEffect.setEnableDrips(this.checked); + }); + + poolToggle.addEventListener('change', function() { + bloodEffect.setEnablePooling(this.checked); + }); + + colorVariationSlider.addEventListener('input', function() { + bloodEffect.setColorVariation(parseInt(this.value)); + }); + + // Button event listeners + clearBtn.addEventListener('click', function() { + bloodEffect.clear(); + }); + + randomSprayBtn.addEventListener('click', function() { + bloodEffect.createRandomSpray(); + }); + + burstBtn.addEventListener('click', function() { + bloodEffect.createBloodBurst(); + }); + + pauseBtn.addEventListener('click', function() { + bloodEffect.togglePause(); + this.textContent = bloodEffect.paused ? 'Resume' : 'Pause'; + });
\ No newline at end of file diff --git a/resources/js/blood_gpu.js b/resources/js/blood_gpu.js new file mode 100644 index 0000000..19d88c4 --- /dev/null +++ b/resources/js/blood_gpu.js @@ -0,0 +1,656 @@ +// This code demonstrates how to implement the blood simulation using WebGPU +// Note: WebGPU is still evolving and requires modern browsers with WebGPU enabled + +export class WebGPUBloodSimulation { + constructor(canvas) { + this.canvas = canvas; + this.particles = []; + this.splatters = []; + + // Simulation parameters + this.particleCount = 10000; // Much higher with GPU acceleration + this.gravity = 0.15; + this.viscosity = 0.92; + this.particleSize = 3; + this.sprayForce = 10; + this.splatterSize = 5; + this.spread = 0.3; + + // WebGPU state + this.device = null; + this.context = null; + this.particleBuffer = null; + this.computePipeline = null; + this.renderPipeline = null; + this.uniformBuffer = null; + this.bindGroups = []; + + // Double buffering for particles (read from one, write to the other) + this.particleBuffers = [null, null]; + this.currentBuffer = 0; + + this.animationId = null; + this.paused = false; + + // Initialize WebGPU + this.initWebGPU(); + } + + async initWebGPU() { + try { + this.context = this.canvas.getContext('webgpu'); + // Check if WebGPU is supported + if (!navigator.gpu) { + console.log(navigator); + throw new Error("WebGPU not supported on this browser."); + } + + // Request adapter + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + throw new Error("No appropriate GPU adapter found."); + } + + // Request device + this.device = await adapter.requestDevice(); + console.log(this.device); + + // Configure canvas context + const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); + this.context.configure({ + device: this.device, + format: canvasFormat, + alphaMode: 'premultiplied' + }); + + // Initialize particle buffer + await this.initializeParticles(); + + // Create shader modules + await this.createShaders(); + + // Create uniform buffer (simulation parameters) + this.createUniformBuffer(); + + // Start rendering + this.startAnimation(); + + console.log("WebGPU Blood Simulation initialized successfully"); + } catch (error) { + console.error("Failed to initialize WebGPU:", error); + // Fallback to Canvas2D implementation if WebGPU isn't available + } + } + + async initializeParticles() { + // Create structured particle buffer + const particleBufferSize = this.particleCount * 8 * Float32Array.BYTES_PER_ELEMENT; // position(2), velocity(2), color(3), life(1) + + // Create double buffered particles for ping-pong computation + this.particleBuffers[0] = this.device.createBuffer({ + size: particleBufferSize, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + + this.particleBuffers[1] = this.device.createBuffer({ + size: particleBufferSize, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + + // Initialize particles on CPU then upload to GPU + const particleData = new Float32Array(this.particleCount * 8); + for (let i = 0; i < this.particleCount; i++) { + const baseIndex = i * 8; + // Position (off-screen initially) + particleData[baseIndex] = -100; + particleData[baseIndex + 1] = -100; + // Velocity + particleData[baseIndex + 2] = 0; + particleData[baseIndex + 3] = 0; + // Color (red with slight variation) + particleData[baseIndex + 4] = 0.8 + Math.random() * 0.2; // r + particleData[baseIndex + 5] = 0.0 + Math.random() * 0.1; // g + particleData[baseIndex + 6] = 0.0 + Math.random() * 0.05; // b + // Life + particleData[baseIndex + 7] = 0.0; // Initially inactive + } + + // Upload initial particle data + this.device.queue.writeBuffer(this.particleBuffers[0], 0, particleData); + this.device.queue.writeBuffer(this.particleBuffers[1], 0, particleData); + + // Create vertex buffer for particle rendering + this.vertexBuffer = this.device.createBuffer({ + size: 6 * 2 * Float32Array.BYTES_PER_ELEMENT, // 6 vertices for quad, 2 floats per vertex + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + mappedAtCreation: true, + }); + + // Create a quad (two triangles) for each particle + const vertexData = new Float32Array(this.vertexBuffer.getMappedRange()); + // Triangle 1 + vertexData[0] = -1; vertexData[1] = -1; // bottom-left + vertexData[2] = 1; vertexData[3] = -1; // bottom-right + vertexData[4] = -1; vertexData[5] = 1; // top-left + // Triangle 2 + vertexData[6] = -1; vertexData[7] = 1; // top-left + vertexData[8] = 1; vertexData[9] = -1; // bottom-right + vertexData[10] = 1; vertexData[11] = 1; // top-right + + this.vertexBuffer.unmap(); + } + + async createShaders() { + // Create compute shader for particle physics + const computeShaderModule = this.device.createShaderModule({ + label: "Blood Particle Compute Shader", + code: ` + struct Particle { + position: vec2f, + velocity: vec2f, + color: vec3f, + life: f32, + }; + + struct SimParams { + deltaTime: f32, + gravity: f32, + viscosity: f32, + canvasWidth: f32, + canvasHeight: f32, + mouseX: f32, + mouseY: f32, + mouseDown: f32, + forceMultiplier: f32, + particleCount: f32, + }; + + @group(0) @binding(0) var<storage, read> particlesA: array<Particle>; + @group(0) @binding(1) var<storage, read_write> particlesB: array<Particle>; + @group(0) @binding(2) var<uniform> params: SimParams; + + // Random number generation on GPU + fn rand(seed: f32) -> f32 { + return fract(sin(seed * 78.233) * 43758.5453); + } + + @compute @workgroup_size(64) + fn computeMain(@builtin(global_invocation_id) global_id: vec3u) { + let index = global_id.x; + if (index >= u32(params.particleCount)) { + return; + } + + var particle = particlesA[index]; + + // Skip inactive particles + if (particle.life <= 0.0) { + // Check if we should spawn a new particle at mouse position + if (params.mouseDown > 0.5) { + let spawnChance = rand(f32(index) + params.deltaTime); + if (spawnChance < 0.05) { // Control spawn rate + // Create new particle at mouse position + particle.position = vec2f(params.mouseX, params.mouseY); + + // Random direction with bias towards mouse movement + let angle = rand(f32(index) * params.deltaTime) * 6.283185; + let force = params.forceMultiplier * (0.5 + rand(f32(index) + 0.1) * 0.5); + + // Set random velocity + particle.velocity.x = cos(angle) * force; + particle.velocity.y = sin(angle) * force; + + // Set color (red with variation) + particle.color.x = 0.8 + rand(f32(index) + 0.2) * 0.2; + particle.color.y = rand(f32(index) + 0.3) * 0.1; + particle.color.z = rand(f32(index) + 0.4) * 0.05; + + // Set life + particle.life = 1.0; + } + } + } else { + // Apply physics to active particles + + // Apply gravity + particle.velocity.y += params.gravity; + + // Apply viscosity (air resistance) + particle.velocity *= params.viscosity; + + // Update position + particle.position += particle.velocity; + + // Check boundaries + if (particle.position.y > params.canvasHeight) { + // Hit bottom - create splatter (handled on CPU for now) + particle.life = -1.0; // Special value to signal splatter creation on CPU + } else if (particle.position.x < 0.0 || particle.position.x > params.canvasWidth) { + // Hit sides + particle.life = -1.0; + } else { + // Decay life + particle.life -= 0.005; + } + } + + // Write updated particle + particlesB[index] = particle; + } + ` + }); + + // Create vertex/fragment shader for rendering + const renderShaderModule = this.device.createShaderModule({ + label: "Blood Particle Render Shader", + code: ` + struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) color: vec3f, + @location(1) quadPosition: vec2f, + }; + + struct Particle { + position: vec2f, + velocity: vec2f, + color: vec3f, + life: f32, + }; + + struct SimParams { + deltaTime: f32, + gravity: f32, + viscosity: f32, + canvasWidth: f32, + canvasHeight: f32, + mouseX: f32, + mouseY: f32, + mouseDown: f32, + forceMultiplier: f32, + particleCount: f32, + }; + + @group(0) @binding(0) var<storage, read> particles: array<Particle>; + @group(0) @binding(2) var<uniform> params: SimParams; + + @vertex + fn vertexMain( + @location(0) position: vec2f, + @builtin(instance_index) instance: u32 + ) -> VertexOutput { + var output: VertexOutput; + + let particle = particles[instance]; + + // Only process active particles + if (particle.life <= 0.0) { + // Place inactive particles offscreen + output.position = vec4f(-10.0, -10.0, 0.0, 1.0); + output.color = vec3f(0.0); + output.quadPosition = vec2f(0.0); + return output; + } + + // Particle size based on life + let size = 3.0 * particle.life; + + // Transform quad to particle position and size + let worldPosition = particle.position + position * size; + + // Convert to clip space + let clipPosition = vec2f( + worldPosition.x / params.canvasWidth * 2.0 - 1.0, + -(worldPosition.y / params.canvasHeight * 2.0 - 1.0) // Y is flipped in clip space + ); + + output.position = vec4f(clipPosition, 0.0, 1.0); + output.color = particle.color; + output.quadPosition = position; + + return output; + } + + @fragment + fn fragmentMain(in: VertexOutput) -> @location(0) vec4f { + // Calculate distance from center of quad for circular particles + let distFromCenter = length(in.quadPosition); + + // Smooth circle with soft edges + let alpha = smoothstep(1.0, 0.8, distFromCenter) * in.color.r; + + return vec4f(in.color, alpha); + } + ` + }); + + // Create compute pipeline + this.computePipeline = this.device.createComputePipeline({ + label: "Blood Particle Compute Pipeline", + layout: 'auto', + compute: { + module: computeShaderModule, + entryPoint: 'computeMain', + } + }); + + // Create render pipeline + this.renderPipeline = this.device.createRenderPipeline({ + label: "Blood Particle Render Pipeline", + layout: 'auto', + vertex: { + module: renderShaderModule, + entryPoint: 'vertexMain', + buffers: [ + { + arrayStride: 2 * Float32Array.BYTES_PER_ELEMENT, + attributes: [ + { + shaderLocation: 0, + offset: 0, + format: 'float32x2', + }, + ], + }, + ], + }, + /*fragment: { + module: renderShaderModule, + entryPoint: 'fragmentMain', + targets: [ + { + format: navigator.gpu.getPreferredCanvasFormat(), + blend: { + color: { + srcFactor: 'src-alpha', + dstFactor: 'one', + operation: 'add', + }, + alpha: { + srcFactor: 'src-alpha', + dstFactor: 'one', + operation: 'add', + }, + }, + }, + ], + },*/ + }); + } + + createUniformBuffer() { + this.uniformBuffer = this.device.createBuffer({ + size: 10 * Float32Array.BYTES_PER_ELEMENT, // 10 parameters + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + // Initial values + this.updateUniformBuffer(); + + // Create bind groups for double buffering + for (let i = 0; i < 2; i++) { + this.bindGroups[i] = this.device.createBindGroup({ + layout: this.computePipeline.getBindGroupLayout(0), + entries: [ + { + binding: 0, + resource: { buffer: this.particleBuffers[i] } + }, + { + binding: 1, + resource: { buffer: this.particleBuffers[(i + 1) % 2] } + }, + { + binding: 2, + resource: { buffer: this.uniformBuffer } + }, + ], + }); + } + + // Create render bind group + this.renderBindGroup = this.device.createBindGroup({ + layout: this.renderPipeline.getBindGroupLayout(0), + entries: [ + { + binding: 0, + resource: { buffer: this.particleBuffers[0] } + }, + { + binding: 2, + resource: { buffer: this.uniformBuffer } + }, + ], + }); + } + + updateUniformBuffer(mouseX = 0, mouseY = 0, mouseDown = 0) { + // Create typed array for uniform data + const uniformData = new Float32Array([ + 0.016, // deltaTime (assuming 60fps) + this.gravity, + this.viscosity, + this.canvas.width, + this.canvas.height, + mouseX, + mouseY, + mouseDown, + this.sprayForce, + this.particleCount, + ]); + + // Upload to GPU + this.device.queue.writeBuffer(this.uniformBuffer, 0, uniformData); + } + + simulate(mouseX = 0, mouseY = 0, mouseDown = 0) { + if (this.paused) return; + + // Update parameters + this.updateUniformBuffer(mouseX, mouseY, mouseDown); + + // Begin command encoding + const commandEncoder = this.device.createCommandEncoder(); + + // Run compute shader + const computePass = commandEncoder.beginComputePass(); + computePass.setPipeline(this.computePipeline); + computePass.setBindGroup(0, this.bindGroups[this.currentBuffer]); + computePass.dispatchWorkgroups(Math.ceil(this.particleCount / 64)); + computePass.end(); + + // Swap buffers + this.currentBuffer = (this.currentBuffer + 1) % 2; + + // Update render bind group to use current particle buffer + this.renderBindGroup = this.device.createBindGroup({ + layout: this.renderPipeline.getBindGroupLayout(0), + entries: [ + { + binding: 0, + resource: { buffer: this.particleBuffers[this.currentBuffer] } + }, + { + binding: 2, + resource: { buffer: this.uniformBuffer } + }, + ], + }); + + // Render particles + const renderPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: this.context.getCurrentTexture().createView(), + clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + + renderPass.setPipeline(this.renderPipeline); + renderPass.setBindGroup(0, this.renderBindGroup); + renderPass.setVertexBuffer(0, this.vertexBuffer); + renderPass.draw(6, this.particleCount, 0, 0); // 6 vertices per particle (2 triangles) + renderPass.end(); + + // Submit GPU commands + this.device.queue.submit([commandEncoder.finish()]); + } + + startAnimation() { + if (this.paused) return; + + let lastMouseX = 0; + let lastMouseY = 0; + let mouseDown = 0; + + // Handle mouse events + this.canvas.addEventListener('mousedown', (e) => { + mouseDown = 1; + lastMouseX = e.offsetX; + lastMouseY = e.offsetY; + }); + + this.canvas.addEventListener('mousemove', (e) => { + lastMouseX = e.offsetX; + lastMouseY = e.offsetY; + }); + + this.canvas.addEventListener('mouseup', () => { + mouseDown = 0; + }); + + this.canvas.addEventListener('mouseleave', () => { + mouseDown = 0; + }); + + // Animation loop + const animate = () => { + this.simulate(lastMouseX, lastMouseY, mouseDown); + this.animationId = requestAnimationFrame(animate); + }; + + animate(); + } + + togglePause() { + this.paused = !this.paused; + if (!this.paused) { + this.startAnimation(); + } + } + + createBloodBurst(x, y) { + // Create a burst from a specific location + // We'll need to handle this via transferring data to the GPU + // This method would modify particle data on the CPU then upload to GPU + + const particleData = new Float32Array(this.particleCount * 8); + let activeCount = 0; + const maxBurstParticles = Math.min(this.particleCount, 2000); + + // First read existing particle data + const readBuffer = this.device.createBuffer({ + size: this.particleCount * 8 * Float32Array.BYTES_PER_ELEMENT, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyBufferToBuffer( + this.particleBuffers[this.currentBuffer], 0, + readBuffer, 0, + this.particleCount * 8 * Float32Array.BYTES_PER_ELEMENT + ); + this.device.queue.submit([commandEncoder.finish()]); + + // Wait for the buffer to be mapped + readBuffer.mapAsync(GPUMapMode.READ).then(() => { + const mappedArray = new Float32Array(readBuffer.getMappedRange()); + + // Copy existing particles + for (let i = 0; i < this.particleCount; i++) { + const baseIndex = i * 8; + // If particle is active, preserve it + if (mappedArray[baseIndex + 7] > 0) { + for (let j = 0; j < 8; j++) { + particleData[baseIndex + j] = mappedArray[baseIndex + j]; + } + activeCount++; + } + } + + // Add burst particles + for (let i = activeCount; i < activeCount + maxBurstParticles && i < this.particleCount; i++) { + const baseIndex = i * 8; + // Position + particleData[baseIndex] = x; + particleData[baseIndex + 1] = y; + + // Velocity - random in all directions + const angle = Math.random() * Math.PI * 2; + const force = 5 + Math.random() * 15; + particleData[baseIndex + 2] = Math.cos(angle) * force; + particleData[baseIndex + 3] = Math.sin(angle) * force; + + // Color - red with variation + particleData[baseIndex + 4] = 0.8 + Math.random() * 0.2; // r + particleData[baseIndex + 5] = Math.random() * 0.1; // g + particleData[baseIndex + 6] = Math.random() * 0.05; // b + + // Life + particleData[baseIndex + 7] = 1.0; + } + + readBuffer.unmap(); + + // Write the updated particle data to the GPU + this.device.queue.writeBuffer(this.particleBuffers[this.currentBuffer], 0, particleData); + }); + } + + clear() { + // Reset all particles to inactive + const particleData = new Float32Array(this.particleCount * 8); + for (let i = 0; i < this.particleCount; i++) { + const baseIndex = i * 8; + // Position (off-screen) + particleData[baseIndex] = -100; + particleData[baseIndex + 1] = -100; + // Velocity + particleData[baseIndex + 2] = 0; + particleData[baseIndex + 3] = 0; + // Color + particleData[baseIndex + 4] = 0.8 + Math.random() * 0.2; + particleData[baseIndex + 5] = 0; + particleData[baseIndex + 6] = 0; + // Life (inactive) + particleData[baseIndex + 7] = 0; + } + + // Upload to both buffers + this.device.queue.writeBuffer(this.particleBuffers[0], 0, particleData); + this.device.queue.writeBuffer(this.particleBuffers[1], 0, particleData); + } + + // Methods to adjust simulation parameters + setGravity(value) { + this.gravity = value; + } + + setViscosity(value) { + this.viscosity = value; + } + + setParticleCount(value) { + // WebGPU allows for much higher particle counts + this.particleCount = Math.min(value, 100000); + } + + setSprayForce(value) { + this.sprayForce = value; + } +} + +//make the class accessible to the browser +window.WebGPUBloodSimulation = WebGPUBloodSimulation;
\ No newline at end of file diff --git a/resources/js/home.js b/resources/js/home.js new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/resources/js/home.js diff --git a/resources/js/writing_index.js b/resources/js/writing_index.js new file mode 100644 index 0000000..b8a2ed1 --- /dev/null +++ b/resources/js/writing_index.js @@ -0,0 +1,59 @@ + +let dom = {}; + +$(function() { + initDOM(); + + + marked.setOptions({ + breaks: true, + sanitize: true + }) + + dom.inputText.oninput = ()=>{ + dom.contentPreview.innerHTML = marked.parse(dom.inputText.value); + }; + + let restoredContent = localStorage.getItem('writing_draft_content'); + if (restoredContent != null){ + if (localStorage.getItem('writing_draft_expiration') < Date.now()){ + localStorage.removeItem('writing_draft_content'); + localStorage.removeItem('writing_draft_expiration'); + } else { + dom.inputText.value = restoredContent; + dom.contentPreview.innerHTML = marked.parse(dom.inputText.value); + } + } + + //periodically save the contents to client to prevent data loss + const intervalId = setInterval(() => { + const content = dom.inputText.value; + localStorage.setItem('writing_draft_content', content); + localStorage.setItem('writing_draft_expiration', Date.now() + 1000*3600*96); + }, 5000); + +}); + +function initDOM(){ + dom.filterMethod = $('#filter_method')[0]; + dom.sortMethod = $('#sort_method')[0]; + +} + +function performReplace(){ + + + // Save current state to history for undo + editorHistory.push(dom.inputText.value); + + // Perform the replacement + const regex = new RegExp(dom.findStr.value, dom.toggleMultiline.checked ? 'gm' : 'g'); + dom.inputText.value = dom.inputText.value.replace(regex, dom.replaceStr.value); + dom.contentPreview.innerHTML = marked.parse(dom.inputText.value); + + + editorHistory.push(dom.inputText.value); + + dom.inputText.value = dom.inputText.value.replace(regex, dom.replaceStr.value); + dom.contentPreview.innerHTML = marked.parse(dom.inputText.value); +};
\ No newline at end of file diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index cf36537..77de9ed 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,6 +1,378 @@ @extends('template') + +@section('head') +<style> + .dashboard-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 30px; + } + + .dashboard-card { + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + padding: 20px; + transition: transform 0.2s; + } + + .dashboard-card:hover { + transform: translateY(-5px); + } + + .dashboard-card h2 { + margin-top: 0; + border-bottom: 1px solid #eee; + padding-bottom: 10px; + font-size: 1.4rem; + } + + .dashboard-card-footer { + margin-top: 15px; + text-align: right; + } + + .dashboard-stats { + display: flex; + justify-content: space-between; + margin-bottom: 15px; + } + + .stat-item { + text-align: center; + padding: 10px; + background-color: #f8f9fa; + border-radius: 5px; + flex-grow: 1; + margin: 0 5px; + } + + .stat-number { + font-size: 1.5rem; + font-weight: bold; + color: #4a69bd; + } + + .stat-label { + font-size: 0.85rem; + color: #666; + } + + .progress-container { + margin: 15px 0; + } + + .progress-bar { + height: 10px; + background-color: #e9ecef; + border-radius: 5px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background-color: #4a69bd; + transition: width 0.3s ease; + } + + .item-list { + list-style-type: none; + padding: 0; + margin: 0; + } + + .item-list li { + padding: 8px 0; + border-bottom: 1px solid #f1f1f1; + } + + .item-list li:last-child { + border-bottom: none; + } + + .badge { + display: inline-block; + padding: 3px 8px; + font-size: 0.8rem; + font-weight: 500; + border-radius: 12px; + } + + .badge-primary { + background-color: #e1eaff; + color: #4a69bd; + } + + .badge-success { + background-color: #e3fcef; + color: #00a86b; + } + + .badge-warning { + background-color: #fff8e1; + color: #ffa000; + } + + .empty-state { + text-align: center; + padding: 20px; + color: #999; + font-style: italic; + } + + .avatar-container { + display: flex; + align-items: center; + } + + .avatar { + width: 50px; + height: 50px; + border-radius: 50%; + background-color: #4a69bd; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + margin-right: 15px; + } + + .profile-info { + flex-grow: 1; + } + + .tag-list { + margin-top: 10px; + } + + .tag { + display: inline-block; + font-size: 12px; + padding: 2px 8px; + margin-right: 5px; + margin-bottom: 5px; + background-color: #f1f1f1; + border-radius: 12px; + } +</style> +@endsection + @section('body') <main> - <p>this is just a dashboard which currently serves no purpose</p> + <h1>Dashboard</h1> + + @php + $user = Auth::user(); + + // Storage stats + $usedStorage = $user->getStorageUsed(); + $totalStorage = $user->storage_quota; + $storagePercent = $totalStorage > 0 ? min(100, round(($usedStorage / $totalStorage) * 100)) : 0; + + // File stats + $fileCount = $user->files()->count(); + $recentFiles = $user->files()->orderBy('created_at', 'desc')->take(3)->get(); + + // Writing stats + $writingCount = $user->writings()->count(); + $recentWritings = $user->writings()->orderBy('created_at', 'desc')->take(3)->get(); + + // Quest stats (if implemented) + $questCount = method_exists($user, 'quests') ? $user->quests()->count() : 0; + $activeQuests = method_exists($user, 'quests') ? $user->quests() + ->wherePivot('status', 'in_progress') + ->orderBy('created_at', 'desc') + ->take(3) + ->get() : collect(); + + // Inventory stats (placeholder) + $inventoryCount = 0; // Update when inventory is implemented + @endphp + + <!-- Profile Overview --> + <div class="dashboard-card" style="margin-bottom: 20px;"> + <div class="avatar-container"> + <div class="avatar">{{ strtoupper(substr($user->name, 0, 1)) }}</div> + <div class="profile-info"> + <h2>{{ $user->name }}</h2> + <p>{{ $user->email }}</p> + <p> + <span class="badge badge-primary">{{ $user->role == 0 ? 'Admin' : 'User' }}</span> + <span class="badge badge-success">Member since {{ $user->created_at->format('M Y') }}</span> + </p> + </div> + </div> + </div> + + <!-- Activity Stats --> + <div class="dashboard-stats"> + <div class="stat-item"> + <div class="stat-number">{{ $fileCount }}</div> + <div class="stat-label">Files</div> + </div> + <div class="stat-item"> + <div class="stat-number">{{ $writingCount }}</div> + <div class="stat-label">Writings</div> + </div> + <div class="stat-item"> + <div class="stat-number">{{ $questCount }}</div> + <div class="stat-label">Quests</div> + </div> + <div class="stat-item"> + <div class="stat-number">{{ $inventoryCount }}</div> + <div class="stat-label">Items</div> + </div> + </div> + + <div class="dashboard-container"> + <!-- Storage Card --> + <div class="dashboard-card"> + <h2>Storage</h2> + + <div class="progress-container"> + <div style="display: flex; justify-content: space-between; margin-bottom: 5px;"> + <span>{{ round($usedStorage / 1024, 2) }} MB used</span> + <span>{{ round($totalStorage / 1024, 2) }} MB total</span> + </div> + <div class="progress-bar"> + <div class="progress-fill" style="width: {{ $storagePercent }}%"></div> + </div> + </div> + + <h3>Recent Files</h3> + @if($recentFiles->count() > 0) + <ul class="item-list"> + @foreach($recentFiles as $file) + <li> + <div style="display: flex; justify-content: space-between;"> + <div> + <a href="/f/{{ $file->filename }}">{{ Str::limit($file->filename, 25) }}</a> + <div style="font-size: 0.8rem; color: #666;"> + {{ round($file->size / 1024 / 1024, 2) }} MB • {{ $file->created_at->diffForHumans() }} + </div> + </div> + <div> + <a href="?file={{ $file->path }}" title="Download">⬇️</a> + </div> + </div> + </li> + @endforeach + </ul> + @else + <div class="empty-state">No files uploaded yet</div> + @endif + + <div class="dashboard-card-footer"> + <a href="/f">Manage Files →</a> + </div> + </div> + + <!-- Writings Card --> + <div class="dashboard-card"> + <h2>Writings</h2> + + @if($recentWritings->count() > 0) + <ul class="item-list"> + @foreach($recentWritings as $writing) + <li> + <div> + <a href="{{ route('w.show', $writing->id) }}">{{ Str::limit($writing->title, 35) }}</a> + <div style="font-size: 0.8rem; color: #666;"> + {{ Str::limit(strip_tags(Str::markdown($writing->content)), 60) }} + </div> + <div style="font-size: 0.8rem; color: #666;"> + {{ $writing->created_at->diffForHumans() }} + </div> + </div> + </li> + @endforeach + </ul> + @else + <div class="empty-state">No writings created yet</div> + @endif + + <div class="dashboard-card-footer"> + <a href="{{ route('w.create') }}">New Writing</a> • + <a href="{{ route('w.index') }}">All Writings →</a> + </div> + </div> + + <!-- Quests Card --> + <div class="dashboard-card"> + <h2>Quests</h2> + + @if(method_exists($user, 'quests') && $activeQuests->count() > 0) + <ul class="item-list"> + @foreach($activeQuests as $quest) + <li> + <div> + <a href="{{ route('quests.show', $quest->id) }}">{{ Str::limit($quest->title, 35) }}</a> + <div style="font-size: 0.8rem; color: #666;"> + Progress: {{ $quest->pivot->progress }}% + </div> + <div class="progress-bar" style="margin: 5px 0; height: 6px;"> + <div class="progress-fill" style="width: {{ $quest->pivot->progress }}%; background-color: #00a86b;"></div> + </div> + <div style="font-size: 0.8rem; color: #666;"> + <span class="badge badge-{{ $quest->difficulty == 'easy' ? 'success' : ($quest->difficulty == 'hard' ? 'warning' : 'primary') }}"> + {{ ucfirst($quest->difficulty) }} + </span> + {{ $quest->reward_points }} points + </div> + </div> + </li> + @endforeach + </ul> + @else + <div class="empty-state">No active quests</div> + @endif + + <div class="dashboard-card-footer"> + @if(method_exists($user, 'quests')) + <a href="{{ route('quests.index') }}">View Quests →</a> + @else + <span style="color: #999;">Coming soon</span> + @endif + </div> + </div> + + <!-- Inventory Card (Placeholder) --> + <div class="dashboard-card"> + <h2>Inventory</h2> + + <div class="empty-state">Inventory system coming soon</div> + + <div class="dashboard-card-footer"> + <span style="color: #999;">Under development</span> + </div> + </div> + </div> + + <!-- Tags Used --> + <div class="dashboard-card"> + <h2>Your Tags</h2> + + @php + // Get all tags from user's content + $fileTags = $user->files()->with('tags')->get()->pluck('tags')->flatten(); + $writingTags = method_exists($user->writings(), 'with') ? + $user->writings()->with('tags')->get()->pluck('tags')->flatten() : + collect(); + + $allTags = $fileTags->merge($writingTags)->unique('id'); + @endphp + + @if($allTags->count() > 0) + <div class="tag-list"> + @foreach($allTags as $tag) + <span class="tag">{{ $tag->label }}</span> + @endforeach + </div> + @else + <div class="empty-state">No tags used yet</div> + @endif + </div> </main> @endsection
\ No newline at end of file diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index d71e5b4..5531a05 100755 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -4,6 +4,7 @@ </canvas> <main> <header> + <h1>A friendly webserver</h1> <h2>in Iowa</h2> <p> @@ -14,7 +15,7 @@ <br> <p>Who are you?</p><input type = "text" id = "input_who" /> </div> - + <br> </header> <section id = "services"> <h4>Services</h4> @@ -86,5 +87,18 @@ <button type = "submit">Import Data from JSON</button> </form> @endif + + </main> +<?php + $files = Storage::disk('public')->files('homeimages'); + if (count($files) > 0){ + $homeImgSrc = $files[array_rand($files)]; + } +?> +<section id="home_img_container" style="position: absolute; top: 20%; right: 10%; width: 200px; height: auto;" ondrop="drop(event)" ondragover="allowDrop(event)"> + <img src="{{ $homeImgSrc }}" id = "img_home" style="max-width: 100%; height: auto;"> +</section> + + @endsection
\ No newline at end of file diff --git a/resources/views/import.blade.php b/resources/views/import.blade.php new file mode 100644 index 0000000..e60a8f0 --- /dev/null +++ b/resources/views/import.blade.php @@ -0,0 +1,24 @@ +@extends('template') +@section('body') +<main> + <header> + <h3>Data Importer</h3> + <h4>add some data items to the database from a json file</h4> + </header> + + <section> + <form action = "/import" method = "post" enctype = "multipart/form-data"> + @csrf + <label = for = "modelType">Type of model being imported: </label> + <select name = "modelType" id = "modelType"> + @foreach (App\Http\Controllers\SiteController::$MODELS as $model) + <option value = "{{ $model }}">{{ $model }}</option> + @endforeach + </select> + <label for = "jsonFile">Select a json file to import</label> + <input type = "file" name = "jsonFile" id = "jsonFile"> + <button type = "submit">Import</button> + </form> + </section> +</main> +@endsection
\ No newline at end of file diff --git a/resources/views/links/index.blade.php b/resources/views/links/index.blade.php index 314759d..a18c2d8 100644 --- a/resources/views/links/index.blade.php +++ b/resources/views/links/index.blade.php @@ -10,7 +10,7 @@ <div class="grid"> @foreach ($links as $link) <article class=""> - <a href="{{ route('l.show', $link->id) }}" class=""> + <a href="{{ $link->url }}" class=""> <h2 class=""> {{ $link->label }} </h2> diff --git a/resources/views/toys/blood-gpu.blade.php b/resources/views/toys/blood-gpu.blade.php new file mode 100644 index 0000000..e5c898e --- /dev/null +++ b/resources/views/toys/blood-gpu.blade.php @@ -0,0 +1,384 @@ +@extends('template') +@section('head') + +<title>WebGPU Blood Spray Effect</title> +@vite(['resources/css/jstoys.css', 'resources/js/blood_gpu.js']) +@endsection +@section('body') + <h1>WebGPU Blood Spray Simulation</h1> + + <div id="statusBox" class="status"> + Initializing WebGPU... + </div> + + <div class="action-buttons"> + <button id="clearBtn">Clear Canvas</button> + <button id="randomSprayBtn">Random Spray</button> + <button id="burstBtn">Blood Burst</button> + <button id="pauseBtn">Pause</button> + </div> + + <canvas id="bloodCanvas" width="800" height="500"></canvas> + + <div class="controls"> + <div class="control-group"> + <label for="gravitySlider">Gravity</label> + <input type="range" id="gravitySlider" min="0.01" max="0.5" value="0.15" step="0.01"> + </div> + + <div class="control-group"> + <label for="viscositySlider">Viscosity</label> + <input type="range" id="viscositySlider" min="0.75" max="0.99" value="0.92" step="0.01"> + </div> + + <div class="control-group"> + <label for="particleCountSlider">Particle Count</label> + <input type="range" id="particleCountSlider" min="10000" max="100000" value="50000"> + <span id="particleCountValue">50,000</span> + </div> + + <div class="control-group"> + <label for="sprayForceSlider">Spray Force</label> + <input type="range" id="sprayForceSlider" min="5" max="30" value="15"> + </div> + </div> + + <div class="performance"> + <div>Active Particles: <span id="activeParticleCount">0</span></div> + <div>FPS: <span id="fpsCounter">0</span></div> + </div> + + <p>Click and drag on the canvas to create blood spray. Using WebGPU for GPU-accelerated physics!</p> + + <script type = "module"> + // Simple FPS counter + let frameCount = 0; + let lastTime = performance.now(); + let fps = 0; + + function updateFPS() { + const now = performance.now(); + const elapsed = now - lastTime; + + if (elapsed >= 1000) { + fps = Math.round((frameCount * 1000) / elapsed); + document.getElementById('fpsCounter').textContent = fps; + frameCount = 0; + lastTime = now; + } + + frameCount++; + requestAnimationFrame(updateFPS); + } + + // Start FPS counter + updateFPS(); + + // Fallback Canvas2D implementation for browsers without WebGPU + class CanvasBloodEffect { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.width = canvas.width; + this.height = canvas.height; + + // Blood parameters + this.gravity = 0.15; + this.viscosity = 0.92; + this.particleCount = 5000; + this.particleSize = 3; + this.sprayForce = 15; + + // Animation state + this.paused = false; + this.animationId = null; + + // Particles + this.particles = []; + + // Mouse interactions + this.mouseDown = false; + this.mouseX = 0; + this.mouseY = 0; + this.lastMouseX = 0; + this.lastMouseY = 0; + + // Initialize event listeners + this.initEvents(); + } + + initEvents() { + this.canvas.addEventListener('mousedown', (e) => { + this.mouseDown = true; + this.updateMousePosition(e); + this.lastMouseX = this.mouseX; + this.lastMouseY = this.mouseY; + this.createSpray(this.mouseX, this.mouseY, 0, 0); + }); + + this.canvas.addEventListener('mousemove', (e) => { + if (this.mouseDown) { + const lastX = this.mouseX; + const lastY = this.mouseY; + this.updateMousePosition(e); + + // Calculate velocity for direction + const velX = this.mouseX - lastX; + const velY = this.mouseY - lastY; + + // Create spray based on movement + if (Math.abs(velX) > 0.5 || Math.abs(velY) > 0.5) { + this.createSpray(this.mouseX, this.mouseY, velX, velY); + } + } + }); + + this.canvas.addEventListener('mouseup', () => { + this.mouseDown = false; + }); + + this.canvas.addEventListener('mouseleave', () => { + this.mouseDown = false; + }); + } + + updateMousePosition(e) { + const rect = this.canvas.getBoundingClientRect(); + this.mouseX = e.clientX - rect.left; + this.mouseY = e.clientY - rect.top; + } + + createSpray(x, y, velX, velY) { + // Direction influence from velocity + const dirX = velX === 0 ? 0 : Math.sign(velX); + const dirY = velY === 0 ? 0 : Math.sign(velY); + const speed = Math.sqrt(velX * velX + velY * velY); + + const sprayCount = Math.min(500, this.particleCount); + + for (let i = 0; i < sprayCount; i++) { + // Calculate random spray direction with influence from movement + const angle = Math.random() * Math.PI * 2; + const force = this.sprayForce * (0.5 + Math.random() * 0.5); + + // Base velocities with randomness + let vx = Math.cos(angle) * force * 0.3; + let vy = Math.sin(angle) * force * 0.3; + + // Add influence from mouse movement + if (speed > 1) { + vx += dirX * force * 0.7 * Math.random(); + vy += dirY * force * 0.7 * Math.random(); + } + + // Random size variation + const size = this.particleSize * (0.5 + Math.random()); + + // Random color variation (darker/lighter red) + const r = 120 + Math.floor(Math.random() * 80); + const g = Math.floor(Math.random() * 30); + const b = Math.floor(Math.random() * 20); + const color = `rgb(${r}, ${g}, ${b})`; + + // Add blood particle + this.particles.push({ + x: x + (Math.random() - 0.5) * 5, + y: y + (Math.random() - 0.5) * 5, + vx: vx, + vy: vy, + size: size, + color: color, + gravity: this.gravity * (0.8 + Math.random() * 0.4), + life: 1.0 // Life percentage (1.0 = full life, 0.0 = dead) + }); + } + + // Limit total particles + if (this.particles.length > this.particleCount) { + this.particles.splice(0, this.particles.length - this.particleCount); + } + + // Update particle counter + document.getElementById('activeParticleCount').textContent = this.particles.length.toLocaleString(); + } + + createRandomSpray() { + const x = Math.random() * this.width; + const y = Math.random() * (this.height / 2); + const velX = (Math.random() - 0.5) * 20; + const velY = Math.random() * 10; + this.createSpray(x, y, velX, velY); + } + + createBloodBurst() { + const x = this.width / 2 + (Math.random() - 0.5) * 200; + const y = this.height / 2 + (Math.random() - 0.5) * 100; + + // Create multiple sprays for a burst effect + for (let i = 0; i < 5; i++) { + const offsetX = (Math.random() - 0.5) * 20; + const offsetY = (Math.random() - 0.5) * 20; + this.createSpray(x + offsetX, y + offsetY, 0, 0); + } + } + + updatePhysics() { + // Clear canvas + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; + this.ctx.fillRect(0, 0, this.width, this.height); + + // Update particles + for (let i = this.particles.length - 1; i >= 0; i--) { + const particle = this.particles[i]; + + // Apply physics + particle.vy += particle.gravity; + particle.vx *= this.viscosity; + particle.vy *= this.viscosity; + + // Update position + particle.x += particle.vx; + particle.y += particle.vy; + + // Check for collisions with bottom or sides + if (particle.y > this.height || particle.x < 0 || particle.x > this.width) { + this.particles.splice(i, 1); + continue; + } + + // Draw particle + this.ctx.beginPath(); + this.ctx.fillStyle = particle.color; + this.ctx.globalAlpha = particle.life; + this.ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + this.ctx.fill(); + + // Decay life + particle.life -= 0.005; + if (particle.life <= 0) { + this.particles.splice(i, 1); + } + } + + // Reset alpha + this.ctx.globalAlpha = 1; + + // Update particle counter + document.getElementById('activeParticleCount').textContent = this.particles.length.toLocaleString(); + } + + animate() { + if (this.paused) return; + + // Update physics and render + this.updatePhysics(); + + // Schedule next frame + this.animationId = requestAnimationFrame(() => this.animate()); + } + + start() { + this.paused = false; + if (!this.animationId) { + this.animate(); + } + } + + pause() { + this.paused = true; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + togglePause() { + if (this.paused) { + this.start(); + } else { + this.pause(); + } + } + + clear() { + this.ctx.clearRect(0, 0, this.width, this.height); + this.particles = []; + document.getElementById('activeParticleCount').textContent = "0"; + } + + // Parameter setters + setGravity(value) { + this.gravity = value; + } + + setViscosity(value) { + this.viscosity = value; + } + + setParticleCount(value) { + this.particleCount = value; + } + + setSprayForce(value) { + this.sprayForce = value; + } + } + + document.addEventListener('DOMContentLoaded', async () => { + + const canvas = document.getElementById('bloodCanvas'); + console.log(canvas); + + try { + const bloodSim = new WebGPUBloodSimulation(canvas); + + // Connect UI controls + document.getElementById('gravitySlider').addEventListener('input', function() { + bloodSim.setGravity(parseFloat(this.value)); + }); + + document.getElementById('viscositySlider').addEventListener('input', function() { + bloodSim.setViscosity(parseFloat(this.value)); + }); + + document.getElementById('particleCountSlider').addEventListener('input', function() { + bloodSim.setParticleCount(parseInt(this.value)); + }); + + document.getElementById('sprayForceSlider').addEventListener('input', function() { + bloodSim.setSprayForce(parseInt(this.value)); + }); + + document.getElementById('clearBtn').addEventListener('click', function() { + bloodSim.clear(); + }); + + document.getElementById('burstBtn').addEventListener('click', function() { + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + bloodSim.createBloodBurst( + canvasWidth/2 + (Math.random() - 0.5) * 200, + canvasHeight/2 + (Math.random() - 0.5) * 100 + ); + }); + + document.getElementById('pauseBtn').addEventListener('click', function() { + bloodSim.togglePause(); + this.textContent = bloodSim.paused ? 'Resume' : 'Pause'; + }); + + } catch (error) { + console.error("WebGPU initialization failed:", error); + console.log("Falling back to Canvas2D implementation"); + + // Fallback to Canvas2D implementation (original blood effect) + const fallbackBlood = new CanvasBloodEffect(canvas); + fallbackBlood.start(); + + // Connect UI controls to fallback + // (code omitted for brevity, similar to the original implementation) + } + }); + + </script> +@endsection
\ No newline at end of file diff --git a/resources/views/toys/blood.blade.php b/resources/views/toys/blood.blade.php new file mode 100644 index 0000000..4953033 --- /dev/null +++ b/resources/views/toys/blood.blade.php @@ -0,0 +1,69 @@ +@extends('template') +@section('head') +<h1>Blood Splatters</h1> +@vite(['resources/css/style.css', 'resources/css/jstoys.css', 'resources/js/blood.js']) +@endsection +@section('body') +<div class="action-buttons"> + <button id="clearBtn">Clear Canvas</button> + <button id="randomSprayBtn">Random Spray</button> + <button id="burstBtn">Blood Burst</button> + <button id="pauseBtn">Pause</button> +</div> + +<canvas id="sprayCanvas" width="1200" height="800"></canvas> + +<div class="controls"> +<div class="control-group"> + <label for="gravitySlider">Gravity</label> + <input type="range" id="gravitySlider" min="0.01" max="0.5" value="0.15" step="0.01"> +</div> + +<div class="control-group"> + <label for="viscositySlider">Viscosity</label> + <input type="range" id="viscositySlider" min="0.75" max="0.99" value="0.92" step="0.01"> +</div> + +<div class="control-group"> + <label for="particleCountSlider">Particle Count</label> + <input type="range" id="particleCountSlider" min="10" max="300" value="100"> +</div> + +<div class="control-group"> + <label for="particleSizeSlider">Particle Size</label> + <input type="range" id="particleSizeSlider" min="1" max="8" value="3"> +</div> + +<div class="control-group"> + <label for="sprayForceSlider">Spray Force</label> + <input type="range" id="sprayForceSlider" min="1" max="20" value="10"> +</div> + +<div class="control-group"> + <label for="splatterSizeSlider">Splatter Size</label> + <input type="range" id="splatterSizeSlider" min="1" max="10" value="5"> +</div> + +<div class="control-group"> + <label for="spreadSlider">Spread</label> + <input type="range" id="spreadSlider" min="0" max="1" value="0.3" step="0.05"> +</div> + +<div class="control-group"> + <label for="dripToggle">Enable Drips</label> + <input type="checkbox" id="dripToggle" checked> +</div> + +<div class="control-group"> + <label for="poolToggle">Enable Pooling</label> + <input type="checkbox" id="poolToggle" checked> +</div> + +<div class="control-group"> + <label for="colorVariationSlider">Color Variation</label> + <input type="range" id="colorVariationSlider" min="0" max="50" value="15"> +</div> +</div> + +<p>Click and drag on the canvas to create blood spray.</p> +@endsection
\ No newline at end of file diff --git a/resources/views/toys/fire.blade.php b/resources/views/toys/fire.blade.php new file mode 100644 index 0000000..c82eebc --- /dev/null +++ b/resources/views/toys/fire.blade.php @@ -0,0 +1,504 @@ +@extends('template') +@section('head') +<h1>Animated Fire Effect</h1> +@vite(['resources/css/style.css', 'resources/css/jstoys.css']) +@endsection +@section('body') + <canvas id="fireCanvas" width="1000" height="800"></canvas> + + <div class="controls"> + <div class="control-group"> + <label for="intensitySlider">Intensity</label> + <input type="range" id="intensitySlider" min="1" max="150" value="10"> + </div> + + <div class="control-group"> + <label for="coolingSlider">Cooling</label> + <input type="range" id="coolingSlider" min="1" max="10" value="4"> + </div> + + <div class="control-group"> + <label for="speedSlider">Speed</label> + <input type="range" id="speedSlider" min=".1" max="25" value="5"> + </div> + + <div class="control-group"> + <label for="windSlider">Wind Direction</label> + <input type="range" id="windSlider" min="-10" max="10" value="0" step="0.5"> + <span id="windValue">0</span> + </div> + + <div class="control-group"> + <label for="windStrengthSlider">Wind Strength</label> + <input type="range" id="windStrengthSlider" min="0" max="10" value="3"> + </div> + + <div class="control-group"> + <label for="windVariabilityToggle">Variable Wind</label> + <input type="checkbox" id="windVariabilityToggle" checked> + </div> + + <div class="control-group"> + <label for="windVariabilitySlider">Wind Variability</label> + <input type="range" id="windVariabilitySlider" min="1" max="10" value="5"> + </div> + + <div class="control-group"> + <label for="scatterSlider">Flame Scatter</label> + <input type="range" id="scatterSlider" min="1" max="10" value="5"> + </div> + + <div class="control-group"> + <label for="sourceWidthSlider">Fire Width</label> + <input type="range" id="sourceWidthSlider" min="10" max="100" value="100"> + <span id="sourceWidthValue">100%</span> + </div> + + <div class="control-group"> + <label for="emberToggle">Embers</label> + <input type="checkbox" id="emberToggle" checked> + </div> + + <div class="control-group"> + <label for="emberRateSlider">Ember Rate</label> + <input type="range" id="emberRateSlider" min="1" max="20" value="5"> + </div> + + <div class="control-group"> + <label for="emberSizeSlider">Ember Size</label> + <input type="range" id="emberSizeSlider" min="1" max="5" value="2"> + </div> + </div> + + <div> + <button id="pauseBtn">Pause</button> + <button id="resetBtn">Reset</button> + </div> + + <script> + // Fire simulation class + class FireEffect { + constructor(canvas, width, height) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.width = width; + this.height = height; + + // Create image data buffer + this.imageData = this.ctx.createImageData(this.width, this.height); + + // Fire parameters - can be adjusted + this.intensity = 10; + this.cooling = 4; + this.speedFactor = 5; + this.windDirection = 0; // Negative = left, Positive = right + this.windStrength = 3; // How much the wind affects the fire + this.sourceWidth = 100; // Width of fire source as percentage of canvas width + this.flameScatter = 5; // How much flames scatter as they rise (1-10) + + // Wind variability + this.windVariabilityEnabled = true; + this.windVariability = 5; + this.windTime = 0; + this.currentVariableWind = 0; + this.targetVariableWind = 0; + this.windTransitionSpeed = 0.02; + + // Ember parameters + this.embersEnabled = true; + this.emberRate = 5; // How many embers to spawn per frame + this.emberSize = 2; // Size of embers + this.embers = []; // Array to store active ember particles + + // Animation state + this.paused = false; + this.animationId = null; + + // Fire buffer - stores "heat" values + this.fireBuffer = new Array(this.width * this.height).fill(0); + + // Color palette for mapping heat values to colors + this.palette = this.createFirePalette(); + + // Initial setup + this.initFire(); + } + + // Creates a color palette with gradients from black to yellow to red + createFirePalette() { + const palette = []; + // Black to dark red + for (let i = 0; i < 32; i++) { + palette.push({ r: i * 2, g: 0, b: 0 }); + } + // Dark red to bright red + for (let i = 0; i < 32; i++) { + palette.push({ r: 64 + i * 3, g: 0, b: 0 }); + } + // Red to orange + for (let i = 0; i < 32; i++) { + palette.push({ r: 160 + i * 3, g: i * 5, b: 0 }); + } + // Orange to yellow + for (let i = 0; i < 32; i++) { + palette.push({ r: 255, g: 160 + i * 3, b: i * 2 }); + } + // Yellow to white (at the hottest) + for (let i = 0; i < 32; i++) { + palette.push({ r: 255, g: 255, b: i * 8 }); + } + return palette; + } + + // Initialize the fire with a hot bottom row + initFire() { + // Reset all cells to 0 + this.fireBuffer.fill(0); + this.embers = []; + + // Set bottom row to maximum heat based on source width + const sourceStart = Math.floor(this.width * (0.5 - this.sourceWidth/200)); + const sourceEnd = Math.floor(this.width * (0.5 + this.sourceWidth/200)); + + for (let x = 0; x < this.width; x++) { + // Only add heat within the source width + if (x >= sourceStart && x <= sourceEnd) { + // Add more heat intensity when source is narrower + const intensityMultiplier = 100 / this.sourceWidth; + const heatValue = Math.min(160, 100 + Math.random() * 60 * intensityMultiplier); + this.fireBuffer[(this.height - 1) * this.width + x] = heatValue; + } else { + this.fireBuffer[(this.height - 1) * this.width + x] = 0; + } + } + } + + // Update the fire simulation + updateFire() { + // Update heat values - simulate fire rising and cooling + for (let y = 0; y < this.height - 1; y++) { + for (let x = 0; x < this.width; x++) { + const src = y * this.width + x; + + // Calculate wind effect - stronger at higher points (more realistic) + const heightFactor = 1 - (y / this.height); // 0 at bottom, 1 at top + const windEffect = this.windDirection * this.windStrength * heightFactor * 0.05; + + // Random spread factor plus wind effect + const baseSpread = Math.floor(Math.random() * 3) - 1; + const windSpread = baseSpread + windEffect; + + // Apply wind to spread calculation + const windOffset = Math.floor(windSpread); + + // Calculate the destination index with wind-affected spread + const dstX = Math.max(0, Math.min(this.width - 1, x + windOffset)); + const dst = (y + 1) * this.width + dstX; + + // Apply cooling - more cooling when wind is stronger + const windCoolingFactor = 1 + (Math.abs(this.windDirection) * 0.02); + let coolAmount = Math.random() * this.cooling * windCoolingFactor; + let value = this.fireBuffer[dst] - coolAmount; + + // Make sure we don't go below 0 + this.fireBuffer[src] = Math.max(0, value); + } + } + + // Add new heat at the bottom for continuous fire based on source width + const sourceStart = Math.floor(this.width * (0.5 - this.sourceWidth/200)); + const sourceEnd = Math.floor(this.width * (0.5 + this.sourceWidth/200)); + + for (let x = 0; x < this.width; x++) { + // Only add heat within the source width + if (x >= sourceStart && x <= sourceEnd) { + if (Math.random() < 0.7) { + // Add more heat intensity when source is narrower + const intensityMultiplier = 100 / this.sourceWidth; + let value = Math.random() * this.intensity * intensityMultiplier; + const idx = (this.height - 1) * this.width + x; + this.fireBuffer[idx] = Math.min(160, this.fireBuffer[idx] + value); + } + } + } + + // Generate embers + if (this.embersEnabled) { + this.generateEmbers(); + } + } + + // Generate ember particles + generateEmbers() { + const sourceStart = Math.floor(this.width * (0.5 - this.sourceWidth/200)); + const sourceEnd = Math.floor(this.width * (0.5 + this.sourceWidth/200)); + + // Generate new embers based on ember rate + for (let i = 0; i < this.emberRate; i++) { + if (Math.random() < 0.2) { + // Generate ember from a random position in the fire source + const x = sourceStart + Math.random() * (sourceEnd - sourceStart); + const y = this.height - 10 - Math.random() * 30; + + // Only create ember if the position is hot enough + const idx = Math.floor(y) * this.width + Math.floor(x); + if (this.fireBuffer[idx] > 80) { + // Create a new ember with random properties + this.embers.push({ + x: x, + y: y, + size: (0.5 + Math.random()) * this.emberSize, + vx: (Math.random() - 0.5) * 1.5 + (this.windDirection * 0.1), + vy: -1 - Math.random() * 2, + life: 1.0, // Life percentage (1.0 = full life, 0.0 = dead) + decay: 0.005 + Math.random() * 0.01 + }); + } + } + } + + // Update existing embers + for (let i = this.embers.length - 1; i >= 0; i--) { + const ember = this.embers[i]; + + // Update position + ember.x += ember.vx; + ember.y += ember.vy; + + // Apply wind + ember.vx += this.windDirection * 0.01; + + // Apply slight upward acceleration (buoyancy) + ember.vy -= 0.01; + + // Decay life + ember.life -= ember.decay; + + // Remove dead embers + if (ember.life <= 0 || ember.x < 0 || ember.x >= this.width || ember.y < 0) { + this.embers.splice(i, 1); + } + } + } + + // Render the fire buffer to the canvas + renderFire() { + // Clear canvas with black background + this.ctx.fillStyle = 'black'; + this.ctx.fillRect(0, 0, this.width, this.height); + + // Map heat values to colors and put them in the image data + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const index = y * this.width + x; + const colorIndex = Math.min(this.palette.length - 1, Math.floor(this.fireBuffer[index])); + const color = this.palette[colorIndex]; + + // Calculate image data index (4 bytes per pixel: R,G,B,A) + const imgDataIdx = (y * this.width + x) * 4; + + // Set the color + this.imageData.data[imgDataIdx] = color.r; + this.imageData.data[imgDataIdx + 1] = color.g; + this.imageData.data[imgDataIdx + 2] = color.b; + this.imageData.data[imgDataIdx + 3] = 255; // Alpha (fully opaque) + } + } + + // Put the image data on the canvas + this.ctx.putImageData(this.imageData, 0, 0); + + // Render embers on top of the fire + if (this.embersEnabled) { + this.ctx.globalCompositeOperation = 'lighter'; + + for (const ember of this.embers) { + // Create a gradient for the ember + const gradient = this.ctx.createRadialGradient( + ember.x, ember.y, 0, + ember.x, ember.y, ember.size * 2 + ); + + // Ember color based on life (yellow-orange to red as it dies) + const alpha = ember.life * 0.7; + gradient.addColorStop(0, `rgba(255, 255, 200, ${alpha})`); + gradient.addColorStop(0.2, `rgba(255, 160, 20, ${alpha * 0.8})`); + gradient.addColorStop(1, 'rgba(255, 50, 0, 0)'); + + // Draw the ember + this.ctx.fillStyle = gradient; + this.ctx.beginPath(); + this.ctx.arc(ember.x, ember.y, ember.size * 2, 0, Math.PI * 2); + this.ctx.fill(); + } + + // Reset composite operation + this.ctx.globalCompositeOperation = 'source-over'; + } + } + + // Animation loop + animate() { + if (this.paused) return; + + // Update the fire simulation multiple times for smoother animation + for (let i = 0; i < this.speedFactor; i++) { + this.updateFire(); + } + + // Render to canvas + this.renderFire(); + + // Schedule next frame + this.animationId = requestAnimationFrame(() => this.animate()); + } + + // Start the animation + start() { + this.paused = false; + if (!this.animationId) { + this.animate(); + } + } + + // Pause the animation + pause() { + this.paused = true; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + // Toggle pause state + togglePause() { + if (this.paused) { + this.start(); + } else { + this.pause(); + } + } + + // Reset the fire + reset() { + this.initFire(); + } + + // Update parameters + setIntensity(value) { + this.intensity = value; + } + + setCooling(value) { + this.cooling = value; + } + + setSpeed(value) { + this.speedFactor = value; + } + + setWindDirection(value) { + this.windDirection = value; + } + + setWindStrength(value) { + this.windStrength = value; + } + + setSourceWidth(value) { + this.sourceWidth = value; + this.initFire(); // Reinitialize fire with new width + } + + setEmbersEnabled(enabled) { + this.embersEnabled = enabled; + if (!enabled) { + this.embers = []; // Clear existing embers when disabled + } + } + + setEmberRate(value) { + this.emberRate = value; + } + + setEmberSize(value) { + this.emberSize = value; + } + } + + // Get the canvas element + const canvas = document.getElementById('fireCanvas'); + + // Create the fire effect + const fire = new FireEffect(canvas, canvas.width, canvas.height); + + // Start the animation + fire.start(); + + // Set up UI controls + const intensitySlider = document.getElementById('intensitySlider'); + const coolingSlider = document.getElementById('coolingSlider'); + const speedSlider = document.getElementById('speedSlider'); + const windSlider = document.getElementById('windSlider'); + const windStrengthSlider = document.getElementById('windStrengthSlider'); + const sourceWidthSlider = document.getElementById('sourceWidthSlider'); + const emberToggle = document.getElementById('emberToggle'); + const emberRateSlider = document.getElementById('emberRateSlider'); + const emberSizeSlider = document.getElementById('emberSizeSlider'); + const windValueDisplay = document.getElementById('windValue'); + const sourceWidthValueDisplay = document.getElementById('sourceWidthValue'); + const pauseBtn = document.getElementById('pauseBtn'); + const resetBtn = document.getElementById('resetBtn'); + + // Event listeners for sliders + intensitySlider.addEventListener('input', function() { + fire.setIntensity(parseInt(this.value)); + }); + + coolingSlider.addEventListener('input', function() { + fire.setCooling(parseInt(this.value)); + }); + + speedSlider.addEventListener('input', function() { + fire.setSpeed(parseInt(this.value)); + }); + + windSlider.addEventListener('input', function() { + const value = parseFloat(this.value); + fire.setWindDirection(value); + windValueDisplay.textContent = value.toFixed(1); + }); + + windStrengthSlider.addEventListener('input', function() { + fire.setWindStrength(parseInt(this.value)); + }); + + sourceWidthSlider.addEventListener('input', function() { + const value = parseInt(this.value); + fire.setSourceWidth(value); + sourceWidthValueDisplay.textContent = value + '%'; + }); + + emberToggle.addEventListener('change', function() { + fire.setEmbersEnabled(this.checked); + }); + + emberRateSlider.addEventListener('input', function() { + fire.setEmberRate(parseInt(this.value)); + }); + + emberSizeSlider.addEventListener('input', function() { + fire.setEmberSize(parseInt(this.value)); + }); + + // Event listeners for buttons + pauseBtn.addEventListener('click', function() { + fire.togglePause(); + this.textContent = fire.paused ? 'Resume' : 'Pause'; + }); + + resetBtn.addEventListener('click', function() { + fire.reset(); + }); + </script> +@endsection
\ No newline at end of file diff --git a/resources/views/toys/index.blade.php b/resources/views/toys/index.blade.php new file mode 100644 index 0000000..fd3f834 --- /dev/null +++ b/resources/views/toys/index.blade.php @@ -0,0 +1,15 @@ +@extends('template') + +@section('head') +<title>Some Javascript toys</title> +@endsection + +@section('nav') +<a href = "/toy/blood">Blood Splatters</a> +<a href = "/toy/fire">Animated Fire</a> + +@endsection + +@section('body') + +@endsection
\ No newline at end of file diff --git a/resources/views/toys/spriteframework.blade.php b/resources/views/toys/spriteframework.blade.php new file mode 100644 index 0000000..be0e823 --- /dev/null +++ b/resources/views/toys/spriteframework.blade.php @@ -0,0 +1,3736 @@ +<!DOCTYPE html> +<html> +<head> + <title>Effect Sprite Generator with GIF Export</title> + <style> + body { + background: #222; + margin: 0; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + font-family: Arial, sans-serif; + color: #eee; + } + + h1, h2, h3 { + margin-top: 0; + color: #fff; + text-shadow: 1px 1px 3px rgba(0,0,0,0.5); + } + + .container { + display: flex; + flex-wrap: wrap; + max-width: 1200px; + gap: 20px; + justify-content: center; + } + + .canvas-container { + position: relative; + border: 1px solid #444; + background: #000; + box-shadow: 0 0 10px rgba(0,0,0,0.5); + margin-bottom: 10px; + } + + .canvas-container canvas { + display: block; + } + + .controls-panel { + width: 320px; + background: #333; + padding: 15px; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0,0,0,0.5); + } + + .control-section { + margin-bottom: 20px; + border-bottom: 1px solid #444; + padding-bottom: 10px; + } + + .control-section:last-child { + margin-bottom: 0; + border-bottom: none; + } + + .control-group { + margin-bottom: 10px; + } + + label { + display: block; + margin-bottom: 5px; + } + + select, input[type="range"], input[type="number"], input[type="checkbox"] { + width: 100%; + margin-bottom: 5px; + background: #444; + color: #fff; + border: 1px solid #555; + padding: 5px; + border-radius: 4px; + } + + input[type="checkbox"] { + width: auto; + } + + select { + appearance: none; + padding: 8px; + } + + button { + background: #4a6cd3; + color: white; + border: none; + padding: 10px 15px; + border-radius: 4px; + cursor: pointer; + margin: 5px 5px 5px 0; + transition: background 0.2s; + } + + button:hover { + background: #5d7ee6; + } + + button.record { + background: #d34a4a; + } + + button.record:hover { + background: #e65d5d; + } + + button.export { + background: #4ad358; + } + + button.export:hover { + background: #5de66b; + } + + .export-panel { + margin-top: 20px; + padding: 15px; + background: #2a2a2a; + border-radius: 5px; + } + + .preview-container { + width: 600px; + background: #333; + padding: 15px; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0,0,0,0.5); + } + + .preview-options { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin-bottom: 10px; + } + + .preview-frames { + display: flex; + flex-wrap: wrap; + gap: 5px; + max-height: 300px; + overflow-y: auto; + padding: 10px; + background: #2a2a2a; + border-radius: 4px; + } + + .frame-preview { + border: 1px solid #555; + background: #000; + position: relative; + } + + .frame-preview.selected { + border-color: #4a6cd3; + } + + .frame-preview canvas { + display: block; + } + + .frame-controls { + position: absolute; + bottom: 0; + right: 0; + background: rgba(0,0,0,0.7); + padding: 3px; + } + + .frame-controls button { + padding: 2px 5px; + margin: 0 2px; + font-size: 0.7em; + } + + .tabs { + display: flex; + margin-bottom: 15px; + } + + .tab { + padding: 10px 15px; + background: #333; + cursor: pointer; + border-radius: 4px 4px 0 0; + margin-right: 5px; + } + + .tab.active { + background: #4a6cd3; + } + + .tab-content { + display: none; + } + + .tab-content.active { + display: block; + } + + #effectPreview { + margin: 10px 0; + } + + .recording-indicator { + position: absolute; + top: 10px; + right: 10px; + width: 15px; + height: 15px; + border-radius: 50%; + background: #d34a4a; + display: none; + } + + .recording-indicator.active { + display: block; + animation: blink 1s infinite; + } + + @keyframes blink { + 0% { opacity: 1; } + 50% { opacity: 0.3; } + 100% { opacity: 1; } + } + + .loader { + display: none; + border: 4px solid #f3f3f3; + border-top: 4px solid #4a6cd3; + border-radius: 50%; + width: 20px; + height: 20px; + animation: spin 2s linear infinite; + margin: 0 auto; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .effect-preview { + display: inline-block; + margin: 10px; + text-align: center; + cursor: pointer; + } + + .effect-preview img { + border: 2px solid transparent; + border-radius: 5px; + } + + .effect-preview.selected img { + border-color: #4a6cd3; + } + </style> +</head> +<body> + <h1>Effect Sprite Generator</h1> + + <div class="container"> + <div class="controls-panel"> + <div class="control-section"> + <h3>Effect Type</h3> + <div class="control-group"> + <select id="effectType"> + <option value="fire">Fire</option> + <option value="blood">Blood Splatter</option> + <option value="smoke">Smoke</option> + <option value="explosion">Explosion</option> + <option value="magic">Magic Effect</option> + <option value="water">Water</option> + </select> + </div> + </div> + + <div id="fireControls" class="effect-controls"> + <div class="control-section"> + <h3>Fire Parameters</h3> + <div class="control-group"> + <label for="fireIntensity">Intensity</label> + <input type="range" id="fireIntensity" min="1" max="20" value="10"> + </div> + <div class="control-group"> + <label for="fireCooling">Cooling</label> + <input type="range" id="fireCooling" min="1" max="10" value="4"> + </div> + <div class="control-group"> + <label for="fireSpeed">Speed</label> + <input type="range" id="fireSpeed" min="1" max="10" value="5"> + </div> + <div class="control-group"> + <label for="fireScatter">Flame Scatter</label> + <input type="range" id="fireScatter" min="1" max="10" value="5"> + </div> + <div class="control-group"> + <label for="fireWidth">Fire Width (%)</label> + <input type="range" id="fireWidth" min="10" max="100" value="100"> + </div> + <div class="control-group"> + <label for="windDirection">Wind Direction</label> + <input type="range" id="windDirection" min="-10" max="10" value="0" step="0.5"> + </div> + <div class="control-group"> + <label for="windStrength">Wind Strength</label> + <input type="range" id="windStrength" min="0" max="10" value="3"> + </div> + <div class="control-group"> + <label> + <input type="checkbox" id="windVariability" checked> + Wind Variability + </label> + </div> + <div class="control-group"> + <label for="emberToggle"> + <input type="checkbox" id="emberToggle" checked> + Show Embers + </label> + </div> + <div class="control-group"> + <label for="fireColor">Fire Color Preset</label> + <select id="fireColor"> + <option value="default">Default (Red/Orange)</option> + <option value="blue">Blue Fire</option> + <option value="green">Green Fire</option> + <option value="purple">Purple Fire</option> + <option value="rainbow">Rainbow Fire</option> + </select> + </div> + </div> + </div> + + <div id="bloodControls" class="effect-controls" style="display: none;"> + <div class="control-section"> + <h3>Blood Splatter Parameters</h3> + <div class="control-group"> + <label for="bloodGravity">Gravity</label> + <input type="range" id="bloodGravity" min="0.01" max="0.5" value="0.15" step="0.01"> + </div> + <div class="control-group"> + <label for="bloodViscosity">Viscosity</label> + <input type="range" id="bloodViscosity" min="0.75" max="0.99" value="0.92" step="0.01"> + </div> + <div class="control-group"> + <label for="bloodParticleCount">Particle Count</label> + <input type="range" id="bloodParticleCount" min="10" max="500" value="100"> + </div> + <div class="control-group"> + <label for="bloodParticleSize">Particle Size</label> + <input type="range" id="bloodParticleSize" min="1" max="8" value="3"> + </div> + <div class="control-group"> + <label for="bloodSprayForce">Spray Force</label> + <input type="range" id="bloodSprayForce" min="1" max="20" value="10"> + </div> + <div class="control-group"> + <label for="bloodSpread">Spread</label> + <input type="range" id="bloodSpread" min="0" max="1" value="0.3" step="0.05"> + </div> + <div class="control-group"> + <label for="bloodColor">Blood Color</label> + <select id="bloodColor"> + <option value="red">Red</option> + <option value="green">Green (Slime)</option> + <option value="blue">Blue</option> + <option value="black">Black</option> + <option value="custom">Custom</option> + </select> + </div> + <div id="customBloodColorGroup" class="control-group" style="display: none;"> + <label for="customBloodColor">Custom Color</label> + <input type="color" id="customBloodColor" value="#ff0000"> + </div> + <div class="control-group"> + <label> + <input type="checkbox" id="dripsToggle" checked> + Enable Drips + </label> + </div> + <div class="control-group"> + <label> + <input type="checkbox" id="poolingToggle" checked> + Enable Pooling + </label> + </div> + </div> + </div> + + <div id="smokeControls" class="effect-controls" style="display: none;"> + <div class="control-section"> + <h3>Smoke Parameters</h3> + <div class="control-group"> + <label for="smokeOpacity">Opacity</label> + <input type="range" id="smokeOpacity" min="0.1" max="1" value="0.6" step="0.05"> + </div> + <div class="control-group"> + <label for="smokeRiseSpeed">Rise Speed</label> + <input type="range" id="smokeRiseSpeed" min="0.5" max="5" value="1.5" step="0.1"> + </div> + <div class="control-group"> + <label for="smokeDensity">Density</label> + <input type="range" id="smokeDensity" min="1" max="10" value="5"> + </div> + <div class="control-group"> + <label for="smokeTurbulence">Turbulence</label> + <input type="range" id="smokeTurbulence" min="0" max="10" value="3"> + </div> + <div class="control-group"> + <label for="smokeSize">Particle Size</label> + <input type="range" id="smokeSize" min="5" max="50" value="20"> + </div> + <div class="control-group"> + <label for="smokeColor">Smoke Color</label> + <select id="smokeColor"> + <option value="white">White</option> + <option value="black">Black</option> + <option value="gray">Gray</option> + <option value="custom">Custom</option> + </select> + </div> + <div id="customSmokeColorGroup" class="control-group" style="display: none;"> + <label for="customSmokeColor">Custom Color</label> + <input type="color" id="customSmokeColor" value="#cccccc"> + </div> + </div> + </div> + + <div id="explosionControls" class="effect-controls" style="display: none;"> + <div class="control-section"> + <h3>Explosion Parameters</h3> + <div class="control-group"> + <label for="explosionSize">Size</label> + <input type="range" id="explosionSize" min="1" max="10" value="5"> + </div> + <div class="control-group"> + <label for="explosionSpeed">Speed</label> + <input type="range" id="explosionSpeed" min="1" max="10" value="5"> + </div> + <div class="control-group"> + <label for="explosionParticles">Particle Count</label> + <input type="range" id="explosionParticles" min="50" max="500" value="200"> + </div> + <div class="control-group"> + <label for="explosionColor">Color Scheme</label> + <select id="explosionColor"> + <option value="fire">Fire (Orange/Red)</option> + <option value="energy">Energy (Blue/White)</option> + <option value="toxic">Toxic (Green/Yellow)</option> + <option value="magic">Magic (Purple/Pink)</option> + </select> + </div> + <div class="control-group"> + <label> + <input type="checkbox" id="explosionSmoke" checked> + Include Smoke + </label> + </div> + <div class="control-group"> + <label> + <input type="checkbox" id="explosionShockwave" checked> + Include Shockwave + </label> + </div> + <div class="control-group"> + <label> + <input type="checkbox" id="explosionLoop"> + Loop Animation + </label> + </div> + </div> + </div> + + <div id="magicControls" class="effect-controls" style="display: none;"> + <div class="control-section"> + <h3>Magic Effect Parameters</h3> + <div class="control-group"> + <label for="magicType">Effect Type</label> + <select id="magicType"> + <option value="sparkles">Sparkles</option> + <option value="aura">Aura</option> + <option value="bolts">Energy Bolts</option> + <option value="portal">Portal</option> + </select> + </div> + <div class="control-group"> + <label for="magicIntensity">Intensity</label> + <input type="range" id="magicIntensity" min="1" max="10" value="5"> + </div> + <div class="control-group"> + <label for="magicSpeed">Speed</label> + <input type="range" id="magicSpeed" min="1" max="10" value="5"> + </div> + <div class="control-group"> + <label for="magicColor">Color Scheme</label> + <select id="magicColor"> + <option value="arcane">Arcane (Purple)</option> + <option value="nature">Nature (Green)</option> + <option value="fire">Fire (Red/Orange)</option> + <option value="ice">Ice (Blue/White)</option> + <option value="holy">Holy (Gold/White)</option> + <option value="void">Void (Black/Purple)</option> + </select> + </div> + <div class="control-group"> + <label for="magicGlow">Glow Intensity</label> + <input type="range" id="magicGlow" min="0" max="10" value="5"> + </div> + <div class="control-group"> + <label> + <input type="checkbox" id="magicPulse" checked> + Pulsing Effect + </label> + </div> + </div> + </div> + + <div id="waterControls" class="effect-controls" style="display: none;"> + <div class="control-section"> + <h3>Water Parameters</h3> + <div class="control-group"> + <label for="waterType">Effect Type</label> + <select id="waterType"> + <option value="splash">Splash</option> + <option value="drip">Drip</option> + <option value="flow">Flow</option> + <option value="ripple">Ripple</option> + </select> + </div> + <div class="control-group"> + <label for="waterViscosity">Viscosity</label> + <input type="range" id="waterViscosity" min="0.8" max="1" value="0.95" step="0.01"> + </div> + <div class="control-group"> + <label for="waterSpeed">Speed</label> + <input type="range" id="waterSpeed" min="1" max="10" value="5"> + </div> + <div class="control-group"> + <label for="waterParticles">Particle Count</label> + <input type="range" id="waterParticles" min="50" max="500" value="200"> + </div> + <div class="control-group"> + <label for="waterColor">Water Color</label> + <select id="waterColor"> + <option value="clear">Clear Blue</option> + <option value="deep">Deep Blue</option> + <option value="murky">Murky</option> + <option value="toxic">Toxic Green</option> + <option value="custom">Custom</option> + </select> + </div> + <div id="customWaterColorGroup" class="control-group" style="display: none;"> + <label for="customWaterColor">Custom Color</label> + <input type="color" id="customWaterColor" value="#4a8eff"> + </div> + <div class="control-group"> + <label for="waterTransparency">Transparency</label> + <input type="range" id="waterTransparency" min="0.1" max="1" value="0.7" step="0.05"> + </div> + <div class="control-group"> + <label> + <input type="checkbox" id="waterReflection" checked> + Reflections + </label> + </div> + </div> + </div> + + <div class="control-section"> + <h3>Animation Settings</h3> + <div class="control-group"> + <label for="canvasWidth">Canvas Width</label> + <input type="number" id="canvasWidth" min="64" max="512" value="256"> + </div> + <div class="control-group"> + <label for="canvasHeight">Canvas Height</label> + <input type="number" id="canvasHeight" min="64" max="512" value="256"> + </div> + <div class="control-group"> + <label for="backgroundColor">Background Color</label> + <select id="backgroundColor"> + <option value="transparent">Transparent</option> + <option value="black">Black</option> + <option value="white">White</option> + <option value="custom">Custom</option> + </select> + </div> + <div id="customBgColorGroup" class="control-group" style="display: none;"> + <label for="customBgColor">Custom Color</label> + <input type="color" id="customBgColor" value="#000000"> + </div> + <div class="control-group"> + <button id="resizeCanvas">Apply Size</button> + </div> + </div> + + <div class="control-section"> + <h3>GIF Export</h3> + <div class="control-group"> + <label for="gifQuality">Quality</label> + <select id="gifQuality"> + <option value="high">High (more colors, larger file)</option> + <option value="medium" selected>Medium</option> + <option value="low">Low (fewer colors, smaller file)</option> + </select> + </div> + <div class="control-group"> + <label for="frameDuration">Frame Duration (ms)</label> + <input type="number" id="frameDuration" min="10" max="1000" value="100"> + </div> + <div class="control-group"> + <label for="loopCount">Loop Count</label> + <input type="number" id="loopCount" min="0" max="100" value="0"> + <small>(0 = infinite loop)</small> + </div> + <div class="button-group"> + <button id="recordBtn" class="record">Start Recording</button> + <button id="exportGifBtn" class="export" disabled>Export as GIF</button> + </div> + </div> + </div> + + <div class="preview-container"> + <h3>Effect Preview</h3> + + <div class="canvas-container"> + <canvas id="effectCanvas" width="256" height="256"></canvas> + <div id="recordingIndicator" class="recording-indicator"></div> + </div> + + <div id="recordingControls" style="display: none;"> + <div class="recording-stats"> + <p>Recording: <span id="frameCount">0</span> frames captured</p> + <button id="stopRecordingBtn">Stop Recording</button> + <div class="loader" id="exportLoader"></div> + </div> + </div> + + <div id="framesPreview" style="display: none;"> + <h3>Captured Frames</h3> + <div class="preview-options"> + <button id="clearFramesBtn">Clear All Frames</button> + <button id="previewAnimationBtn">Preview Animation</button> + </div> + <div class="preview-frames" id="framesContainer"> + <!-- Captured frames will be displayed here --> + </div> + </div> + + <div id="exportedPreview" style="display: none;"> + <h3>Exported Animation</h3> + <div id="exportResult"></div> + </div> + </div> + </div> + + <!-- Include gif.js library --> + <script src="https://cdnjs.cloudflare.com/gif.js/0.2.0/gif.js"></script> + + <script> + // Global variables + let effectCanvas, effectCtx; + let currentEffect = 'fire'; + let recording = false; + let frames = []; + let currentFireEffect, currentBloodEffect, currentSmokeEffect, currentExplosionEffect, currentMagicEffect, currentWaterEffect; + let animationId = null; + + // Initialize on page load + window.addEventListener('DOMContentLoaded', () => { + // Get canvas and context + effectCanvas = document.getElementById('effectCanvas'); + effectCtx = effectCanvas.getContext('2d'); + + // Init canvas size + updateCanvasSize(); + + // Set up event listeners + document.getElementById('effectType').addEventListener('change', handleEffectChange); + document.getElementById('recordBtn').addEventListener('click', toggleRecording); + document.getElementById('stopRecordingBtn').addEventListener('click', stopRecording); + document.getElementById('exportGifBtn').addEventListener('click', exportGif); + document.getElementById('resizeCanvas').addEventListener('click', updateCanvasSize); + document.getElementById('clearFramesBtn').addEventListener('click', clearFrames); + document.getElementById('previewAnimationBtn').addEventListener('click', previewAnimation); + + // Background color change handler + document.getElementById('backgroundColor').addEventListener('change', function() { + if (this.value === 'custom') { + document.getElementById('customBgColorGroup').style.display = 'block'; + } else { + document.getElementById('customBgColorGroup').style.display = 'none'; + } + }); + + // Custom color handlers + document.getElementById('bloodColor').addEventListener('change', function() { + document.getElementById('customBloodColorGroup').style.display = + this.value === 'custom' ? 'block' : 'none'; + }); + + document.getElementById('smokeColor').addEventListener('change', function() { + document.getElementById('customSmokeColorGroup').style.display = + this.value === 'custom' ? 'block' : 'none'; + }); + + document.getElementById('waterColor').addEventListener('change', function() { + document.getElementById('customWaterColorGroup').style.display = + this.value === 'custom' ? 'block' : 'none'; + }); + + // Initialize fire effect + initFireEffect(); + + // Start animation loop + animate(); + }); + + function updateCanvasSize() { + const width = parseInt(document.getElementById('canvasWidth').value); + const height = parseInt(document.getElementById('canvasHeight').value); + + // Update canvas size + effectCanvas.width = width; + effectCanvas.height = height; + + // Re-initialize current effect + switch(currentEffect) { + case 'fire': + initFireEffect(); + break; + case 'blood': + initBloodEffect(); + break; + case 'smoke': + initSmokeEffect(); + break; + case 'explosion': + initExplosionEffect(); + break; + case 'magic': + initMagicEffect(); + break; + case 'water': + initWaterEffect(); + break; + } + } + + function handleEffectChange() { + // Get the selected effect + const selectedEffect = document.getElementById('effectType').value; + + // Hide all effect controls + document.querySelectorAll('.effect-controls').forEach(el => { + el.style.display = 'none'; + }); + + // Show the selected effect controls + document.getElementById(`${selectedEffect}Controls`).style.display = 'block'; + + // Update the current effect + currentEffect = selectedEffect; + + // Initialize the selected effect if not already initialized + switch(selectedEffect) { + case 'fire': + if (!currentFireEffect) initFireEffect(); + break; + case 'blood': + if (!currentBloodEffect) initBloodEffect(); + break; + case 'smoke': + if (!currentSmokeEffect) initSmokeEffect(); + break; + case 'explosion': + if (!currentExplosionEffect) initExplosionEffect(); + break; + case 'magic': + if (!currentMagicEffect) initMagicEffect(); + break; + case 'water': + if (!currentWaterEffect) initWaterEffect(); + break; + } + } + + // Frame Recording Functions + function toggleRecording() { + if (!recording) { + startRecording(); + } else { + stopRecording(); + } + } + + function startRecording() { + recording = true; + frames = []; + document.getElementById('recordBtn').textContent = 'Recording...'; + document.getElementById('recordBtn').classList.add('recording'); + document.getElementById('recordingIndicator').classList.add('active'); + document.getElementById('recordingControls').style.display = 'block'; + document.getElementById('framesPreview').style.display = 'none'; + document.getElementById('exportedPreview').style.display = 'none'; + } + + function stopRecording() { + recording = false; + document.getElementById('recordBtn').textContent = 'Start Recording'; + document.getElementById('recordBtn').classList.remove('recording'); + document.getElementById('recordingIndicator').classList.remove('active'); + document.getElementById('exportGifBtn').disabled = frames.length === 0; + document.getElementById('frameCount').textContent = frames.length; + + if (frames.length > 0) { + document.getElementById('framesPreview').style.display = 'block'; + renderFramePreviews(); + } + } + + function captureFrame() { + // Create a new canvas for the frame + const frameCanvas = document.createElement('canvas'); + frameCanvas.width = effectCanvas.width; + frameCanvas.height = effectCanvas.height; + const frameCtx = frameCanvas.getContext('2d'); + + // Copy the current canvas state + frameCtx.drawImage(effectCanvas, 0, 0); + + // Add to frames array + frames.push(frameCanvas.toDataURL('image/png')); + + // Update frame count + document.getElementById('frameCount').textContent = frames.length; + } + + function renderFramePreviews() { + const container = document.getElementById('framesContainer'); + container.innerHTML = ''; + + frames.forEach((frame, index) => { + const frameDiv = document.createElement('div'); + frameDiv.className = 'frame-preview'; + + const frameCanvas = document.createElement('canvas'); + frameCanvas.width = 80; + frameCanvas.height = 80; + const frameCtx = frameCanvas.getContext('2d'); + + const img = new Image(); + img.onload = function() { + // Calculate sizing to maintain aspect ratio + const aspectRatio = img.width / img.height; + let drawWidth, drawHeight; + + if (aspectRatio > 1) { + drawWidth = 80; + drawHeight = 80 / aspectRatio; + } else { + drawHeight = 80; + drawWidth = 80 * aspectRatio; + } + + // Center the image + const offsetX = (80 - drawWidth) / 2; + const offsetY = (80 - drawHeight) / 2; + + frameCtx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); + }; + img.src = frame; + + // Add controls + const controls = document.createElement('div'); + controls.className = 'frame-controls'; + + const deleteBtn = document.createElement('button'); + deleteBtn.textContent = 'X'; + deleteBtn.addEventListener('click', () => { + frames.splice(index, 1); + renderFramePreviews(); + document.getElementById('frameCount').textContent = frames.length; + document.getElementById('exportGifBtn').disabled = frames.length === 0; + }); + + controls.appendChild(deleteBtn); + frameDiv.appendChild(frameCanvas); + frameDiv.appendChild(controls); + container.appendChild(frameDiv); + }); + } + + function clearFrames() { + frames = []; + document.getElementById('framesContainer').innerHTML = ''; + document.getElementById('frameCount').textContent = '0'; + document.getElementById('exportGifBtn').disabled = true; + document.getElementById('framesPreview').style.display = 'none'; + } + + function previewAnimation() { + if (frames.length === 0) return; + + // Create preview animation container + const container = document.getElementById('exportedPreview'); + container.style.display = 'block'; + container.querySelector('#exportResult').innerHTML = ''; + + // Create preview canvas + const previewCanvas = document.createElement('canvas'); + previewCanvas.width = effectCanvas.width; + previewCanvas.height = effectCanvas.height; + const previewCtx = previewCanvas.getContext('2d'); + + document.getElementById('exportResult').appendChild(previewCanvas); + + // Animation variables + let currentFrame = 0; + const frameDuration = parseInt(document.getElementById('frameDuration').value); + + // Clear any existing animation + if (animationId) { + cancelAnimationFrame(animationId); + } + + // Animation function + function animatePreview() { + const img = new Image(); + img.onload = function() { + previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); + previewCtx.drawImage(img, 0, 0, previewCanvas.width, previewCanvas.height); + + currentFrame = (currentFrame + 1) % frames.length; + setTimeout(() => { + animationId = requestAnimationFrame(animatePreview); + }, frameDuration); + }; + img.src = frames[currentFrame]; + } + + animatePreview(); + } + + function exportGif() { + if (frames.length === 0) return; + + // Show loader + document.getElementById('exportLoader').style.display = 'block'; + + // Get export settings + const quality = document.getElementById('gifQuality').value; + const frameDuration = parseInt(document.getElementById('frameDuration').value); + const loopCount = parseInt(document.getElementById('loopCount').value); + + // Set up GIF encoder with appropriate settings + const gif = new GIF({ + workers: 2, + quality: quality === 'high' ? 1 : quality === 'medium' ? 5 : 20, + workerScript: 'https://cdnjs.cloudflare.com/gif.js/0.2.0/gif.worker.js', + width: effectCanvas.width, + height: effectCanvas.height, + repeat: loopCount + }); + + let framesLoaded = 0; + + // Add each frame to the gif + frames.forEach(frameData => { + const img = new Image(); + img.onload = function() { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = effectCanvas.width; + tempCanvas.height = effectCanvas.height; + const tempCtx = tempCanvas.getContext('2d'); + + tempCtx.drawImage(img, 0, 0, effectCanvas.width, effectCanvas.height); + gif.addFrame(tempCtx, {delay: frameDuration, copy: true}); + + framesLoaded++; + if (framesLoaded === frames.length) { + // All frames loaded, render the gif + gif.render(); + } + }; + img.src = frameData; + }); + + // When the gif is finished + gif.on('finished', function(blob) { + // Hide loader + document.getElementById('exportLoader').style.display = 'none'; + + // Create preview + const container = document.getElementById('exportedPreview'); + container.style.display = 'block'; + + const resultContainer = document.getElementById('exportResult'); + resultContainer.innerHTML = ''; + + // Display the gif + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + resultContainer.appendChild(img); + + // Add download button + const downloadBtn = document.createElement('button'); + downloadBtn.textContent = 'Download GIF'; + downloadBtn.className = 'export'; + downloadBtn.style.marginTop = '10px'; + downloadBtn.addEventListener('click', () => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${currentEffect}-effect.gif`; + link.click(); + }); + + resultContainer.appendChild(document.createElement('br')); + resultContainer.appendChild(downloadBtn); + }); + } + + // Animation loop + function animate() { + // Clear canvas + clearCanvas(); + + // Update and render current effect + switch(currentEffect) { + case 'fire': + updateFireEffect(); + break; + case 'blood': + updateBloodEffect(); + break; + case 'smoke': + updateSmokeEffect(); + break; + case 'explosion': + updateExplosionEffect(); + break; + case 'magic': + updateMagicEffect(); + break; + case 'water': + updateWaterEffect(); + break; + } + + // Capture frame if recording + if (recording) { + captureFrame(); + } + + // Continue animation loop + requestAnimationFrame(animate); + } + + function clearCanvas() { + const bgColor = document.getElementById('backgroundColor').value; + + if (bgColor === 'transparent') { + effectCtx.clearRect(0, 0, effectCanvas.width, effectCanvas.height); + } else if (bgColor === 'custom') { + effectCtx.fillStyle = document.getElementById('customBgColor').value; + effectCtx.fillRect(0, 0, effectCanvas.width, effectCanvas.height); + } else { + effectCtx.fillStyle = bgColor; + effectCtx.fillRect(0, 0, effectCanvas.width, effectCanvas.height); + } + } + + /*** FIRE EFFECT ***/ + + function initFireEffect() { + const width = effectCanvas.width; + const height = effectCanvas.height; + + currentFireEffect = { + width: width, + height: height, + fireBuffer: new Array(width * height).fill(0), + palette: createFirePalette('default'), + particles: [], + // Parameters + intensity: 10, + cooling: 4, + speedFactor: 5, + windDirection: 0, + windStrength: 3, + flameScatter: 5, + sourceWidth: 100, + windVariabilityEnabled: true, + windVariability: 5, + embersEnabled: true, + emberRate: 5, + emberSize: 2, + // Wind variability + windTime: 0, + currentVariableWind: 0, + targetVariableWind: 0 + }; + + // Initialize the fire with heat at bottom + resetFireBuffer(); + + // Setup event listeners for fire parameters + document.getElementById('fireIntensity').addEventListener('input', function() { + currentFireEffect.intensity = parseInt(this.value); + }); + + document.getElementById('fireCooling').addEventListener('input', function() { + currentFireEffect.cooling = parseInt(this.value); + }); + + document.getElementById('fireSpeed').addEventListener('input', function() { + currentFireEffect.speedFactor = parseInt(this.value); + }); + + document.getElementById('fireScatter').addEventListener('input', function() { + currentFireEffect.flameScatter = parseInt(this.value); + }); + + document.getElementById('fireWidth').addEventListener('input', function() { + currentFireEffect.sourceWidth = parseInt(this.value); + }); + + document.getElementById('windDirection').addEventListener('input', function() { + currentFireEffect.windDirection = parseFloat(this.value); + }); + + document.getElementById('windStrength').addEventListener('input', function() { + currentFireEffect.windStrength = parseInt(this.value); + }); + + document.getElementById('windVariability').addEventListener('change', function() { + currentFireEffect.windVariabilityEnabled = this.checked; + }); + + document.getElementById('emberToggle').addEventListener('change', function() { + currentFireEffect.embersEnabled = this.checked; + if (!this.checked) { + currentFireEffect.particles = []; + } + }); + + document.getElementById('fireColor').addEventListener('change', function() { + currentFireEffect.palette = createFirePalette(this.value); + }); + } + + function resetFireBuffer() { + if (!currentFireEffect) return; + + const width = currentFireEffect.width; + const height = currentFireEffect.height; + + // Reset buffer + currentFireEffect.fireBuffer.fill(0); + + // Create heat source based on source width + const sourceWidth = currentFireEffect.sourceWidth; + const sourceStart = Math.floor(width * (0.5 - sourceWidth/200)); + const sourceEnd = Math.floor(width * (0.5 + sourceWidth/200)); + + for (let x = 0; x < width; x++) { + if (x >= sourceStart && x <= sourceEnd) { + currentFireEffect.fireBuffer[(height - 1) * width + x] = 160; + } + } + } + + function createFirePalette(type) { + const palette = []; + + switch(type) { + case 'blue': + // Blue fire palette + // Black to dark blue + for (let i = 0; i < 32; i++) { + palette.push({ r: 0, g: 0, b: i * 2 }); + } + // Dark blue to blue + for (let i = 0; i < 32; i++) { + palette.push({ r: 0, g: i * 2, b: 64 + i * 5 }); + } + // Blue to light blue + for (let i = 0; i < 32; i++) { + palette.push({ r: 0, g: 64 + i * 3, b: 224 - i * 2 }); + } + // Light blue to white + for (let i = 0; i < 32; i++) { + palette.push({ r: i * 8, g: 160 + i * 3, b: 160 + i * 3 }); + } + break; + + case 'green': + // Green fire palette + // Black to dark green + for (let i = 0; i < 32; i++) { + palette.push({ r: 0, g: i * 2, b: 0 }); + } + // Dark green to green + for (let i = 0; i < 32; i++) { + palette.push({ r: 0, g: 64 + i * 3, b: i * 2 }); + } + // Green to light green/yellow + for (let i = 0; i < 32; i++) { + palette.push({ r: i * 5, g: 160 + i * 3, b: 64 + i }); + } + // Light green to white + for (let i = 0; i < 32; i++) { + palette.push({ r: 160 + i * 3, g: 224 + i, b: 96 + i * 5 }); + } + break; + + case 'purple': + // Purple fire palette + // Black to dark purple + for (let i = 0; i < 32; i++) { + palette.push({ r: i * 2, g: 0, b: i * 3 }); + } + // Dark purple to purple + for (let i = 0; i < 32; i++) { + palette.push({ r: 64 + i * 2, g: 0, b: 96 + i * 3 }); + } + // Purple to magenta + for (let i = 0; i < 32; i++) { + palette.push({ r: 128 + i * 4, g: i * 2, b: 192 - i }); + } + // Magenta to white + for (let i = 0; i < 32; i++) { + palette.push({ r: 224 + i, g: 64 + i * 6, b: 160 + i * 3 }); + } + break; + + case 'rainbow': + // Rainbow fire palette + const steps = 32; + const hueStep = 360 / (4 * steps); + + // Create a rainbow gradient + for (let i = 0; i < 4 * steps; i++) { + const hue = i * hueStep; + const rgb = hslToRgb(hue, 100, Math.min(50 + i / 2, 90)); + palette.push({ r: rgb[0], g: rgb[1], b: rgb[2] }); + } + break; + + default: + // Default red/orange/yellow fire palette + // Black to dark red + for (let i = 0; i < 32; i++) { + palette.push({ r: i * 2, g: 0, b: 0 }); + } + // Dark red to bright red + for (let i = 0; i < 32; i++) { + palette.push({ r: 64 + i * 3, g: 0, b: 0 }); + } + // Red to orange + for (let i = 0; i < 32; i++) { + palette.push({ r: 160 + i * 3, g: i * 5, b: 0 }); + } + // Orange to yellow + for (let i = 0; i < 32; i++) { + palette.push({ r: 255, g: 160 + i * 3, b: i * 2 }); + } + // Yellow to white + for (let i = 0; i < 32; i++) { + palette.push({ r: 255, g: 255, b: i * 8 }); + } + break; + } + + return palette; + } + + function updateWindVariability() { + if (!currentFireEffect.windVariabilityEnabled) { + currentFireEffect.currentVariableWind = 0; + return; + } + + // Update wind time + currentFireEffect.windTime += 0.01; + + // Check if we need a new target wind value + if (Math.random() < 0.01) { + // Set new target wind based on variability + const variabilityFactor = currentFireEffect.windVariability / 5; + currentFireEffect.targetVariableWind = (Math.random() * 2 - 1) * variabilityFactor * currentFireEffect.windDirection; + + // If wind direction is near zero, still have some variability + if (Math.abs(currentFireEffect.windDirection) < 0.5) { + currentFireEffect.targetVariableWind = (Math.random() * 2 - 1) * variabilityFactor * 2; + } + + // Adjust transition speed based on how different the new target is + currentFireEffect.windTransitionSpeed = 0.02 + Math.random() * 0.03; + } + + // Smoothly transition to target wind + const diff = currentFireEffect.targetVariableWind - currentFireEffect.currentVariableWind; + if (Math.abs(diff) > 0.01) { + currentFireEffect.currentVariableWind += diff * currentFireEffect.windTransitionSpeed; + } else { + currentFireEffect.currentVariableWind = currentFireEffect.targetVariableWind; + } + } + + function getEffectiveWind() { + return currentFireEffect.windDirection + currentFireEffect.currentVariableWind; + } + + function updateFireEffect() { + if (!currentFireEffect) return; + + // Update wind variability + updateWindVariability(); + + // Get effective wind direction including variability + const effectiveWind = getEffectiveWind(); + + // Update fire simulation multiple times for smoother animation + for (let step = 0; step < currentFireEffect.speedFactor; step++) { + // Main fire simulation + updateFireSimulation(effectiveWind); + + // Generate embers if enabled + if (currentFireEffect.embersEnabled) { + updateEmbers(effectiveWind); + } + } + + // Render fire to canvas + renderFire(); + } + + function updateFireSimulation(effectiveWind) { + const width = currentFireEffect.width; + const height = currentFireEffect.height; + const buffer = currentFireEffect.fireBuffer; + + // Update heat values - simulate fire rising and cooling + for (let y = 0; y < height - 1; y++) { + for (let x = 0; x < width; x++) { + const src = y * width + x; + + // Calculate height factor for vertical effects + const heightFactor = 1 - (y / height); // 0 at bottom, 1 at top + + // Calculate wind effect - stronger at higher points (more realistic) + const windEffect = effectiveWind * currentFireEffect.windStrength * heightFactor * 0.05; + + // Increased scatter factor based on height and user setting + const scatterFactor = 1 + (heightFactor * currentFireEffect.flameScatter * 0.4); + + // Random spread factor plus wind effect with increased scatter at top + const baseSpread = (Math.random() * 5 - 2.5) * scatterFactor; + const windSpread = baseSpread + windEffect; + + // Apply wind to spread calculation + const windOffset = Math.floor(windSpread); + + // Calculate the destination index with wind-affected spread + const dstX = Math.max(0, Math.min(width - 1, x + windOffset)); + const dst = (y + 1) * width + dstX; + + // Apply cooling - more cooling when wind is stronger + const windCoolingFactor = 1 + (Math.abs(effectiveWind) * 0.02); + // Also increase cooling with height for natural flame tapering + const heightCoolingFactor = 1 + (heightFactor * 0.5); + let coolAmount = Math.random() * currentFireEffect.cooling * windCoolingFactor * heightCoolingFactor; + let value = buffer[dst] - coolAmount; + + // Make sure we don't go below 0 + buffer[src] = Math.max(0, value); + } + } + + // Add new heat at the bottom for continuous fire based on source width + const sourceWidth = currentFireEffect.sourceWidth; + const sourceStart = Math.floor(width * (0.5 - sourceWidth/200)); + const sourceEnd = Math.floor(width * (0.5 + sourceWidth/200)); + + for (let x = 0; x < width; x++) { + // Only add heat within the source width + if (x >= sourceStart && x <= sourceEnd) { + if (Math.random() < 0.7) { + // Add more heat intensity when source is narrower + const intensityMultiplier = 100 / sourceWidth; + let value = Math.random() * currentFireEffect.intensity * intensityMultiplier; + const idx = (height - 1) * width + x; + buffer[idx] = Math.min(160, buffer[idx] + value); + } + } + } + } + + function updateEmbers(effectiveWind) { + if (!currentFireEffect) return; + + const width = currentFireEffect.width; + const height = currentFireEffect.height; + const particles = currentFireEffect.particles; + + // Source dimensions for ember creation + const sourceWidth = currentFireEffect.sourceWidth; + const sourceStart = Math.floor(width * (0.5 - sourceWidth/200)); + const sourceEnd = Math.floor(width * (0.5 + sourceWidth/200)); + + // Create new embers based on ember rate + for (let i = 0; i < currentFireEffect.emberRate; i++) { + if (Math.random() < 0.2) { + // Generate ember from a random position in the fire source + const x = sourceStart + Math.random() * (sourceEnd - sourceStart); + const y = height - 10 - Math.random() * 30; + + // Only create ember if the position is hot enough (check fire buffer) + const idx = Math.floor(y) * width + Math.floor(x); + if (currentFireEffect.fireBuffer[idx] > 80) { + // Create a new ember + particles.push({ + x: x, + y: y, + size: (0.5 + Math.random()) * currentFireEffect.emberSize, + vx: (Math.random() - 0.5) * 1.5 + (effectiveWind * 0.1), + vy: -1 - Math.random() * 2, + life: 1.0, // Life percentage (1.0 = full life, 0.0 = dead) + decay: 0.005 + Math.random() * 0.01 + }); + } + } + } + + // Update existing embers + for (let i = particles.length - 1; i >= 0; i--) { + const ember = particles[i]; + + // Update position + ember.x += ember.vx; + ember.y += ember.vy; + + // Apply wind with some variability + ember.vx += effectiveWind * 0.01 * (0.8 + Math.random() * 0.4); + + // Apply slight upward acceleration (buoyancy) + ember.vy -= 0.01; + + // Add some random motion to embers + ember.vx += (Math.random() - 0.5) * 0.1; + ember.vy += (Math.random() - 0.5) * 0.1; + + // Decay life + ember.life -= ember.decay; + + // Remove dead embers or those that go out of bounds + if (ember.life <= 0 || ember.x < 0 || ember.x >= width || ember.y < 0) { + particles.splice(i, 1); + } + } + } + + function renderFire() { + if (!currentFireEffect) return; + + const width = currentFireEffect.width; + const height = currentFireEffect.height; + const buffer = currentFireEffect.fireBuffer; + const palette = currentFireEffect.palette; + const particles = currentFireEffect.particles; + + // Create an image data object + const imageData = effectCtx.createImageData(width, height); + + // Map heat values to colors and put them in the image data + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = y * width + x; + const colorIndex = Math.min(palette.length - 1, Math.floor(buffer[index])); + const color = palette[colorIndex]; + + // Calculate image data index (4 bytes per pixel: R,G,B,A) + const imgDataIdx = (y * width + x) * 4; + + // Set the color + imageData.data[imgDataIdx] = color.r; + imageData.data[imgDataIdx + 1] = color.g; + imageData.data[imgDataIdx + 2] = color.b; + imageData.data[imgDataIdx + 3] = buffer[index] > 0 ? 255 : 0; // Transparent if no heat + } + } + + // Put the image data on the canvas + effectCtx.putImageData(imageData, 0, 0); + + // Draw embers on top of the fire + if (currentFireEffect.embersEnabled) { + effectCtx.globalCompositeOperation = 'lighter'; + + for (const ember of particles) { + // Create a gradient for the ember + const gradient = effectCtx.createRadialGradient( + ember.x, ember.y, 0, + ember.x, ember.y, ember.size * 2 + ); + + // Ember color based on life (yellow-orange to red as it dies) + const alpha = ember.life * 0.7; + gradient.addColorStop(0, `rgba(255, 255, 200, ${alpha})`); + gradient.addColorStop(0.2, `rgba(255, 160, 20, ${alpha * 0.8})`); + gradient.addColorStop(1, 'rgba(255, 50, 0, 0)'); + + // Draw the ember + effectCtx.fillStyle = gradient; + effectCtx.beginPath(); + effectCtx.arc(ember.x, ember.y, ember.size * 2, 0, Math.PI * 2); + effectCtx.fill(); + } + + // Reset composite operation + effectCtx.globalCompositeOperation = 'source-over'; + } + } + + /*** BLOOD EFFECT ***/ + + function initBloodEffect() { + // Create blood effect object + currentBloodEffect = { + // Blood parameters + gravity: 0.15, + viscosity: 0.92, + particleCount: 100, + particleSize: 3, + sprayForce: 10, + splatterSize: 5, + spread: 0.3, + colorVariation: 15, + enableDrips: true, + enablePooling: true, + particleColor: 'red', + customColor: '#ff0000', + + // Particles and splatters + particles: [], + splatters: [], + pools: [], + drips: [], + + // Mouse interactions + mouseDown: false, + mouseX: 0, + mouseY: 0, + lastMouseX: 0, + lastMouseY: 0, + + // Auto spray settings + autoSpray: true, + autoSprayTimer: 0, + autoSprayInterval: 30 + }; + + // Set up event listeners for canvas interactions + setupBloodCanvasEvents(); + + // Event listeners for blood parameters + document.getElementById('bloodGravity').addEventListener('input', function() { + currentBloodEffect.gravity = parseFloat(this.value); + }); + + document.getElementById('bloodViscosity').addEventListener('input', function() { + currentBloodEffect.viscosity = parseFloat(this.value); + }); + + document.getElementById('bloodParticleCount').addEventListener('input', function() { + currentBloodEffect.particleCount = parseInt(this.value); + }); + + document.getElementById('bloodParticleSize').addEventListener('input', function() { + currentBloodEffect.particleSize = parseInt(this.value); + }); + + document.getElementById('bloodSprayForce').addEventListener('input', function() { + currentBloodEffect.sprayForce = parseInt(this.value); + }); + + document.getElementById('bloodSpread').addEventListener('input', function() { + currentBloodEffect.spread = parseFloat(this.value); + }); + + document.getElementById('dripsToggle').addEventListener('change', function() { + currentBloodEffect.enableDrips = this.checked; + }); + + document.getElementById('poolingToggle').addEventListener('change', function() { + currentBloodEffect.enablePooling = this.checked; + }); + + document.getElementById('bloodColor').addEventListener('change', function() { + currentBloodEffect.particleColor = this.value; + }); + + document.getElementById('customBloodColor').addEventListener('input', function() { + currentBloodEffect.customColor = this.value; + }); + } + + function setupBloodCanvasEvents() { + // Mouse events for blood effect + effectCanvas.addEventListener('mousedown', (e) => { + if (currentEffect !== 'blood') return; + + currentBloodEffect.mouseDown = true; + updateBloodMousePosition(e); + currentBloodEffect.lastMouseX = currentBloodEffect.mouseX; + currentBloodEffect.lastMouseY = currentBloodEffect.mouseY; + createBloodSpray(currentBloodEffect.mouseX, currentBloodEffect.mouseY, 0, 0); + }); + + effectCanvas.addEventListener('mousemove', (e) => { + if (currentEffect !== 'blood') return; + + if (currentBloodEffect.mouseDown) { + const lastX = currentBloodEffect.mouseX; + const lastY = currentBloodEffect.mouseY; + updateBloodMousePosition(e); + + // Calculate velocity for direction + const velX = currentBloodEffect.mouseX - lastX; + const velY = currentBloodEffect.mouseY - lastY; + + // Create spray based on movement + if (Math.abs(velX) > 0.5 || Math.abs(velY) > 0.5) { + createBloodSpray(currentBloodEffect.mouseX, currentBloodEffect.mouseY, velX, velY); + } + } + }); + + effectCanvas.addEventListener('mouseup', () => { + if (currentEffect !== 'blood') return; + currentBloodEffect.mouseDown = false; + }); + + effectCanvas.addEventListener('mouseleave', () => { + if (currentEffect !== 'blood') return; + currentBloodEffect.mouseDown = false; + }); + } + + function updateBloodMousePosition(e) { + const rect = effectCanvas.getBoundingClientRect(); + currentBloodEffect.mouseX = e.clientX - rect.left; + currentBloodEffect.mouseY = e.clientY - rect.top; + } + + function getBloodColor() { + const colorType = currentBloodEffect.particleColor; + + switch(colorType) { + case 'red': + return [120 + Math.floor(Math.random() * currentBloodEffect.colorVariation), + 0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.4)), + 0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.2))]; + case 'green': + return [0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.2)), + 120 + Math.floor(Math.random() * currentBloodEffect.colorVariation), + 0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.4))]; + case 'blue': + return [0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.2)), + 0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.4)), + 120 + Math.floor(Math.random() * currentBloodEffect.colorVariation)]; + case 'black': + return [0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.5)), + 0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.5)), + 0 + Math.floor(Math.random() * (currentBloodEffect.colorVariation * 0.5))]; + case 'custom': + // Parse custom color + const hex = currentBloodEffect.customColor.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + // Add some variation + return [Math.max(0, Math.min(255, r + (Math.random() * 2 - 1) * currentBloodEffect.colorVariation)), + Math.max(0, Math.min(255, g + (Math.random() * 2 - 1) * currentBloodEffect.colorVariation)), + Math.max(0, Math.min(255, b + (Math.random() * 2 - 1) * currentBloodEffect.colorVariation))]; + } + } + + function createBloodSpray(x, y, velX, velY) { + // Direction influence from velocity + const dirX = velX === 0 ? 0 : Math.sign(velX); + const dirY = velY === 0 ? 0 : Math.sign(velY); + const speed = Math.sqrt(velX * velX + velY * velY); + + for (let i = 0; i < currentBloodEffect.particleCount; i++) { + // Calculate random spray direction with influence from movement + const angle = Math.random() * Math.PI * 2; + const force = currentBloodEffect.sprayForce * (0.5 + Math.random() * 0.5); + + // Base velocities with randomness + let vx = Math.cos(angle) * force * currentBloodEffect.spread; + let vy = Math.sin(angle) * force * currentBloodEffect.spread; + + // Add influence from mouse movement + if (speed > 1) { + vx += dirX * force * (1 - currentBloodEffect.spread) * Math.random(); + vy += dirY * force * (1 - currentBloodEffect.spread) * Math.random(); + } + + // Random size variation + const size = currentBloodEffect.particleSize * (0.5 + Math.random()); + + // Get color + const [r, g, b] = getBloodColor(); + const color = `rgb(${r}, ${g}, ${b})`; + + // Add blood particle + currentBloodEffect.particles.push({ + x: x + (Math.random() - 0.5) * 5, + y: y + (Math.random() - 0.5) * 5, + vx: vx, + vy: vy, + size: size, + color: color, + gravity: currentBloodEffect.gravity * (0.8 + Math.random() * 0.4), + life: 1.0, // Life percentage (1.0 = full life, 0.0 = dead) + decay: 0.01 + Math.random() * 0.02 + }); + } + } + + function createBloodSplatter(x, y, size, color) { + // Create a blood splatter at impact location + const splatterSize = size * currentBloodEffect.splatterSize; + + currentBloodEffect.splatters.push({ + x: x, + y: y, + size: splatterSize, + color: color, + // Random shapes for splatters + shape: Math.floor(Math.random() * 3), + angle: Math.random() * Math.PI * 2, + // Stretch factor for directional impact + stretchX: 0.5 + Math.random(), + stretchY: 0.5 + Math.random(), + // How much it has dried/darkened (0 = fresh, 1 = dried) + dried: 0 + }); + + // Possibly create a drip + if (currentBloodEffect.enableDrips && Math.random() < 0.3) { + createBloodDrip(x, y, color); + } + + // Possibly create a pool below the splatter + if (currentBloodEffect.enablePooling && y > effectCanvas.height * 0.7 && Math.random() < 0.5) { + createBloodPool(x, y, splatterSize * 1.5, color); + } + } + + function createBloodDrip(x, y, color) { + const length = 10 + Math.random() * 30; + const width = 2 + Math.random() * 4; + + currentBloodEffect.drips.push({ + x: x, + y: y, + targetY: y + length, + width: width, + color: color, + progress: 0, + speed: 0.005 + Math.random() * 0.01, // How fast it drips down + dried: 0 + }); + } + + function createBloodPool(x, y, size, color) { + currentBloodEffect.pools.push({ + x: x, + y: y, + currentSize: 0, + targetSize: size * (1 + Math.random()), + growSpeed: 0.1 + Math.random() * 0.2, + color: color, + dried: 0 + }); + } + + function adjustColorForDrying(color, driedAmount) { + // Extract RGB components + const rgbMatch = color.match(/\d+/g); + if (!rgbMatch || rgbMatch.length < 3) return color; + + const r = parseInt(rgbMatch[0]); + const g = parseInt(rgbMatch[1]); + const b = parseInt(rgbMatch[2]); + + // Calculate dried color (darker, more brown) + const driedR = Math.max(0, Math.floor(r - (driedAmount * 80))); + const driedG = Math.max(0, Math.floor(g - (driedAmount * 10))); + const driedB = Math.max(0, Math.floor(b - (driedAmount * 10))); + + return `rgb(${driedR}, ${driedG}, ${driedB})`; + } + + function updateBloodEffect() { + if (!currentBloodEffect) return; + + // Check if we should auto-spray + if (currentBloodEffect.autoSpray) { + currentBloodEffect.autoSprayTimer++; + + if (currentBloodEffect.autoSprayTimer >= currentBloodEffect.autoSprayInterval) { + // Create a random spray + const x = Math.random() * effectCanvas.width; + const y = Math.random() * (effectCanvas.height / 2); + const velX = (Math.random() - 0.5) * 20; + const velY = Math.random() * 10; + + createBloodSpray(x, y, velX, velY); + + // Reset timer + currentBloodEffect.autoSprayTimer = 0; + + // Randomize next interval + currentBloodEffect.autoSprayInterval = 10 + Math.floor(Math.random() * 40); + } + } + + // Update particles + for (let i = currentBloodEffect.particles.length - 1; i >= 0; i--) { + const particle = currentBloodEffect.particles[i]; + + // Apply physics + particle.vy += particle.gravity; + particle.vx *= currentBloodEffect.viscosity; + particle.vy *= currentBloodEffect.viscosity; + + // Update position + particle.x += particle.vx; + particle.y += particle.vy; + + // Check for collisions with bottom or sides + if (particle.y > effectCanvas.height - particle.size) { + // Bottom collision - create splatter + createBloodSplatter(particle.x, effectCanvas.height, particle.size, particle.color); + currentBloodEffect.particles.splice(i, 1); + } else if (particle.x < 0 || particle.x > effectCanvas.width) { + // Side collision - create splatter + const x = particle.x < 0 ? 0 : effectCanvas.width; + createBloodSplatter(x, particle.y, particle.size, particle.color); + currentBloodEffect.particles.splice(i, 1); + } else { + // Decay life + particle.life -= particle.decay; + if (particle.life <= 0) { + currentBloodEffect.particles.splice(i, 1); + } + } + } + + // Update drips + for (let i = currentBloodEffect.drips.length - 1; i >= 0; i--) { + const drip = currentBloodEffect.drips[i]; + + // Grow drip down + drip.progress += drip.speed; + if (drip.progress >= 1) { + // When drip reaches target, it stops + drip.progress = 1; + // Slowly dry + drip.dried += 0.001; + + // Remove very dried drips + if (drip.dried > 0.7) { + currentBloodEffect.drips.splice(i, 1); + } + } + } + + // Update pools + for (let i = 0; i < currentBloodEffect.pools.length; i++) { + const pool = currentBloodEffect.pools[i]; + + // Grow pool + if (pool.currentSize < pool.targetSize) { + pool.currentSize += pool.growSpeed; + } else { + // Slowly dry + pool.dried += 0.0005; + } + } + + // Update splatters + for (let i = 0; i < currentBloodEffect.splatters.length; i++) { + // Slowly dry + currentBloodEffect.splatters[i].dried += 0.0002; + } + + // Render blood + renderBlood(); + } + + function renderBlood() { + // Draw particles + for (const particle of currentBloodEffect.particles) { + effectCtx.beginPath(); + effectCtx.fillStyle = particle.color; + effectCtx.globalAlpha = particle.life; + effectCtx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + effectCtx.fill(); + } + + // Draw splatters + for (const splatter of currentBloodEffect.splatters) { + // Darker color as it dries + const color = adjustColorForDrying(splatter.color, splatter.dried); + effectCtx.fillStyle = color; + effectCtx.globalAlpha = 1; + + effectCtx.save(); + effectCtx.translate(splatter.x, splatter.y); + effectCtx.rotate(splatter.angle); + effectCtx.scale(splatter.stretchX, splatter.stretchY); + + // Different splatter shapes + switch (splatter.shape) { + case 0: + // Circular splatter + effectCtx.beginPath(); + effectCtx.arc(0, 0, splatter.size, 0, Math.PI * 2); + effectCtx.fill(); + break; + case 1: + // Star-like splatter + effectCtx.beginPath(); + const points = 5 + Math.floor(Math.random() * 4); + for (let i = 0; i < points * 2; i++) { + const radius = i % 2 === 0 ? splatter.size : splatter.size * 0.5; + const angle = (i * Math.PI) / points; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + if (i === 0) effectCtx.moveTo(x, y); + else effectCtx.lineTo(x, y); + } + effectCtx.closePath(); + effectCtx.fill(); + break; + case 2: + // Irregular blob + effectCtx.beginPath(); + const segments = 8 + Math.floor(Math.random() * 5); + for (let i = 0; i <= segments; i++) { + const angle = (i / segments) * Math.PI * 2; + const radius = splatter.size * (0.7 + Math.random() * 0.6); + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + if (i === 0) effectCtx.moveTo(x, y); + else effectCtx.bezierCurveTo( + x - Math.random() * 10, y - Math.random() * 10, + x + Math.random() * 10, y + Math.random() * 10, + x, y + ); + } + effectCtx.closePath(); + effectCtx.fill(); + break; + } + + effectCtx.restore(); + } + + // Draw drips + for (const drip of currentBloodEffect.drips) { + const color = adjustColorForDrying(drip.color, drip.dried); + effectCtx.fillStyle = color; + effectCtx.globalAlpha = 1; + + // Calculate current height based on progress + const currentY = drip.y + (drip.targetY - drip.y) * drip.progress; + + // Draw drip as elongated teardrop + effectCtx.beginPath(); + effectCtx.moveTo(drip.x - drip.width/2, drip.y); + effectCtx.bezierCurveTo( + drip.x - drip.width, drip.y + (currentY - drip.y) * 0.3, + drip.x - drip.width/3, currentY - drip.width, + drip.x, currentY + ); + effectCtx.bezierCurveTo( + drip.x + drip.width/3, currentY - drip.width, + drip.x + drip.width, drip.y + (currentY - drip.y) * 0.3, + drip.x + drip.width/2, drip.y + ); + effectCtx.closePath(); + effectCtx.fill(); + } + + // Draw pools + for (const pool of currentBloodEffect.pools) { + const color = adjustColorForDrying(pool.color, pool.dried); + effectCtx.fillStyle = color; + effectCtx.globalAlpha = 1; + + // Draw pool as irregular ellipse + effectCtx.beginPath(); + const segments = 12; + for (let i = 0; i <= segments; i++) { + const angle = (i / segments) * Math.PI * 2; + // Make pool wider than tall + const radiusX = pool.currentSize * (1.2 + Math.sin(angle * 3) * 0.2); + const radiusY = pool.currentSize * (0.6 + Math.cos(angle * 2) * 0.1); + const x = pool.x + Math.cos(angle) * radiusX; + const y = pool.y + Math.sin(angle) * radiusY; + if (i === 0) effectCtx.moveTo(x, y); + else effectCtx.bezierCurveTo( + x - Math.random() * 5, y - Math.random() * 2, + x + Math.random() * 5, y + Math.random() * 2, + x, y + ); + } + effectCtx.closePath(); + effectCtx.fill(); + } + + // Reset alpha + effectCtx.globalAlpha = 1; + } + + /*** SMOKE EFFECT ***/ + + function initSmokeEffect() { + // Initialize smoke effect settings and particles + currentSmokeEffect = { + particles: [], + opacity: 0.6, + riseSpeed: 1.5, + density: 5, + turbulence: 3, + particleSize: 20, + smokeColor: 'white', + customColor: '#cccccc' + }; + + // Event listeners for smoke parameters + document.getElementById('smokeOpacity').addEventListener('input', function() { + currentSmokeEffect.opacity = parseFloat(this.value); + }); + + document.getElementById('smokeRiseSpeed').addEventListener('input', function() { + currentSmokeEffect.riseSpeed = parseFloat(this.value); + }); + + document.getElementById('smokeDensity').addEventListener('input', function() { + currentSmokeEffect.density = parseInt(this.value); + }); + + document.getElementById('smokeTurbulence').addEventListener('input', function() { + currentSmokeEffect.turbulence = parseInt(this.value); + }); + + document.getElementById('smokeSize').addEventListener('input', function() { + currentSmokeEffect.particleSize = parseInt(this.value); + }); + + document.getElementById('smokeColor').addEventListener('change', function() { + currentSmokeEffect.smokeColor = this.value; + }); + + document.getElementById('customSmokeColor').addEventListener('input', function() { + currentSmokeEffect.customColor = this.value; + }); + } + + function getSmokeColor() { + switch (currentSmokeEffect.smokeColor) { + case 'white': + return 'rgba(255, 255, 255, '; + case 'black': + return 'rgba(40, 40, 40, '; + case 'gray': + return 'rgba(150, 150, 150, '; + case 'custom': + // Parse custom color + const hex = currentSmokeEffect.customColor.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, `; + } + } + + function createSmokeParticle() { + const width = effectCanvas.width; + const height = effectCanvas.height; + + // Create new smoke particle + currentSmokeEffect.particles.push({ + x: width / 2 + (Math.random() - 0.5) * width * 0.5, + y: height - Math.random() * 20, + size: Math.random() * currentSmokeEffect.particleSize + 10, + speedX: (Math.random() - 0.5) * 0.5, + speedY: -Math.random() * currentSmokeEffect.riseSpeed - 0.5, + opacity: Math.random() * currentSmokeEffect.opacity, + life: 1.0, + decay: 0.003 + Math.random() * 0.005 + }); + } + + function updateSmokeEffect() { + if (!currentSmokeEffect) return; + + // Add new smoke particles based on density + for (let i = 0; i < currentSmokeEffect.density; i++) { + if (Math.random() < 0.1) { + createSmokeParticle(); + } + } + + // Update existing particles + for (let i = currentSmokeEffect.particles.length - 1; i >= 0; i--) { + const particle = currentSmokeEffect.particles[i]; + + // Move the particle + particle.x += particle.speedX; + particle.y += particle.speedY; + + // Add some turbulence + particle.speedX += (Math.random() - 0.5) * 0.01 * currentSmokeEffect.turbulence; + particle.speedY -= Math.random() * 0.01; // Acceleration upwards + + // Increase size as it rises + particle.size += 0.1; + + // Decay life + particle.life -= particle.decay; + + // Update opacity based on life + particle.opacity = Math.min(currentSmokeEffect.opacity, particle.life * currentSmokeEffect.opacity); + + // Remove dead particles + if (particle.life <= 0 || particle.y < -particle.size) { + currentSmokeEffect.particles.splice(i, 1); + } + } + + // Render smoke + renderSmoke(); + } + + function renderSmoke() { + const particles = currentSmokeEffect.particles; + const baseColor = getSmokeColor(); + + // Sort particles by size (for proper layering - larger particles behind smaller ones) + particles.sort((a, b) => b.size - a.size); + + // Draw smoke particles + for (const particle of particles) { + effectCtx.beginPath(); + + // Create radial gradient for each particle + const gradient = effectCtx.createRadialGradient( + particle.x, particle.y, 0, + particle.x, particle.y, particle.size + ); + + // Set gradient colors + gradient.addColorStop(0, baseColor + particle.opacity + ')'); + gradient.addColorStop(1, baseColor + '0)'); + + effectCtx.fillStyle = gradient; + effectCtx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + effectCtx.fill(); + } + } + + /*** EXPLOSION EFFECT ***/ + + function initExplosionEffect() { + currentExplosionEffect = { + particles: [], + shockwaves: [], + smokeParticles: [], + size: 5, + speed: 5, + particleCount: 200, + colorScheme: 'fire', + includeSmoke: true, + includeShockwave: true, + loop: false, + // Animation state + phase: 'initial', // initial, explosion, fade, reset + timer: 0, + explosionDuration: 60, // frames + fadeDuration: 30, // frames + resetDuration: 30 // frames + }; + + // Event listeners for explosion parameters + document.getElementById('explosionSize').addEventListener('input', function() { + currentExplosionEffect.size = parseInt(this.value); + }); + + document.getElementById('explosionSpeed').addEventListener('input', function() { + currentExplosionEffect.speed = parseInt(this.value); + }); + + document.getElementById('explosionParticles').addEventListener('input', function() { + currentExplosionEffect.particleCount = parseInt(this.value); + }); + + document.getElementById('explosionColor').addEventListener('change', function() { + currentExplosionEffect.colorScheme = this.value; + }); + + document.getElementById('explosionSmoke').addEventListener('change', function() { + currentExplosionEffect.includeSmoke = this.checked; + }); + + document.getElementById('explosionShockwave').addEventListener('change', function() { + currentExplosionEffect.includeShockwave = this.checked; + }); + + document.getElementById('explosionLoop').addEventListener('change', function() { + currentExplosionEffect.loop = this.checked; + }); + + // Trigger initial explosion + triggerExplosion(); + } + + function getExplosionColor(position, scheme) { + // Position from 0 (center/hot) to 1 (edge/cool) + switch (scheme) { + case 'fire': + if (position < 0.2) return `rgba(255, 255, 255, ${1 - position * 5})`; + if (position < 0.5) return `rgba(255, 255, 0, ${1 - position})`; + return `rgba(255, ${Math.floor(140 * (1 - position))}, 0, ${1 - position})`; + + case 'energy': + if (position < 0.2) return `rgba(255, 255, 255, ${1 - position * 5})`; + if (position < 0.5) return `rgba(200, 230, 255, ${1 - position})`; + return `rgba(0, ${Math.floor(120 * (1 - position))}, 255, ${1 - position})`; + + case 'toxic': + if (position < 0.2) return `rgba(230, 255, 150, ${1 - position * 5})`; + if (position < 0.5) return `rgba(160, 255, 0, ${1 - position})`; + return `rgba(${Math.floor(100 * (1 - position))}, 200, 0, ${1 - position})`; + + case 'magic': + if (position < 0.2) return `rgba(255, 200, 255, ${1 - position * 5})`; + if (position < 0.5) return `rgba(220, 120, 255, ${1 - position})`; + return `rgba(180, 0, ${Math.floor(200 * (1 - position))}, ${1 - position})`; + } + } + + function triggerExplosion() { + // Clear existing particles + currentExplosionEffect.particles = []; + currentExplosionEffect.shockwaves = []; + currentExplosionEffect.smokeParticles = []; + + // Reset state + currentExplosionEffect.phase = 'explosion'; + currentExplosionEffect.timer = 0; + + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + const size = currentExplosionEffect.size; + + // Create explosion particles + for (let i = 0; i < currentExplosionEffect.particleCount; i++) { + const angle = Math.random() * Math.PI * 2; + const distance = Math.random() * 10 * size; + const speed = (0.5 + Math.random() * 1.5) * currentExplosionEffect.speed; + + currentExplosionEffect.particles.push({ + x: centerX, + y: centerY, + size: 1 + Math.random() * size * 2, + speedX: Math.cos(angle) * speed, + speedY: Math.sin(angle) * speed, + distance: 0, + maxDistance: distance, + angle: angle, + life: 1.0, + decay: 0.01 + Math.random() * 0.02 + }); + } + + // Create shockwave if enabled + if (currentExplosionEffect.includeShockwave) { + currentExplosionEffect.shockwaves.push({ + x: centerX, + y: centerY, + size: 10, + targetSize: 100 * size, + growth: 2 * currentExplosionEffect.speed, + opacity: 1 + }); + } + + // Create initial smoke particles if enabled + if (currentExplosionEffect.includeSmoke) { + for (let i = 0; i < currentExplosionEffect.particleCount / 2; i++) { + const angle = Math.random() * Math.PI * 2; + const distance = Math.random() * 20 * size; + const speed = (0.2 + Math.random() * 0.8) * currentExplosionEffect.speed; + + currentExplosionEffect.smokeParticles.push({ + x: centerX, + y: centerY, + size: 5 + Math.random() * size * 3, + speedX: Math.cos(angle) * speed * 0.5, + speedY: Math.sin(angle) * speed * 0.5, + opacity: 0.7, + life: 1.0, + decay: 0.005 + Math.random() * 0.01 + }); + } + } + } + + function updateExplosionEffect() { + if (!currentExplosionEffect) return; + + // Update based on current phase + currentExplosionEffect.timer++; + + switch (currentExplosionEffect.phase) { + case 'initial': + // Waiting for trigger + if (Math.random() < 0.01) { + triggerExplosion(); + } + break; + + case 'explosion': + // Update explosion particles + for (let i = currentExplosionEffect.particles.length - 1; i >= 0; i--) { + const particle = currentExplosionEffect.particles[i]; + + // Update position + particle.x += particle.speedX; + particle.y += particle.speedY; + + // Calculate distance from center + const dx = particle.x - effectCanvas.width / 2; + const dy = particle.y - effectCanvas.height / 2; + particle.distance = Math.sqrt(dx * dx + dy * dy); + + // Apply deceleration as particles move outward + const deceleration = 0.97; + particle.speedX *= deceleration; + particle.speedY *= deceleration; + + // Apply gravity + particle.speedY += 0.05; + + // Decay life + particle.life -= particle.decay; + + // Remove dead particles + if (particle.life <= 0) { + currentExplosionEffect.particles.splice(i, 1); + } + } + + // Update shockwaves + for (let i = currentExplosionEffect.shockwaves.length - 1; i >= 0; i--) { + const shockwave = currentExplosionEffect.shockwaves[i]; + + // Grow the shockwave + shockwave.size += shockwave.growth; + + // Fade out as it grows + shockwave.opacity = Math.max(0, 1 - shockwave.size / shockwave.targetSize); + + // Remove completed shockwaves + if (shockwave.size >= shockwave.targetSize) { + currentExplosionEffect.shockwaves.splice(i, 1); + } + } + + // Update smoke particles + for (let i = currentExplosionEffect.smokeParticles.length - 1; i >= 0; i--) { + const smoke = currentExplosionEffect.smokeParticles[i]; + + // Update position + smoke.x += smoke.speedX; + smoke.y += smoke.speedY; + + // Slow down + smoke.speedX *= 0.98; + smoke.speedY *= 0.98; + + // Grow in size + smoke.size += 0.2; + + // Rise upward + smoke.speedY -= 0.01; + + // Add turbulence + smoke.speedX += (Math.random() - 0.5) * 0.1; + + // Decay life + smoke.life -= smoke.decay; + smoke.opacity = Math.min(0.7, smoke.life * 0.7); + + // Remove dead smoke + if (smoke.life <= 0) { + currentExplosionEffect.smokeParticles.splice(i, 1); + } + } + + // Check if explosion phase is complete + if (currentExplosionEffect.timer >= currentExplosionEffect.explosionDuration) { + currentExplosionEffect.phase = 'fade'; + currentExplosionEffect.timer = 0; + } + break; + + case 'fade': + // Continue updating remaining particles + updateExplosionParticles(); + + // Check if fade phase is complete + if (currentExplosionEffect.timer >= currentExplosionEffect.fadeDuration) { + currentExplosionEffect.phase = 'reset'; + currentExplosionEffect.timer = 0; + } + break; + + case 'reset': + // Brief pause before next explosion (if looping) + if (currentExplosionEffect.timer >= currentExplosionEffect.resetDuration) { + if (currentExplosionEffect.loop) { + triggerExplosion(); + } else { + currentExplosionEffect.phase = 'initial'; + } + } + break; + } + + // Render explosion + renderExplosion(); + } + + function updateExplosionParticles() { + // Update remaining particles + for (let i = currentExplosionEffect.particles.length - 1; i >= 0; i--) { + const particle = currentExplosionEffect.particles[i]; + + // Update position + particle.x += particle.speedX; + particle.y += particle.speedY; + + // Apply gravity + particle.speedY += 0.05; + + // Decay life faster during fade + particle.life -= particle.decay * 1.5; + + // Remove dead particles + if (particle.life <= 0) { + currentExplosionEffect.particles.splice(i, 1); + } + } + + // Update smoke particles + for (let i = currentExplosionEffect.smokeParticles.length - 1; i >= 0; i--) { + const smoke = currentExplosionEffect.smokeParticles[i]; + + smoke.x += smoke.speedX; + smoke.y += smoke.speedY; + smoke.speedY -= 0.01; // Rise + smoke.life -= smoke.decay; + smoke.opacity = Math.min(0.7, smoke.life * 0.7); + + if (smoke.life <= 0) { + currentExplosionEffect.smokeParticles.splice(i, 1); + } + } + } + + function renderExplosion() { + // Draw smoke first (behind everything) + if (currentExplosionEffect.includeSmoke) { + for (const smoke of currentExplosionEffect.smokeParticles) { + effectCtx.beginPath(); + const gradient = effectCtx.createRadialGradient( + smoke.x, smoke.y, 0, + smoke.x, smoke.y, smoke.size + ); + + const alpha = smoke.opacity; + gradient.addColorStop(0, `rgba(100, 100, 100, ${alpha})`); + gradient.addColorStop(1, `rgba(70, 70, 70, 0)`); + + effectCtx.fillStyle = gradient; + effectCtx.arc(smoke.x, smoke.y, smoke.size, 0, Math.PI * 2); + effectCtx.fill(); + } + } + + // Draw shockwaves + for (const shockwave of currentExplosionEffect.shockwaves) { + effectCtx.beginPath(); + effectCtx.strokeStyle = `rgba(255, 255, 255, ${shockwave.opacity})`; + effectCtx.lineWidth = 3; + effectCtx.arc(shockwave.x, shockwave.y, shockwave.size, 0, Math.PI * 2); + effectCtx.stroke(); + } + + // Draw explosion particles + effectCtx.globalCompositeOperation = 'lighter'; + + for (const particle of currentExplosionEffect.particles) { + // Position from center determines color + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + + const dx = particle.x - centerX; + const dy = particle.y - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + const maxDistance = 100 * currentExplosionEffect.size; + const position = Math.min(1, distance / maxDistance); + + // Get color based on scheme and position + const color = getExplosionColor(position, currentExplosionEffect.colorScheme); + + // Draw particle + effectCtx.beginPath(); + effectCtx.fillStyle = color; + effectCtx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + effectCtx.fill(); + } + + // Reset composite operation + effectCtx.globalCompositeOperation = 'source-over'; + } + + /*** MAGIC EFFECT ***/ + + function initMagicEffect() { + currentMagicEffect = { + particles: [], + type: 'sparkles', + intensity: 5, + speed: 5, + colorScheme: 'arcane', + glowIntensity: 5, + pulseEnabled: true, + pulsePhase: 0 + }; + + // Event listeners for magic effect parameters + document.getElementById('magicType').addEventListener('change', function() { + currentMagicEffect.type = this.value; + initializeMagicParticles(); + }); + + document.getElementById('magicIntensity').addEventListener('input', function() { + currentMagicEffect.intensity = parseInt(this.value); + }); + + document.getElementById('magicSpeed').addEventListener('input', function() { + currentMagicEffect.speed = parseInt(this.value); + }); + + document.getElementById('magicColor').addEventListener('change', function() { + currentMagicEffect.colorScheme = this.value; + }); + + document.getElementById('magicGlow').addEventListener('input', function() { + currentMagicEffect.glowIntensity = parseInt(this.value); + }); + + document.getElementById('magicPulse').addEventListener('change', function() { + currentMagicEffect.pulseEnabled = this.checked; + }); + + // Initialize particles based on selected type + initializeMagicParticles(); + } + + function initializeMagicParticles() { + if (!currentMagicEffect) return; + + // Clear existing particles + currentMagicEffect.particles = []; + + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + const intensity = currentMagicEffect.intensity; + + // Create particles based on effect type + switch (currentMagicEffect.type) { + case 'sparkles': + // Create random sparkle particles + for (let i = 0; i < 20 * intensity; i++) { + currentMagicEffect.particles.push({ + x: Math.random() * width, + y: Math.random() * height, + size: 0.5 + Math.random() * 2.5, + speedX: (Math.random() - 0.5) * 2, + speedY: (Math.random() - 0.5) * 2, + alpha: Math.random(), + alphaSpeed: 0.01 + Math.random() * 0.05, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.1 + }); + } + break; + + case 'aura': + // Create circular aura + const auraRadius = Math.min(width, height) * 0.3; + for (let i = 0; i < 30 * intensity; i++) { + const angle = Math.random() * Math.PI * 2; + const distance = auraRadius * (0.8 + Math.random() * 0.4); + + currentMagicEffect.particles.push({ + x: centerX + Math.cos(angle) * distance, + y: centerY + Math.sin(angle) * distance, + angle: angle, + distance: distance, + baseDistance: distance, + size: 2 + Math.random() * 4, + alpha: 0.3 + Math.random() * 0.7, + alphaSpeed: 0.01 + Math.random() * 0.03, + rotationSpeed: 0.002 + Math.random() * 0.01 * (Math.random() > 0.5 ? 1 : -1) + }); + } + break; + + case 'bolts': + // Create energy bolts + for (let i = 0; i < 8 * intensity; i++) { + const bolt = { + segments: [], + alpha: 0.7 + Math.random() * 0.3, + alphaSpeed: 0.02 + Math.random() * 0.03, + lifespan: 20 + Math.floor(Math.random() * 30), + age: 0 + }; + + // Create bolt segments + const startAngle = Math.random() * Math.PI * 2; + const length = 30 + Math.random() * 40 * (intensity / 5); + + bolt.segments.push({ + x: centerX, + y: centerY, + angle: startAngle + }); + + let currentX = centerX; + let currentY = centerY; + let currentAngle = startAngle; + + const numSegments = 5 + Math.floor(Math.random() * 5); + for (let j = 0; j < numSegments; j++) { + // Add some variation to the angle + currentAngle += (Math.random() - 0.5) * 1.5; + + // Calculate new position + const segmentLength = length / numSegments; + currentX += Math.cos(currentAngle) * segmentLength; + currentY += Math.sin(currentAngle) * segmentLength; + + bolt.segments.push({ + x: currentX, + y: currentY, + angle: currentAngle + }); + } + + currentMagicEffect.particles.push(bolt); + } + break; + + case 'portal': + // Create portal effect + const portalRadius = Math.min(width, height) * 0.25; + + // Create portal ring + for (let i = 0; i < 60; i++) { + const angle = (i / 60) * Math.PI * 2; + currentMagicEffect.particles.push({ + type: 'ring', + angle: angle, + x: centerX + Math.cos(angle) * portalRadius, + y: centerY + Math.sin(angle) * portalRadius, + radius: portalRadius, + size: 3 + Math.random() * 2, + alpha: 0.7 + Math.random() * 0.3, + rotationSpeed: 0.005 * (Math.random() > 0.5 ? 1 : -1) + }); + } + + // Create energy particles + for (let i = 0; i < 40 * intensity; i++) { + const angle = Math.random() * Math.PI * 2; + const distance = portalRadius * Math.random(); + + currentMagicEffect.particles.push({ + type: 'energy', + x: centerX + Math.cos(angle) * distance, + y: centerY + Math.sin(angle) * distance, + angle: Math.random() * Math.PI * 2, + speed: 0.2 + Math.random() * 2, + size: 1 + Math.random() * 3, + alpha: 0.5 + Math.random() * 0.5, + alphaSpeed: 0.02 + Math.random() * 0.05 + }); + } + break; + } + } + + function getMagicColor(scheme, position = 0) { + // Position from 0 to 1 controls gradient within a color scheme + switch (scheme) { + case 'arcane': + // Purple/pink hues + if (position < 0.3) return [180, 100, 255]; + if (position < 0.6) return [150, 50, 220]; + return [100, 0, 180]; + + case 'nature': + // Green/earthy tones + if (position < 0.3) return [200, 255, 150]; + if (position < 0.6) return [100, 220, 50]; + return [50, 180, 0]; + + case 'fire': + // Red/orange tones + if (position < 0.3) return [255, 200, 100]; + if (position < 0.6) return [255, 150, 50]; + return [220, 50, 0]; + + case 'ice': + // Blue/cyan tones + if (position < 0.3) return [200, 240, 255]; + if (position < 0.6) return [150, 200, 255]; + return [50, 150, 220]; + + case 'holy': + // Gold/white tones + if (position < 0.3) return [255, 255, 220]; + if (position < 0.6) return [255, 240, 150]; + return [220, 180, 50]; + + case 'void': + // Dark purple/black tones + if (position < 0.3) return [100, 30, 150]; + if (position < 0.6) return [70, 10, 100]; + return [40, 0, 60]; + } + } + + function updateMagicEffect() { + if (!currentMagicEffect) return; + + // Update pulse phase + if (currentMagicEffect.pulseEnabled) { + currentMagicEffect.pulsePhase += 0.05; + if (currentMagicEffect.pulsePhase > Math.PI * 2) { + currentMagicEffect.pulsePhase -= Math.PI * 2; + } + } + + const pulseValue = currentMagicEffect.pulseEnabled ? + 0.7 + Math.sin(currentMagicEffect.pulsePhase) * 0.3 : 1; + + const speedFactor = currentMagicEffect.speed / 5; + + // Update based on effect type + switch (currentMagicEffect.type) { + case 'sparkles': + updateSparkles(pulseValue, speedFactor); + break; + case 'aura': + updateAura(pulseValue, speedFactor); + break; + case 'bolts': + updateBolts(pulseValue, speedFactor); + break; + case 'portal': + updatePortal(pulseValue, speedFactor); + break; + } + + // Render magic effect + renderMagicEffect(pulseValue); + } + + function updateSparkles(pulseValue, speedFactor) { + const width = effectCanvas.width; + const height = effectCanvas.height; + + for (let i = 0; i < currentMagicEffect.particles.length; i++) { + const particle = currentMagicEffect.particles[i]; + + // Update position + particle.x += particle.speedX * speedFactor; + particle.y += particle.speedY * speedFactor; + + // Update rotation + particle.rotation += particle.rotationSpeed * speedFactor; + + // Pulsate alpha + particle.alpha += particle.alphaSpeed * (Math.random() > 0.5 ? 1 : -1); + particle.alpha = Math.max(0.1, Math.min(1, particle.alpha)); + + // Wrap around screen + if (particle.x < 0) particle.x = width; + if (particle.x > width) particle.x = 0; + if (particle.y < 0) particle.y = height; + if (particle.y > height) particle.y = 0; + } + + // Add new particles occasionally + if (Math.random() < 0.1 * pulseValue) { + currentMagicEffect.particles.push({ + x: Math.random() * width, + y: Math.random() * height, + size: 0.5 + Math.random() * 2.5, + speedX: (Math.random() - 0.5) * 2, + speedY: (Math.random() - 0.5) * 2, + alpha: Math.random(), + alphaSpeed: 0.01 + Math.random() * 0.05, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.1 + }); + } + } + + function updateAura(pulseValue, speedFactor) { + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + + for (let i = 0; i < currentMagicEffect.particles.length; i++) { + const particle = currentMagicEffect.particles[i]; + + // Rotate around center + particle.angle += particle.rotationSpeed * speedFactor; + + // Pulsate distance + if (currentMagicEffect.pulseEnabled) { + particle.distance = particle.baseDistance * pulseValue; + } + + // Update position + particle.x = centerX + Math.cos(particle.angle) * particle.distance; + particle.y = centerY + Math.sin(particle.angle) * particle.distance; + + // Pulsate alpha + particle.alpha += particle.alphaSpeed * (Math.random() > 0.5 ? 1 : -1); + particle.alpha = Math.max(0.1, Math.min(1, particle.alpha)); + } + } + + function updateBolts(pulseValue, speedFactor) { + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + + // Update existing bolts + for (let i = currentMagicEffect.particles.length - 1; i >= 0; i--) { + const bolt = currentMagicEffect.particles[i]; + + // Increase age + bolt.age += speedFactor; + + // Fade out based on age + if (bolt.age > bolt.lifespan * 0.7) { + bolt.alpha -= bolt.alphaSpeed * speedFactor; + } + + // Remove old bolts + if (bolt.alpha <= 0 || bolt.age >= bolt.lifespan) { + currentMagicEffect.particles.splice(i, 1); + } + } + + // Add new bolts occasionally + if (currentMagicEffect.particles.length < 8 * currentMagicEffect.intensity * pulseValue && + Math.random() < 0.1 * speedFactor) { + + const bolt = { + segments: [], + alpha: 0.7 + Math.random() * 0.3, + alphaSpeed: 0.02 + Math.random() * 0.03, + lifespan: 20 + Math.floor(Math.random() * 30), + age: 0 + }; + + // Create bolt segments + const startAngle = Math.random() * Math.PI * 2; + const length = 30 + Math.random() * 40 * (currentMagicEffect.intensity / 5); + + bolt.segments.push({ + x: centerX, + y: centerY, + angle: startAngle + }); + + let currentX = centerX; + let currentY = centerY; + let currentAngle = startAngle; + + const numSegments = 5 + Math.floor(Math.random() * 5); + for (let j = 0; j < numSegments; j++) { + // Add some variation to the angle + currentAngle += (Math.random() - 0.5) * 1.5; + + // Calculate new position + const segmentLength = length / numSegments; + currentX += Math.cos(currentAngle) * segmentLength; + currentY += Math.sin(currentAngle) * segmentLength; + + bolt.segments.push({ + x: currentX, + y: currentY, + angle: currentAngle + }); + } + + currentMagicEffect.particles.push(bolt); + } + } + + function updatePortal(pulseValue, speedFactor) { + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + + for (let i = 0; i < currentMagicEffect.particles.length; i++) { + const particle = currentMagicEffect.particles[i]; + + if (particle.type === 'ring') { + // Rotate ring particles + particle.angle += particle.rotationSpeed * speedFactor; + + // Update position + particle.x = centerX + Math.cos(particle.angle) * particle.radius * pulseValue; + particle.y = centerY + Math.sin(particle.angle) * particle.radius * pulseValue; + } else if (particle.type === 'energy') { + // Move energy particles + particle.x += Math.cos(particle.angle) * particle.speed * speedFactor; + particle.y += Math.sin(particle.angle) * particle.speed * speedFactor; + + // Pulsate alpha + particle.alpha += particle.alphaSpeed * (Math.random() > 0.5 ? 1 : -1); + particle.alpha = Math.max(0.1, Math.min(1, particle.alpha)); + + // Check if particle is too far from center + const dx = particle.x - centerX; + const dy = particle.y - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > particle.radius * 1.5) { + // Reset to a new random position in the portal + const newAngle = Math.random() * Math.PI * 2; + const newDistance = Math.random() * particle.radius * 0.8; + + particle.x = centerX + Math.cos(newAngle) * newDistance; + particle.y = centerY + Math.sin(newAngle) * newDistance; + particle.angle = Math.random() * Math.PI * 2; + } + } + } + } + + function renderMagicEffect(pulseValue) { + // Apply glow effect + effectCtx.shadowBlur = currentMagicEffect.glowIntensity * 2 * pulseValue; + effectCtx.globalCompositeOperation = 'lighter'; + + // Get primary and secondary colors for the scheme + const [r1, g1, b1] = getMagicColor(currentMagicEffect.colorScheme, 0.2); + const [r2, g2, b2] = getMagicColor(currentMagicEffect.colorScheme, 0.8); + + // Set shadow color based on scheme + effectCtx.shadowColor = `rgb(${r1}, ${g1}, ${b1})`; + + // Render based on effect type + switch (currentMagicEffect.type) { + case 'sparkles': + renderSparkles(r1, g1, b1, r2, g2, b2, pulseValue); + break; + case 'aura': + renderAura(r1, g1, b1, r2, g2, b2, pulseValue); + break; + case 'bolts': + renderBolts(r1, g1, b1, r2, g2, b2, pulseValue); + break; + case 'portal': + renderPortal(r1, g1, b1, r2, g2, b2, pulseValue); + break; + } + + // Reset composite operation and shadow + effectCtx.globalCompositeOperation = 'source-over'; + effectCtx.shadowBlur = 0; + } + + function renderSparkles(r1, g1, b1, r2, g2, b2, pulseValue) { + for (const particle of currentMagicEffect.particles) { + effectCtx.save(); + effectCtx.translate(particle.x, particle.y); + effectCtx.rotate(particle.rotation); + + const alpha = particle.alpha * pulseValue; + + // Draw sparkle (star shape) + effectCtx.beginPath(); + + for (let i = 0; i < 5; i++) { + const angle = (i * Math.PI * 2) / 5; + const outerRadius = particle.size * 2; + const innerRadius = particle.size * 0.5; + + effectCtx.lineTo(Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius); + effectCtx.lineTo( + Math.cos(angle + Math.PI / 5) * innerRadius, + Math.sin(angle + Math.PI / 5) * innerRadius + ); + } + + effectCtx.closePath(); + effectCtx.fillStyle = `rgba(${r1}, ${g1}, ${b1}, ${alpha})`; + effectCtx.fill(); + + // Draw center dot + effectCtx.beginPath(); + effectCtx.arc(0, 0, particle.size * 0.5, 0, Math.PI * 2); + effectCtx.fillStyle = `rgba(${r2}, ${g2}, ${b2}, ${alpha})`; + effectCtx.fill(); + + effectCtx.restore(); + } + } + + function renderAura(r1, g1, b1, r2, g2, b2, pulseValue) { + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + + // Draw aura glow + const radius = Math.min(width, height) * 0.3 * pulseValue; + const gradient = effectCtx.createRadialGradient( + centerX, centerY, radius * 0.2, + centerX, centerY, radius + ); + + gradient.addColorStop(0, `rgba(${r1}, ${g1}, ${b1}, 0.4)`); + gradient.addColorStop(0.7, `rgba(${r2}, ${g2}, ${b2}, 0.1)`); + gradient.addColorStop(1, `rgba(${r2}, ${g2}, ${b2}, 0)`); + + effectCtx.beginPath(); + effectCtx.fillStyle = gradient; + effectCtx.arc(centerX, centerY, radius, 0, Math.PI * 2); + effectCtx.fill(); + + // Draw particles + for (const particle of currentMagicEffect.particles) { + const alpha = particle.alpha * pulseValue; + + effectCtx.beginPath(); + effectCtx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + effectCtx.fillStyle = `rgba(${r1}, ${g1}, ${b1}, ${alpha})`; + effectCtx.fill(); + } + } + + function renderBolts(r1, g1, b1, r2, g2, b2, pulseValue) { + // Draw bolts + for (const bolt of currentMagicEffect.particles) { + const alpha = bolt.alpha * pulseValue; + + // Set line style + effectCtx.strokeStyle = `rgba(${r1}, ${g1}, ${b1}, ${alpha})`; + effectCtx.lineWidth = 2 * pulseValue; + + // Draw main bolt + effectCtx.beginPath(); + effectCtx.moveTo(bolt.segments[0].x, bolt.segments[0].y); + + for (let i = 1; i < bolt.segments.length; i++) { + effectCtx.lineTo(bolt.segments[i].x, bolt.segments[i].y); + } + + effectCtx.stroke(); + + // Draw glow + effectCtx.strokeStyle = `rgba(${r2}, ${g2}, ${b2}, ${alpha * 0.5})`; + effectCtx.lineWidth = 5 * pulseValue; + effectCtx.stroke(); + + // Draw connection points + for (const segment of bolt.segments) { + effectCtx.beginPath(); + effectCtx.arc(segment.x, segment.y, 2 * pulseValue, 0, Math.PI * 2); + effectCtx.fillStyle = `rgba(${r1}, ${g1}, ${b1}, ${alpha})`; + effectCtx.fill(); + } + } + } + + function renderPortal(r1, g1, b1, r2, g2, b2, pulseValue) { + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + + // Draw portal background + const portalRadius = Math.min(width, height) * 0.25 * pulseValue; + const gradient = effectCtx.createRadialGradient( + centerX, centerY, 0, + centerX, centerY, portalRadius + ); + + gradient.addColorStop(0, `rgba(${r1}, ${g1}, ${b1}, 0.7)`); + gradient.addColorStop(0.5, `rgba(${r2}, ${g2}, ${b2}, 0.3)`); + gradient.addColorStop(1, `rgba(${r2}, ${g2}, ${b2}, 0)`); + + effectCtx.beginPath(); + effectCtx.fillStyle = gradient; + effectCtx.arc(centerX, centerY, portalRadius, 0, Math.PI * 2); + effectCtx.fill(); + + // Draw particles + for (const particle of currentMagicEffect.particles) { + const alpha = particle.alpha * pulseValue; + + if (particle.type === 'ring') { + // Ring particles + effectCtx.beginPath(); + effectCtx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + effectCtx.fillStyle = `rgba(${r1}, ${g1}, ${b1}, ${alpha})`; + effectCtx.fill(); + } else if (particle.type === 'energy') { + // Energy particles + effectCtx.beginPath(); + effectCtx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + effectCtx.fillStyle = `rgba(${r2}, ${g2}, ${b2}, ${alpha})`; + effectCtx.fill(); + } + } + + // Draw portal ring + effectCtx.beginPath(); + effectCtx.arc(centerX, centerY, portalRadius, 0, Math.PI * 2); + effectCtx.strokeStyle = `rgba(${r1}, ${g1}, ${b1}, 0.8)`; + effectCtx.lineWidth = 3 * pulseValue; + effectCtx.stroke(); + } + + /*** WATER EFFECT ***/ + + function initWaterEffect() { + currentWaterEffect = { + particles: [], + type: 'splash', + viscosity: 0.95, + speed: 5, + particleCount: 200, + color: 'clear', + customColor: '#4a8eff', + transparency: 0.7, + reflectionEnabled: true, + + // Animation state + phase: 'active', + timer: 0 + }; + + // Event listeners for water parameters + document.getElementById('waterType').addEventListener('change', function() { + currentWaterEffect.type = this.value; + initializeWaterParticles(); + }); + + document.getElementById('waterViscosity').addEventListener('input', function() { + currentWaterEffect.viscosity = parseFloat(this.value); + }); + + document.getElementById('waterSpeed').addEventListener('input', function() { + currentWaterEffect.speed = parseInt(this.value); + }); + + document.getElementById('waterParticles').addEventListener('input', function() { + currentWaterEffect.particleCount = parseInt(this.value); + }); + + document.getElementById('waterColor').addEventListener('change', function() { + currentWaterEffect.color = this.value; + }); + + document.getElementById('customWaterColor').addEventListener('input', function() { + currentWaterEffect.customColor = this.value; + }); + + document.getElementById('waterTransparency').addEventListener('input', function() { + currentWaterEffect.transparency = parseFloat(this.value); + }); + + document.getElementById('waterReflection').addEventListener('change', function() { + currentWaterEffect.reflectionEnabled = this.checked; + }); + + // Initialize water particles + initializeWaterParticles(); + } + + function initializeWaterParticles() { + if (!currentWaterEffect) return; + + // Clear existing particles + currentWaterEffect.particles = []; + + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + + // Create particles based on effect type + switch (currentWaterEffect.type) { + case 'splash': + // Create splash particles + for (let i = 0; i < currentWaterEffect.particleCount; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = 1 + Math.random() * 5; + + currentWaterEffect.particles.push({ + x: centerX, + y: centerY, + size: 2 + Math.random() * 4, + speedX: Math.cos(angle) * speed, + speedY: Math.sin(angle) * speed - 5, // Initial upward velocity + gravity: 0.2, + alpha: 0.7 + Math.random() * 0.3, + trail: [] + }); + } + break; + + case 'drip': + // Create water drips + for (let i = 0; i < currentWaterEffect.particleCount / 10; i++) { + createWaterDrip(); + } + break; + + case 'flow': + // Create flowing water particles + for (let i = 0; i < currentWaterEffect.particleCount; i++) { + currentWaterEffect.particles.push({ + x: Math.random() * width, + y: Math.random() * height, + size: 1 + Math.random() * 3, + speedX: 0.5 + Math.random() * 1.5, + speedY: 0.1 + Math.random() * 0.5, + alpha: 0.3 + Math.random() * 0.7, + wavePhase: Math.random() * Math.PI * 2, + waveAmplitude: 0.5 + Math.random() * 1.5 + }); + } + break; + + case 'ripple': + // Create ripple waves + for (let i = 0; i < 10; i++) { + currentWaterEffect.particles.push({ + x: centerX, + y: centerY, + radius: 10 + i * 15, + targetRadius: 100 + i * 20, + thickness: 2, + alpha: 1 - i * 0.1, + speed: 0.5 + Math.random() * 0.5 + }); + } + break; + } + } + + function createWaterDrip() { + const width = effectCanvas.width; + const startX = Math.random() * width; + + currentWaterEffect.particles.push({ + x: startX, + y: 0, + size: 3 + Math.random() * 5, + length: 10 + Math.random() * 20, + speedY: 1 + Math.random() * 2, + alpha: 0.6 + Math.random() * 0.4, + droplets: [], + readyToDrop: false, + dropDelay: 30 + Math.floor(Math.random() * 60) + }); + } + + function getWaterColor(alpha = 1) { + switch (currentWaterEffect.color) { + case 'clear': + return `rgba(74, 142, 255, ${alpha * currentWaterEffect.transparency})`; + case 'deep': + return `rgba(0, 80, 180, ${alpha * currentWaterEffect.transparency})`; + case 'murky': + return `rgba(100, 125, 130, ${alpha * currentWaterEffect.transparency})`; + case 'toxic': + return `rgba(80, 220, 75, ${alpha * currentWaterEffect.transparency})`; + case 'custom': + // Parse custom color + const hex = currentWaterEffect.customColor.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha * currentWaterEffect.transparency})`; + } + } + + function updateWaterEffect() { + if (!currentWaterEffect) return; + + const speedFactor = currentWaterEffect.speed / 5; + + // Update based on effect type + switch (currentWaterEffect.type) { + case 'splash': + updateWaterSplash(speedFactor); + break; + case 'drip': + updateWaterDrip(speedFactor); + break; + case 'flow': + updateWaterFlow(speedFactor); + break; + case 'ripple': + updateWaterRipple(speedFactor); + break; + } + + // Render water effect + renderWaterEffect(); + } + + function updateWaterSplash(speedFactor) { + const height = effectCanvas.height; + + for (let i = currentWaterEffect.particles.length - 1; i >= 0; i--) { + const particle = currentWaterEffect.particles[i]; + + // Add position to trail (limited length) + particle.trail.push({x: particle.x, y: particle.y}); + if (particle.trail.length > 5) { + particle.trail.shift(); + } + + // Update position + particle.x += particle.speedX * speedFactor; + particle.y += particle.speedY * speedFactor; + + // Apply gravity + particle.speedY += particle.gravity * speedFactor; + + // Apply resistance + particle.speedX *= currentWaterEffect.viscosity; + particle.speedY *= currentWaterEffect.viscosity; + + // Check for ground collision + if (particle.y > height - particle.size) { + // Bounce with energy loss + particle.y = height - particle.size; + particle.speedY = -particle.speedY * 0.6; + + // Reduce horizontal speed (friction) + particle.speedX *= 0.8; + + // Remove very slow particles + if (Math.abs(particle.speedY) < 0.5) { + currentWaterEffect.particles.splice(i, 1); + } + } + } + + // Reset if all particles are gone + if (currentWaterEffect.particles.length === 0) { + initializeWaterParticles(); + } + } + + function updateWaterDrip(speedFactor) { + const height = effectCanvas.height; + + for (let i = currentWaterEffect.particles.length - 1; i >= 0; i--) { + const drip = currentWaterEffect.particles[i]; + + // Update drip position + drip.y += drip.speedY * speedFactor; + + // Check if it's time to create a droplet + if (!drip.readyToDrop) { + drip.dropDelay -= speedFactor; + if (drip.dropDelay <= 0) { + drip.readyToDrop = true; + } + } + + // Create droplet if ready and at bottom of drip + if (drip.readyToDrop && drip.y > drip.length && Math.random() < 0.03 * speedFactor) { + drip.droplets.push({ + x: drip.x, + y: drip.y + drip.length, + size: drip.size * 0.7, + speedY: 1 + Math.random() * 3, + alpha: drip.alpha + }); + + // Reset drip length + drip.length = 3 + Math.random() * 8; + drip.readyToDrop = false; + drip.dropDelay = 10 + Math.floor(Math.random() * 20); + } else { + // Increase length until ready to drop + drip.length += 0.1 * speedFactor; + } + + // Update droplets + for (let j = drip.droplets.length - 1; j >= 0; j--) { + const droplet = drip.droplets[j]; + + // Update droplet position + droplet.y += droplet.speedY * speedFactor; + + // Remove droplets that fall off screen + if (droplet.y > height) { + drip.droplets.splice(j, 1); + } + } + + // Create new drips occasionally + if (currentWaterEffect.particles.length < currentWaterEffect.particleCount / 10 && + Math.random() < 0.02 * speedFactor) { + createWaterDrip(); + } + } + } + + function updateWaterFlow(speedFactor) { + const width = effectCanvas.width; + const height = effectCanvas.height; + + for (let i = currentWaterEffect.particles.length - 1; i >= 0; i--) { + const particle = currentWaterEffect.particles[i]; + + // Update position + particle.x += particle.speedX * speedFactor; + particle.y += particle.speedY * speedFactor; + + // Add wave motion + particle.wavePhase += 0.1 * speedFactor; + particle.x += Math.sin(particle.wavePhase) * particle.waveAmplitude; + + // Wrap around screen + if (particle.x > width) { + particle.x = 0; + } + + if (particle.y > height) { + particle.y = 0; + particle.x = Math.random() * width; + } + } + } + + function updateWaterRipple(speedFactor) { + const width = effectCanvas.width; + const height = effectCanvas.height; + const centerX = width / 2; + const centerY = height / 2; + + let allComplete = true; + + for (let i = currentWaterEffect.particles.length - 1; i >= 0; i--) { + const ripple = currentWaterEffect.particles[i]; + + // Expand ripple + ripple.radius += ripple.speed * speedFactor; + + // Fade out as it expands + ripple.alpha = Math.max(0, 1 - (ripple.radius / ripple.targetRadius)); + + // Check if ripple is complete + if (ripple.radius < ripple.targetRadius) { + allComplete = false; + } + } + + // Reset ripples if all complete + if (allComplete) { + initializeWaterParticles(); + } + + // Create new ripple occasionally + if (Math.random() < 0.01 * speedFactor) { + const x = centerX + (Math.random() - 0.5) * width * 0.5; + const y = centerY + (Math.random() - 0.5) * height * 0.5; + + // Add a new small ripple + currentWaterEffect.particles.push({ + x: x, + y: y, + radius: 5, + targetRadius: 30 + Math.random() * 30, + thickness: 1.5, + alpha: 0.7 + Math.random() * 0.3, + speed: 0.3 + Math.random() * 0.3 + }); + } + } + + function renderWaterEffect() { + // Set water properties + const baseColor = getWaterColor(1); + const lightColor = getWaterColor(0.7); + + // Apply transparency settings + effectCtx.globalAlpha = currentWaterEffect.transparency; + + // Add reflections if enabled + if (currentWaterEffect.reflectionEnabled) { + effectCtx.shadowColor = lightColor; + effectCtx.shadowBlur = 5; + } + + switch (currentWaterEffect.type) { + case 'splash': + renderWaterSplash(baseColor, lightColor); + break; + case 'drip': + renderWaterDrip(baseColor, lightColor); + break; + case 'flow': + renderWaterFlow(baseColor, lightColor); + break; + case 'ripple': + renderWaterRipple(baseColor, lightColor); + break; + } + + // Reset properties + effectCtx.globalAlpha = 1; + effectCtx.shadowBlur = 0; + } + + function renderWaterSplash(baseColor, lightColor) { + // Draw water particles + for (const particle of currentWaterEffect.particles) { + // Draw particle trails (for motion blur effect) + if (particle.trail.length > 1) { + effectCtx.beginPath(); + effectCtx.moveTo(particle.trail[0].x, particle.trail[0].y); + + for (let i = 1; i < particle.trail.length; i++) { + effectCtx.lineTo(particle.trail[i].x, particle.trail[i].y); + } + + effectCtx.lineWidth = particle.size / 2; + effectCtx.strokeStyle = lightColor; + effectCtx.stroke(); + } + + // Draw the particle + effectCtx.beginPath(); + effectCtx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + effectCtx.fillStyle = baseColor; + effectCtx.fill(); + + // Draw highlight + effectCtx.beginPath(); + effectCtx.arc( + particle.x - particle.size * 0.3, + particle.y - particle.size * 0.3, + particle.size * 0.4, + 0, Math.PI * 2 + ); + effectCtx.fillStyle = 'rgba(255, 255, 255, 0.3)'; + effectCtx.fill(); + } + } + + function renderWaterDrip(baseColor, lightColor) { + for (const drip of currentWaterEffect.particles) { + // Draw the drip + effectCtx.beginPath(); + effectCtx.fillStyle = baseColor; + + // Teardrop shape for the drip + const width = drip.size; + const height = drip.length; + + // Draw drip body (rounded rectangle) + effectCtx.roundRect( + drip.x - width/2, + drip.y, + width, + height, + [0, 0, width/2, width/2] + ); + effectCtx.fill(); + + // Draw droplets + for (const droplet of drip.droplets) { + // Teardrop shape for droplets + effectCtx.beginPath(); + effectCtx.arc(droplet.x, droplet.y, droplet.size, 0, Math.PI * 2); + effectCtx.fillStyle = baseColor; + effectCtx.fill(); + + // Add highlight + effectCtx.beginPath(); + effectCtx.arc( + droplet.x - droplet.size * 0.3, + droplet.y - droplet.size * 0.3, + droplet.size * 0.4, + 0, Math.PI * 2 + ); + effectCtx.fillStyle = 'rgba(255, 255, 255, 0.3)'; + effectCtx.fill(); + } + } + } + + function renderWaterFlow(baseColor, lightColor) { + // Draw flowing water particles + for (const particle of currentWaterEffect.particles) { + effectCtx.beginPath(); + effectCtx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + effectCtx.fillStyle = baseColor; + effectCtx.fill(); + + // Draw trail (elongated in direction of movement) + effectCtx.beginPath(); + effectCtx.ellipse( + particle.x - particle.speedX * 2, + particle.y - particle.speedY * 2, + particle.size * 0.8, + particle.size * 1.5, + Math.atan2(particle.speedY, particle.speedX), + 0, Math.PI * 2 + ); + effectCtx.fillStyle = lightColor; + effectCtx.fill(); + } + } + + function renderWaterRipple(baseColor, lightColor) { + for (const ripple of currentWaterEffect.particles) { + // Draw ripple circle + effectCtx.beginPath(); + effectCtx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI * 2); + effectCtx.strokeStyle = `rgba(74, 142, 255, ${ripple.alpha * currentWaterEffect.transparency})`; + effectCtx.lineWidth = ripple.thickness; + effectCtx.stroke(); + + // Draw secondary ripple for effect + effectCtx.beginPath(); + effectCtx.arc(ripple.x, ripple.y, ripple.radius * 0.8, 0, Math.PI * 2); + effectCtx.strokeStyle = `rgba(255, 255, 255, ${ripple.alpha * 0.5 * currentWaterEffect.transparency})`; + effectCtx.lineWidth = ripple.thickness * 0.5; + effectCtx.stroke(); + } + } + + // Helper function: HSL to RGB conversion + function hslToRgb(h, s, l) { + h /= 360; + s /= 100; + l /= 100; + + let r, g, b; + + if (s === 0) { + r = g = b = l; // achromatic + } 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)]; + } + </script> +</body> +</html>
\ No newline at end of file diff --git a/resources/views/writings/edit.blade.php b/resources/views/writings/edit.blade.php index 85e1f14..3dbe36c 100644 --- a/resources/views/writings/edit.blade.php +++ b/resources/views/writings/edit.blade.php @@ -4,7 +4,7 @@ @vite(['resources/js/writing_show.js', 'resources/css/writings.css']) @endsection -@section('body') +@section('body') <main> @if (Auth::user() != null) <div class="container"> diff --git a/resources/views/writings/index.blade.php b/resources/views/writings/index.blade.php index 6fd1e84..36cb735 100644 --- a/resources/views/writings/index.blade.php +++ b/resources/views/writings/index.blade.php @@ -7,6 +7,22 @@ <p >Thoughts, ideas, words potentially worth publishing</p> <a href = "/w/create">Write Something</a> <!-- TODO search --> + <div class = "sidebar"> + <legend>Sort by: </legend> + <select name = "sort_method"> + <option value = "date">Date</option> + <option value = "user">Author</option> + <option value = "title">Title</option> + </select> + <fieldset id = "filter_method"> + <legend>Filter: </legend> + <div> + <input type="checkbox" id="checkbox_author1" name="checkbox_author1" value="Author 1 (placeholder TODO)"> + <label for="checkbox_author1">Author 1 (placeholder TODO)</label> + </div> + </fieldset> + </div> + </section> <section> @@ -29,6 +45,8 @@ @endforeach </section> + + @if ($writings->hasPages()) <div > {{ $writings->links() }} diff --git a/routes/web.php b/routes/web.php index 76ef3a7..b0d36e8 100755 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\SiteController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\WritingController; use App\Http\Controllers\LinkController; +use App\Http\Controllers\DashboardController; use Illuminate\Http\Request; Route::get('/', function () { @@ -59,6 +60,8 @@ Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + Route::get('/dashboard/stats', [DashboardController::class, 'statistics'])->name('dashboard.stats'); }); Route::post('/update-session', function(Request $req){ @@ -70,6 +73,13 @@ Route::post('/update-session', function(Request $req){ require __DIR__.'/auth.php'; +Route::get('/toy/{v}', function($v){ + if (is_null($v)){ + return view('toys.index'); + } + return view('toys.'.$v); +}); + Route::get('/{v}', function($v){ return view($v); }); diff --git a/vite.config.js b/vite.config.js index a380d5c..0af0b6a 100755 --- a/vite.config.js +++ b/vite.config.js @@ -4,12 +4,24 @@ import laravel from 'laravel-vite-plugin'; export default defineConfig({ plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/css/style.css', 'resources/css/writings.css','resources/js/main.js', 'resources/js/marked.js', 'resources/js/writing_create.js', 'resources/js/writing_show.js'], + input: [ + 'resources/css/app.css', + 'resources/css/skeleton.css', + 'resources/css/normalize.css', + 'resources/css/style.css', + 'resources/css/writings.css', + 'resources/css/jstoys.css', + 'resources/js/main.js', + 'resources/js/marked.js', + 'resources/js/writing_create.js', + 'resources/js/writing_show.js', + 'resources/js/blood_gpu.js', + 'resources/js/blood.js'], refresh: true }), ], server: { - host: 'localhost', + host: '192.168.4.32', cors: true } }); |
