summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md15
-rw-r--r--app/Http/Controllers/DashboardController.php54
-rw-r--r--app/Http/Controllers/FileController.php236
-rw-r--r--app/Http/Controllers/SiteController.php53
-rw-r--r--app/Models/File.php68
-rw-r--r--app/Models/Link.php2
-rw-r--r--app/Models/Role.php8
-rwxr-xr-xapp/Models/User.php15
-rw-r--r--database/migrations/2025_02_26_223431_roles_add_filefields.php35
-rw-r--r--database/migrations/2025_03_04_050210_link_url_unique.php28
-rw-r--r--database/migrations/99999_pivots.php4
-rw-r--r--database/seeders/RoleSeeder.php19
-rw-r--r--resources/css/jstoys.css85
-rwxr-xr-xresources/css/normalize.css427
-rwxr-xr-xresources/css/skeleton.css419
-rw-r--r--resources/css/style.css6
-rw-r--r--resources/js/blood.js593
-rw-r--r--resources/js/blood_gpu.js656
-rw-r--r--resources/js/home.js0
-rw-r--r--resources/js/writing_index.js59
-rw-r--r--resources/views/dashboard.blade.php374
-rwxr-xr-xresources/views/home.blade.php16
-rw-r--r--resources/views/import.blade.php24
-rw-r--r--resources/views/links/index.blade.php2
-rw-r--r--resources/views/toys/blood-gpu.blade.php384
-rw-r--r--resources/views/toys/blood.blade.php69
-rw-r--r--resources/views/toys/fire.blade.php504
-rw-r--r--resources/views/toys/index.blade.php15
-rw-r--r--resources/views/toys/spriteframework.blade.php3736
-rw-r--r--resources/views/writings/edit.blade.php2
-rw-r--r--resources/views/writings/index.blade.php18
-rwxr-xr-xroutes/web.php10
-rwxr-xr-xvite.config.js16
33 files changed, 7914 insertions, 38 deletions
diff --git a/README.md b/README.md
index 7dc9caf..b5e7a64 100644
--- a/README.md
+++ b/README.md
@@ -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
}
});