diff options
| author | grothedev <grothedev@gmail.com> | 2025-03-03 23:44:46 -0600 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2025-03-03 23:44:46 -0600 |
| commit | 9ba51ca2586201efc660fc1c899dd378b2d09d0e (patch) | |
| tree | 301ec00e9a4168b6a51fea508ce08937d5b4f4d6 | |
| parent | 44421b147910948076cdbffc8e5678776e5e25c1 (diff) | |
update readme. some unfinished work on writings. adding some javascript playing. can now import models from json data. tested and verified working on links
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 } }); |
