diff options
Diffstat (limited to '2026/submissions/threecoff/clennis')
15 files changed, 1153 insertions, 0 deletions
diff --git a/2026/submissions/threecoff/clennis/.clangd b/2026/submissions/threecoff/clennis/.clangd new file mode 100644 index 0000000..2f33531 --- /dev/null +++ b/2026/submissions/threecoff/clennis/.clangd @@ -0,0 +1,2 @@ +CompileFlags: + CompilationDatabase: build-native diff --git a/2026/submissions/threecoff/clennis/.gitignore b/2026/submissions/threecoff/clennis/.gitignore new file mode 100644 index 0000000..ed18558 --- /dev/null +++ b/2026/submissions/threecoff/clennis/.gitignore @@ -0,0 +1,5 @@ +# Build output +build*/ + +# Project-local Emscripten SDK (large; installed via scripts/setup-emsdk.sh) +vendor/emsdk/ diff --git a/2026/submissions/threecoff/clennis/CMakeLists.txt b/2026/submissions/threecoff/clennis/CMakeLists.txt new file mode 100644 index 0000000..8d1ca09 --- /dev/null +++ b/2026/submissions/threecoff/clennis/CMakeLists.txt @@ -0,0 +1,98 @@ +cmake_minimum_required(VERSION 3.16) +project(clonk_tennis C) + +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # for clangd (see .clangd) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE) +endif() + +include(FetchContent) + +# --- raylib: windowing, rendering, input (native + web) --- +set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(BUILD_GAMES OFF CACHE BOOL "" FORCE) +FetchContent_Declare( + raylib + URL https://github.com/raysan5/raylib/archive/refs/tags/5.5.tar.gz +) + +# --- box3d: 3D rigid body physics (Erin Catto). Only the library builds via +# FetchContent: its samples/tests (and their forced -pthread) are gated +# behind PROJECT_IS_TOP_LEVEL, and it sets its own SIMD flags for wasm. --- +FetchContent_Declare( + box3d + URL https://github.com/erincatto/box3d/archive/refs/tags/v0.1.0.tar.gz +) + +FetchContent_MakeAvailable(raylib box3d) + +add_executable(tennis src/main.c) +target_link_libraries(tennis PRIVATE raylib box3d::box3d) + +if(NOT EMSCRIPTEN) + # main.c pokes GLFW directly (glfwSetInputMode, to undo raylib's + # raw-mouse-motion default); use the GLFW bundled inside raylib. + target_include_directories(tennis PRIVATE + "${raylib_SOURCE_DIR}/src/external/glfw/include") +endif() + +# Where the game finds its models at runtime: baked into the wasm bundle on +# web, read straight from the source tree natively (dev builds only). +if(EMSCRIPTEN) + target_compile_definitions(tennis PRIVATE ASSETS_DIR="assets") +else() + target_compile_definitions(tennis PRIVATE ASSETS_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets") +endif() + +if(EMSCRIPTEN) + # Work around an LLVM optimizer crash in emcc's clang (6.0.3) that segfaults + # while compiling some Box3D files at -O2/-O3 (independent of SIMD). Build the + # physics library at -O1 on web; it's more than fast enough for this game. + # The trailing -O1 wins over the build-type -O3 for this target only. + if(TARGET box3d) + target_compile_options(box3d PRIVATE -O1) + endif() + + # Produce tennis.html + tennis.js + tennis.wasm + set_target_properties(tennis PROPERTIES SUFFIX ".html") + target_compile_definitions(tennis PRIVATE PLATFORM_WEB) + target_link_options(tennis PRIVATE + -sUSE_GLFW=3 + -sALLOW_MEMORY_GROWTH=1 + # dr_mp3's whole-file decode (raylib LoadSound on .mp3) keeps ~30KB of + # scratch structs on the stack and overflows Emscripten's 64KB default, + # crashing at startup with "memory access out of bounds". + -sSTACK_SIZE=262144 + -sGL_ENABLE_GET_PROC_ADDRESS=1 + # Emscripten 4+ stopped putting the heap views on Module by default, + # but raylib's miniaudio audio callback reads Module.HEAPF32 — without + # this every audio tick throws "Cannot read properties of undefined + # (reading 'buffer')" in the ScriptProcessorNode handler. + "-sEXPORTED_RUNTIME_METHODS=HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64" + --shell-file "${CMAKE_CURRENT_SOURCE_DIR}/shell.html" + --preload-file "${CMAKE_CURRENT_SOURCE_DIR}/assets@assets" + ) + + # Also expose the page as index.html so a plain directory server's bare + # URL (e.g. http://localhost:8000/) serves the game, not a file listing. + add_custom_command(TARGET tennis POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$<TARGET_FILE:tennis>" "${CMAKE_CURRENT_BINARY_DIR}/index.html" + VERBATIM + ) + + # `cmake --install` publishes just the runtime files (what a web server + # needs), leaving all the CMake/emscripten machinery in the build dir. + install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/index.html" + "${CMAKE_CURRENT_BINARY_DIR}/tennis.html" + "${CMAKE_CURRENT_BINARY_DIR}/tennis.js" + "${CMAKE_CURRENT_BINARY_DIR}/tennis.wasm" + "${CMAKE_CURRENT_BINARY_DIR}/tennis.data" + DESTINATION . + COMPONENT web # keeps raylib/box3d's own install rules out + ) +endif() diff --git a/2026/submissions/threecoff/clennis/LICENSE b/2026/submissions/threecoff/clennis/LICENSE new file mode 100644 index 0000000..899f755 --- /dev/null +++ b/2026/submissions/threecoff/clennis/LICENSE @@ -0,0 +1,38 @@ +MIT License + +Copyright (c) 2026 Clonk Tennis contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------------------- + +SCOPE OF THIS LICENSE + +The MIT license above applies ONLY to the source code of this project — the +contents of src/, CMakeLists.txt, the scripts in scripts/, shell.html, and +other build/configuration files authored for this project. + +It does NOT apply to the art, audio, 3D model, or other media assets located +in the assets/ directory. Those assets are covered by separate licenses and, +in some cases, may not be redistributable at all. See assets/LICENSE.md for +the terms that apply to each asset. + +Third-party dependencies fetched at build time (e.g. raylib, Box3D, the +Emscripten SDK) are not distributed by this project and remain subject to +their own respective licenses. diff --git a/2026/submissions/threecoff/clennis/README.md b/2026/submissions/threecoff/clennis/README.md new file mode 100644 index 0000000..ff24512 --- /dev/null +++ b/2026/submissions/threecoff/clennis/README.md @@ -0,0 +1,26 @@ +# Clonk Tennis + +## To build the C version + + +```sh +./scripts/build-native.sh +./build-native/tennis +``` + +## Building web version + + +```sh +./scripts/setup-emsdk.sh +``` + + +```sh +./scripts/build-web.sh +python -m http.server -d build-web 8000 +#open http://localhost:8000/tennis.html +``` + +Tennis racket from https://www.artstation.com/marketplace/p/LO8dg/cartoon-tennis-racket-3d-model +Music from https://pixabay.com/music/upbeat-retro-arcade-game-music-297305/ diff --git a/2026/submissions/threecoff/clennis/assets/enemyping.mp3 b/2026/submissions/threecoff/clennis/assets/enemyping.mp3 Binary files differnew file mode 100644 index 0000000..cb509a4 --- /dev/null +++ b/2026/submissions/threecoff/clennis/assets/enemyping.mp3 diff --git a/2026/submissions/threecoff/clennis/assets/mrgreen.png b/2026/submissions/threecoff/clennis/assets/mrgreen.png Binary files differnew file mode 100644 index 0000000..1f1edca --- /dev/null +++ b/2026/submissions/threecoff/clennis/assets/mrgreen.png diff --git a/2026/submissions/threecoff/clennis/assets/music.mp3 b/2026/submissions/threecoff/clennis/assets/music.mp3 Binary files differnew file mode 100644 index 0000000..8d64a62 --- /dev/null +++ b/2026/submissions/threecoff/clennis/assets/music.mp3 diff --git a/2026/submissions/threecoff/clennis/assets/mypong.mp3 b/2026/submissions/threecoff/clennis/assets/mypong.mp3 Binary files differnew file mode 100644 index 0000000..9300c6f --- /dev/null +++ b/2026/submissions/threecoff/clennis/assets/mypong.mp3 diff --git a/2026/submissions/threecoff/clennis/assets/racket.glb b/2026/submissions/threecoff/clennis/assets/racket.glb Binary files differnew file mode 100644 index 0000000..005ddc0 --- /dev/null +++ b/2026/submissions/threecoff/clennis/assets/racket.glb diff --git a/2026/submissions/threecoff/clennis/scripts/build-native.sh b/2026/submissions/threecoff/clennis/scripts/build-native.sh new file mode 100755 index 0000000..8a0b297 --- /dev/null +++ b/2026/submissions/threecoff/clennis/scripts/build-native.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Configure + build the native desktop binary at ./build-native/tennis +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +cmake -B build-native -DCMAKE_BUILD_TYPE=Debug +cmake --build build-native -j"$(nproc)" + +echo +echo ">> Built build-native/tennis — run it with: ./build-native/tennis" diff --git a/2026/submissions/threecoff/clennis/scripts/build-web.sh b/2026/submissions/threecoff/clennis/scripts/build-web.sh new file mode 100755 index 0000000..12ca74b --- /dev/null +++ b/2026/submissions/threecoff/clennis/scripts/build-web.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Configure + build the WebAssembly version into ./build-web (tennis.html/.js/.wasm) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +# Activate the project-local Emscripten SDK if the caller hasn't already. +if ! command -v emcc >/dev/null 2>&1; then + if [ -f "$ROOT/vendor/emsdk/emsdk_env.sh" ]; then + # shellcheck disable=SC1091 + source "$ROOT/vendor/emsdk/emsdk_env.sh" + else + echo "error: emcc not found and vendor/emsdk missing. Run scripts/setup-emsdk.sh first." >&2 + exit 1 + fi +fi + +# CMake/emscripten intermediates live in build/web; only the runtime files +# (index/tennis.html, tennis.js, tennis.wasm, tennis.data) land in build-web. +emcmake cmake -B build/web -DPLATFORM=Web -DCMAKE_BUILD_TYPE=Release +cmake --build build/web -j"$(nproc)" +cmake --install build/web --prefix build-web --component web >/dev/null + +echo +echo ">> Built build-web/tennis.html (+ index.html copy) — serve it" +echo " (COOP/COEP not required; ports below 1024 need root) with e.g.:" +echo " python -m http.server -d build-web 8000" +echo " then open http://localhost:8000/" diff --git a/2026/submissions/threecoff/clennis/scripts/setup-emsdk.sh b/2026/submissions/threecoff/clennis/scripts/setup-emsdk.sh new file mode 100644 index 0000000..b711b7f --- /dev/null +++ b/2026/submissions/threecoff/clennis/scripts/setup-emsdk.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Install the Emscripten SDK into ./vendor/emsdk (needed only for the web build). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EMSDK_DIR="$ROOT/vendor/emsdk" + +if [ ! -d "$EMSDK_DIR" ]; then + echo ">> Cloning emsdk into $EMSDK_DIR" + git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$EMSDK_DIR" +fi + +cd "$EMSDK_DIR" +echo ">> Installing latest Emscripten toolchain (large download)..." +./emsdk install latest +./emsdk activate latest + +echo +echo ">> Done. Activate it in your shell with:" +echo " source $EMSDK_DIR/emsdk_env.sh" diff --git a/2026/submissions/threecoff/clennis/shell.html b/2026/submissions/threecoff/clennis/shell.html new file mode 100644 index 0000000..208101c --- /dev/null +++ b/2026/submissions/threecoff/clennis/shell.html @@ -0,0 +1,32 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Clonk Tennis</title> + <style> + html, body { margin: 0; height: 100%; background: #12141d; color: #e8e8ee; + font-family: system-ui, sans-serif; } + body { display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 12px; } + canvas { background: #000; border-radius: 6px; max-width: 100%; + image-rendering: auto; outline: none; } + #status { font-size: 14px; opacity: 0.7; min-height: 18px; } + </style> +</head> +<body> + <canvas id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas> + <div id="status">Loading…</div> + <script> + var statusEl = document.getElementById('status'); + var Module = { + canvas: (function () { return document.getElementById('canvas'); })(), + print: function (t) { console.log(t); }, + printErr: function (t) { console.error(t); }, + setStatus: function (t) { if (statusEl) statusEl.textContent = t; }, + onRuntimeInitialized: function () { if (statusEl) statusEl.textContent = ''; } + }; + </script> + {{{ SCRIPT }}} +</body> +</html> diff --git a/2026/submissions/threecoff/clennis/src/main.c b/2026/submissions/threecoff/clennis/src/main.c new file mode 100644 index 0000000..23c1c13 --- /dev/null +++ b/2026/submissions/threecoff/clennis/src/main.c @@ -0,0 +1,891 @@ +// Clonk Tennis - yay + + +#include "raylib.h" +#include "raymath.h" +#include "rlgl.h" // rlSetClipPlanes (see main) +#include "box3d/box3d.h" + +#if !defined(PLATFORM_WEB) + +#include "GLFW/glfw3.h" +#endif + +#include <stdlib.h> +#include <math.h> + +// --- harness stuff +#if defined(PLATFORM_WEB) +#include <emscripten.h> + +EM_JS(void, HarnessInit, (), { + + if (!window.__clonkInit) { + window.__clonkInit = true; + window.addEventListener('message', function (ev) { + var d = ev && ev.data; + if (d && d.op === 'start' && typeof d.difficulty === 'number') { + window.lcolonqJamStart = d.difficulty; + } + }); + } +}); +EM_JS(void, HarnessReady, (), { window.parent.postMessage({ op: "ready" }, "*"); }); +EM_JS(void, HarnessStarted, (const char *verb), { + window.parent.postMessage({ op: "started", verb: UTF8ToString(verb) }, "*"); +}); +EM_JS(void, HarnessDone, (int win), { + window.parent.postMessage({ op: "done", win: (win != 0) }, "*"); +}); + +EM_JS(int, HarnessPollStart, (), { + var v = window.lcolonqJamStart; + return (typeof v === 'number' && isFinite(v)) ? (v | 0) : -1; +}); +#else +static void HarnessInit(void) {} +static void HarnessReady(void) {} +static void HarnessStarted(const char *verb) { (void)verb; } +static void HarnessDone(int win) { (void)win; } +static int HarnessPollStart(void) { return 35; } // native: start immediately +#endif + +// --- Court / gameplay dimensions (metres) --- +#define COURT_W 8.0f // width along X +#define COURT_L 20.0f // length along Z +#define NET_H 1.0f +#define BALL_R 0.16f +#define PLAYER_Z 8.5f +#define AI_Z (-8.5f) + + +#define WALL_X (COURT_W * 0.5f + 1.0f) +#define CEIL_H 4.5f +#define BACK_Z (AI_Z - 2.0f) + +#define FIXED_DT (1.0f / 60.0f) +#define GRAVITY_Y (-12.0f) + + +#define RES_W 240 +#define RES_H 160 + +// Racket paddle half-extents. +#define PAD_HX 0.80f +#define PAD_HY 0.80f +#define PAD_HZ 0.08f + +// Hit tuning (see TryHit). +#define HIT_Z_BAND 0.7f +#define HIT_REACH 1.1f +#define REACH_TOP_Y 3.4f +#define RETURN_SPEED 30.0f +#define LIFT 5.0f +#define PAD_INFLUENCE 0.45f +#define HIT_COOLDOWN 0.0f + +#define AI_SPEED 9.0f + +// Racket +#define RACKET_SCALE 2.3f +#define RACKET_FACE_Y 0.87f +#define RACKET_ROD_L (RACKET_FACE_Y * RACKET_SCALE) + +// Racket swing stuff +#define SWAY_SPRING 260.0f +#define SWAY_DAMP 12.0f +#define HIT_RECOIL 0.12f + +// Racket face angling +#define PIVOT_MAX 0.5f +#define PIVOT_SENS 0.015f +#define PIVOT_RELAX 10.0f + +// Match rules. +#define TARGET_RETURNS 3 +#define TIME_LIMIT 30.0f +#define MAX_LIVES 3 +#define LIFE_EVERY_SCORES 3 + +// Difficulty: +#define DIFF_SCALE 0.40f +#define DIFF_MAX_AT 35.0f // difficulty setting at which the AI reaches full skill + +#define DIFF_PER_SEC 2.5f +#define DIFF_BALL_EVERY 25 +#define DIFF_EVIL_EVERY 50 +#define MAX_BALLS 2 +#define MAX_AIS 1 +#define SLOW_TIME 3.0f +#define SLOW_GRIP_SPEED 4.0f + + +#define LIFE_ICON_SCALE 1.0f +#define LIFE_ICON_PAD 2 + +typedef enum { + GS_WAITING, + GS_PLAYING, + GS_WON, + GS_LOST +} GameState; + +typedef struct Racket { + Vector3 grip; + Vector3 head; + Vector3 headVel; + float cooldown; + float yaw; + bool yawArmed; +} Racket; + +typedef struct Ball { + b3BodyId body; + bool evil; + bool playerLast; + float serveTimer; + float stallTimer; +} Ball; + +// Where each AI racket idles on its baseline, so a crowd of them spreads out. +static const float AI_HOME_X[] = { 0.0f, -2.6f, 2.6f, -1.3f, 1.3f, 3.5f }; + +typedef struct Game { + GameState state; + int difficulty; + + b3WorldId world; + + Racket player; + Racket ais[MAX_AIS]; + int aiCount; + + Ball balls[MAX_BALLS]; + int ballCount; + + Vector2 cursor; + + + float diff; + int milestones; + float slowTimer; + + int returns; + int scores; + int lives; + float timeLeft; + + Camera3D camera; + + Model racketModel; + int frameMat; + + Texture2D lifeTex; + Texture2D lifeTexGray; + + RenderTexture2D target; + + Music music; + Sound pongSound; // player's racket contact + Sound pingSound; // AI racket contact +} Game; + +static Game G; + +// --- small helpers ------------------------------------------------------- + +static Vector3 B2R(b3Vec3 v) { return (Vector3){ v.x, v.y, v.z }; } + +static float Clampf(float v, float lo, float hi) { + return v < lo ? lo : (v > hi ? hi : v); +} + +static float RandRange(float lo, float hi) { + return lo + (hi - lo) * ((float)rand() / (float)RAND_MAX); +} + +// --- difficulty scaling + +// 0..1 fraction of full difficulty, saturating at a setting of DIFF_MAX_AT. +static float DiffFrac(void) { + return Clampf(G.diff / (DIFF_MAX_AT * DIFF_SCALE), 0.0f, 1.0f); +} + +static float AISpeed(void) { + return AI_SPEED * (1.0f + 1.5f * DiffFrac()); // up to 2.5x at full difficulty +} + + +static float AILead(void) { + return 0.4f + 0.6f * DiffFrac(); // 0.4 baseline, full prediction at max +} + +// Multiplier on the ball's pace (AI returns only; serves stay at base pace). +static float BallPace(void) { + return 1.0f + 0.3f * DiffFrac(); // up to 1.3x at full difficulty +} + +// How often the AI angles its racket face on a return. +static float AIPivotChance(void) { + return 0.9f * DiffFrac(); // rare early, most returns at full difficulty +} + +// How far the AI is willing to angle the face when it does. +static float AIPivotMax(void) { + return 0.12f + 0.28f * DiffFrac(); // ~7 to ~23 degrees +} + +// --- physics scene construction ------------------------------------------ + +static void CreateStaticBox(b3WorldId world, b3Vec3 center, float hx, float hy, float hz, + float restitution) { + b3BodyDef bd = b3DefaultBodyDef(); + bd.type = b3_staticBody; + bd.position = center; + b3BodyId body = b3CreateBody(world, &bd); + + b3ShapeDef sd = b3DefaultShapeDef(); + sd.baseMaterial.restitution = restitution; + sd.baseMaterial.friction = 0.4f; + + b3BoxHull hull = b3MakeBoxHull(hx, hy, hz); + b3CreateHullShape(body, &sd, &hull.base); +} + + +static void ServeBall(Ball *b) { + b3Vec3 pos = { RandRange(-2.0f, 2.0f), 3.0f, AI_Z * 0.5f }; + b3Body_SetTransform(b->body, pos, b3Quat_identity); + b3Vec3 vel = { RandRange(-1.0f, 1.0f), 2.0f, 7.0f }; + b3Body_SetLinearVelocity(b->body, vel); + b3Body_SetAngularVelocity(b->body, b3Vec3_zero); + b->serveTimer = 0.0f; + b->stallTimer = 0.0f; + b->playerLast = false; +} + +static void AddBall(bool evil) { + if (G.ballCount >= MAX_BALLS) return; + + b3BodyDef bd = b3DefaultBodyDef(); + bd.type = b3_dynamicBody; + bd.position = (b3Vec3){ 0.0f, 3.0f, AI_Z * 0.5f }; + bd.enableSleep = false; + + Ball *b = &G.balls[G.ballCount++]; + b->body = b3CreateBody(G.world, &bd); + b->evil = evil; + + b3ShapeDef sd = b3DefaultShapeDef(); + sd.density = 1.0f; + sd.baseMaterial.restitution = 0.7f; + sd.baseMaterial.friction = 0.3f; + b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, BALL_R }; + b3CreateSphereShape(b->body, &sd, &sphere); + + ServeBall(b); +} + +static void AddAIRacket(void) { + if (G.aiCount >= MAX_AIS) return; + Racket *a = &G.ais[G.aiCount++]; + a->grip = (Vector3){ AI_HOME_X[G.aiCount - 1], 1.0f - RACKET_ROD_L, AI_Z }; + a->head = (Vector3){ a->grip.x, 1.0f, AI_Z }; + a->headVel = (Vector3){ 0 }; + a->cooldown = 0.0f; + a->yaw = 0.0f; + a->yawArmed = false; +} + +static void InitScene(void) { + + b3WorldDef wd = b3DefaultWorldDef(); + wd.gravity = (b3Vec3){ 0.0f, GRAVITY_Y, 0.0f }; + G.world = b3CreateWorld(&wd); + + // Floor + CreateStaticBox(G.world, (b3Vec3){ 0.0f, -0.5f, 0.0f }, + COURT_W * 0.5f + 2.0f, 0.5f, COURT_L * 0.5f + 2.0f, 0.55f); + + // Net + CreateStaticBox(G.world, (b3Vec3){ 0.0f, NET_H * 0.5f, 0.0f }, + COURT_W * 0.5f, NET_H * 0.5f, 0.05f, 0.3f); + + // Arena shell + float wallHz = COURT_L * 0.5f + 4.0f; + CreateStaticBox(G.world, (b3Vec3){ WALL_X + 0.5f, CEIL_H * 0.5f, 0.0f }, + 0.5f, CEIL_H * 0.5f + 1.0f, wallHz, 0.6f); + CreateStaticBox(G.world, (b3Vec3){ -(WALL_X + 0.5f), CEIL_H * 0.5f, 0.0f }, + 0.5f, CEIL_H * 0.5f + 1.0f, wallHz, 0.6f); + // CreateStaticBox(G.world, (b3Vec3){ 0.0f, CEIL_H + 0.5f, 0.0f }, + // WALL_X + 1.0f, 0.5f, wallHz, 0.6f); + // CreateStaticBox(G.world, (b3Vec3){ 0.0f, CEIL_H * 0.5f, BACK_Z - 0.5f }, + // WALL_X + 1.0f, CEIL_H * 0.5f + 1.0f, 0.5f, 0.6f); + + // Spawning + G.player.grip = (Vector3){ 0.0f, 1.0f - RACKET_ROD_L, PLAYER_Z }; + G.player.head = (Vector3){ 0.0f, 1.0f, PLAYER_Z }; + + // Camera behind and above the player, looking down the court. + G.camera.position = (Vector3){ 0.0f, 6.5f, 16.0f }; + G.camera.target = (Vector3){ 0.0f, 1.0f, 0.0f }; + G.camera.up = (Vector3){ 0.0f, 1.0f, 0.0f }; + G.camera.fovy = 55.0f; + G.camera.projection = CAMERA_PERSPECTIVE; +} + +static void StartMatch(int difficulty) { + G.difficulty = difficulty; + G.diff = difficulty * DIFF_SCALE; + G.milestones = 0; + G.slowTimer = 0.0f; + G.returns = 0; + G.scores = 0; + G.lives = MAX_LIVES; + G.timeLeft = TIME_LIMIT; + G.state = GS_PLAYING; + PlayMusicStream(G.music); + AddBall(false); + AddAIRacket(); + HarnessStarted("return!"); +} + +static void FinishMatch(bool win) { + if (G.state == GS_WON || G.state == GS_LOST) return; // report once + G.state = win ? GS_WON : GS_LOST; + HarnessDone(win ? 1 : 0); +} + +// --- gameplay update +static void SwingRacket(Racket *r, float dt, float damp) { + Vector3 upright = { r->grip.x, r->grip.y + RACKET_ROD_L, r->grip.z }; + Vector3 pull = Vector3Scale(Vector3Subtract(upright, r->head), SWAY_SPRING); + r->headVel = Vector3Add(r->headVel, Vector3Scale(pull, dt)); + r->headVel = Vector3Scale(r->headVel, 1.0f / (1.0f + damp * dt)); + + Vector3 prev = r->head; + r->head = Vector3Add(r->head, Vector3Scale(r->headVel, dt)); + + + Vector3 rod = Vector3Subtract(r->head, r->grip); + float len = Vector3Length(rod); + if (len > 0.0001f) { + r->head = Vector3Add(r->grip, Vector3Scale(rod, RACKET_ROD_L / len)); + } + r->headVel = Vector3Scale(Vector3Subtract(r->head, prev), 1.0f / dt); +} + + +static bool TryHit(Racket *r, Ball *b, bool towardNegZ, float aimX, float pace) { + if (r->cooldown > 0.0f) return false; + + b3Vec3 bp = b3Body_GetPosition(b->body); + b3Vec3 bv = b3Body_GetLinearVelocity(b->body); + + if (fabsf(bp.z - r->head.z) > HIT_Z_BAND) return false; + float approach = towardNegZ ? bv.z : -bv.z; // +Z for player, -Z for AI + if (approach <= 0.0f) return false; + float dx = bp.x - r->head.x; + float dy = bp.y - r->head.y; + if (dx * dx + dy * dy > HIT_REACH * HIT_REACH) return false; + + float zdir = towardNegZ ? -1.0f : 1.0f; + b3Vec3 v; + v.z = zdir * RETURN_SPEED * pace; + // Steer sideways by where the ball met the strings, then aim toward aimX. + v.x = dx * 4.0f + (aimX - bp.x) * 0.8f; + v.y = LIFT; + + // Let the head's swing add English to the shot (bounded, so a fast racket quickens the ball a little instead of launching it). + v.x += Clampf(r->headVel.x * PAD_INFLUENCE, -6.0f, 6.0f); + v.y += Clampf(-r->headVel.y * PAD_INFLUENCE, -1.0f, 4.0f); // swing up = more lift + v.z += Clampf(r->headVel.z * PAD_INFLUENCE, -8.0f, 8.0f); + + float yc = cosf(r->yaw), ys = sinf(r->yaw); + float vx = v.x * yc + v.z * ys; + v.z = -v.x * ys + v.z * yc; + v.x = vx; + +. + float t = ((towardNegZ ? AI_Z : PLAYER_Z) - bp.z) / v.z; + if (t > 0.0f) { + float vyMax = (REACH_TOP_Y - bp.y - 0.5f * GRAVITY_Y * t * t) / t; + if (v.y > vyMax) v.y = vyMax; + } + + b3Body_SetLinearVelocity(b->body, v); + b3Body_SetAngularVelocity(b->body, b3Vec3_zero); + + r->headVel.x += (bv.x - v.x) * HIT_RECOIL; + r->headVel.y += (bv.y - v.y) * HIT_RECOIL; + r->headVel.z += (bv.z - v.z) * HIT_RECOIL; + + r->cooldown = HIT_COOLDOWN; + return true; +} + + +static void UpdateAimCursor(void) { + + if (!IsCursorHidden() && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + DisableCursor(); + } + + + Vector2 d = GetMouseDelta(); + + // Left button held: sideways mouse motion turns the racket face + if (IsMouseButtonDown(MOUSE_BUTTON_LEFT)) { + G.player.yaw = Clampf(G.player.yaw - d.x * RES_W / (float)GetScreenWidth() * PIVOT_SENS, + -PIVOT_MAX, PIVOT_MAX); + return; + } + + G.cursor.x = Clampf(G.cursor.x + d.x * RES_W / (float)GetScreenWidth(), + 0.0f, (float)RES_W); + G.cursor.y = Clampf(G.cursor.y + d.y * RES_H / (float)GetScreenHeight(), + 0.0f, (float)RES_H); +} + +static void UpdatePlayerRacket(float dt) { + Racket *p = &G.player; + + + Ray ray = GetScreenToWorldRayEx(G.cursor, G.camera, RES_W, RES_H); + float along = (PLAYER_Z - ray.position.z) / ray.direction.z; + Vector3 aim = Vector3Add(ray.position, Vector3Scale(ray.direction, along)); + aim.x = Clampf(aim.x, -(COURT_W * 0.5f + 1.5f), COURT_W * 0.5f + 1.5f); + aim.y = Clampf(aim.y, 0.3f, 3.6f); + Vector3 want = { aim.x, aim.y - RACKET_ROD_L, PLAYER_Z }; + + if (G.slowTimer > 0.0f) { + G.slowTimer -= dt; + Vector3 to = Vector3Subtract(want, p->grip); + float d = Vector3Length(to); + float maxStep = SLOW_GRIP_SPEED * dt; + if (d > maxStep && d > 0.0001f) { + want = Vector3Add(p->grip, Vector3Scale(to, maxStep / d)); + } + } + p->grip = want; + + // Left click: instantly straighten the racket, killing any sway. + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + p->yaw = 0.0f; + p->head = (Vector3){ p->grip.x, p->grip.y + RACKET_ROD_L, p->grip.z }; + p->headVel = (Vector3){ 0 }; + } + // Released: the face eases back square. + if (!IsMouseButtonDown(MOUSE_BUTTON_LEFT)) { + p->yaw *= 1.0f / (1.0f + PIVOT_RELAX * dt); + } + SwingRacket(p, dt, SWAY_DAMP); + + if (p->cooldown > 0.0f) p->cooldown -= dt; + for (int i = 0; i < G.ballCount; i++) { + Ball *b = &G.balls[i]; + if (!TryHit(p, b, true, 0.0f, 1.0f)) continue; + PlaySound(G.pongSound); + if (b->evil) { + G.slowTimer = SLOW_TIME; // touched the red ball: cursed + } else { + G.returns++; + b->playerLast = true; + } + } +} + + +static Vector2 PredictBallXY(b3Vec3 bp, b3Vec3 bv, float t) { + float g = -GRAVITY_Y; // positive-down + float y = bp.y + bv.y * t - 0.5f * g * t * t; + if (y < BALL_R) { + float disc = bv.y * bv.y + 2.0f * g * (bp.y - BALL_R); + if (disc <= 0.0f) { + y = BALL_R; + } else { + float tb = (bv.y + sqrtf(disc)) / g; // when it meets the floor + float vy = (g * tb - bv.y) * 0.55f; // rebound (floor restitution) + float tr = t - tb; + y = BALL_R + vy * tr - 0.5f * g * tr * tr; + if (y < BALL_R) y = BALL_R; // don't bother predicting a second bounce + } + } + float x = bp.x + bv.x * t; + float w = WALL_X - BALL_R; + if (x > w) x = 2.0f * w - x; // mirror off the side walls + if (x < -w) x = -2.0f * w - x; + return (Vector2){ x, y }; +} + +static void UpdateAIRacket(Racket *a, float homeX, float dt) { + + Vector3 target = { homeX, 1.0f, AI_Z }; + float best = 1e9f; + for (int i = 0; i < G.ballCount; i++) { + b3Vec3 bp = b3Body_GetPosition(G.balls[i].body); + b3Vec3 bv = b3Body_GetLinearVelocity(G.balls[i].body); + if (bv.z >= 0.0f || bp.z < AI_Z - 1.0f) continue; // not incoming + // Reaction time + if (bp.z >= 2.0f + (PLAYER_Z - 2.0f) * AILead()) continue; + float dx = bp.x - a->head.x, dz = bp.z - AI_Z; + float score = dx * dx + dz * dz; + if (score < best) { + best = score; + float t = Clampf((AI_Z - bp.z) / bv.z * AILead(), 0.0f, 1.2f); + Vector2 hit = PredictBallXY(bp, bv, t); + target = (Vector3){ Clampf(hit.x, -COURT_W * 0.5f, COURT_W * 0.5f), + Clampf(hit.y, 0.4f, 3.0f), AI_Z }; + } + } + target.y -= RACKET_ROD_L; + + // Once per incoming ball, maybe angle the racket face so the return comes + // off at an angle instead of straight back. + if (best < 1e9f) { + if (!a->yawArmed) { + a->yawArmed = true; + a->yaw = 0.0f; + if (RandRange(0.0f, 1.0f) < AIPivotChance()) { + float sign = (RandRange(0.0f, 1.0f) < 0.5f) ? -1.0f : 1.0f; + // Skilled AIs angle with intent: deflect the shot away from + // wherever the player's racket is right now. + if (RandRange(0.0f, 1.0f) < DiffFrac()) { + sign = (G.player.head.x >= 0.0f) ? -1.0f : 1.0f; + } + a->yaw = sign * RandRange(0.4f, 1.0f) * AIPivotMax(); + } + } + } else { + a->yawArmed = false; + a->yaw *= 1.0f / (1.0f + PIVOT_RELAX * dt); + } + + Vector3 to = Vector3Subtract(target, a->grip); + float maxStep = AISpeed() * dt; // faster hands as difficulty climbs + float d = Vector3Length(to); + if (d > maxStep && d > 0.0001f) to = Vector3Scale(to, maxStep / d); + a->grip = Vector3Add(a->grip, to); + a->grip.z = AI_Z; + // Steadier hands as difficulty climbs + SwingRacket(a, dt, SWAY_DAMP + 20.0f * AILead()); + + if (a->cooldown > 0.0f) a->cooldown -= dt; + + float aimX = RandRange(-COURT_W * 0.3f, COURT_W * 0.3f); + for (int i = 0; i < G.ballCount; i++) { + if (TryHit(a, &G.balls[i], false, aimX, BallPace())) { + PlaySound(G.pingSound); + G.balls[i].playerLast = false; + break; + } + } +} + +static void UpdateBallState(Ball *b, float dt) { + b3Vec3 bp = b3Body_GetPosition(b->body); + b3Vec3 bv = b3Body_GetLinearVelocity(b->body); + + if (bp.z > PLAYER_Z + 1.2f) { + if (b->serveTimer <= 0.0f) { + if (!b->evil) { + G.lives--; + if (G.lives <= 0) { + FinishMatch(false); + return; + } + } + b->serveTimer = 0.8f; + } + return; + } + + // Dead ball + float speed = sqrtf(bv.x * bv.x + bv.y * bv.y + bv.z * bv.z); + if (bp.y < 0.4f && speed < 1.2f) b->stallTimer += dt; else b->stallTimer = 0.0f; + + bool dead = (bp.y < -1.0f) || + (fabsf(bp.x) > WALL_X + 1.5f) || + (bp.z < BACK_Z - 1.5f) || + (b->stallTimer > 1.5f); + + if (dead && b->serveTimer <= 0.0f) { + // The ball died in the AI's half off the player's racket: point won. + if (b->playerLast && !b->evil && bp.z < 0.0f) { + G.scores++; + if (G.scores % LIFE_EVERY_SCORES == 0 && G.lives < MAX_LIVES) { + G.lives++; // every few points won earns a lost life back + } + } + b->serveTimer = 0.8f; // brief pause before the next serve + } +} + +static void UpdatePlaying(float dt) { + G.timeLeft -= dt; + if (G.timeLeft <= 0.0f) { + G.timeLeft = 0.0f; + FinishMatch(true); + return; + } + + + G.diff += DIFF_PER_SEC * DIFF_SCALE * dt; + while (G.milestones < (int)(G.diff / DIFF_BALL_EVERY)) { + G.milestones++; + AddBall(false); + AddAIRacket(); + if ((G.milestones * DIFF_BALL_EVERY) % DIFF_EVIL_EVERY == 0) { + AddBall(false); + } + } + + for (int i = 0; i < G.ballCount; i++) { + Ball *b = &G.balls[i]; + if (b->serveTimer > 0.0f) { + b->serveTimer -= dt; + if (b->serveTimer <= 0.0f) ServeBall(b); + } + } + + UpdatePlayerRacket(dt); + for (int i = 0; i < G.aiCount; i++) { + UpdateAIRacket(&G.ais[i], AI_HOME_X[i], dt); + } + + b3World_Step(G.world, dt, 4); + + for (int i = 0; i < G.ballCount; i++) { + UpdateBallState(&G.balls[i], dt); + } +} + +// --- rendering + +static void LoadRacketModel(void) { + G.racketModel = LoadModel(ASSETS_DIR "/racket.glb"); + // Find the frame material (the blue one) so each side can wear its colour. + G.frameMat = -1; + for (int i = 0; i < G.racketModel.materialCount; i++) { + Color c = G.racketModel.materials[i].maps[MATERIAL_MAP_ALBEDO].color; + if (c.b > c.r && c.b > c.g) G.frameMat = i; + } +} + + +static void LoadLifeIcons(void) { + Image img = LoadImage(ASSETS_DIR "/mrgreen.png"); + ImageFormat(&img, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8); + G.lifeTex = LoadTextureFromImage(img); + + Color *px = (Color *)img.data; + for (int i = 0; i < img.width * img.height; i++) { + unsigned char v = (unsigned char)((px[i].r * 30 + px[i].g * 59 + px[i].b * 11) / 200); + px[i] = (Color){ v, v, v, px[i].a }; + } + G.lifeTexGray = LoadTextureFromImage(img); + UnloadImage(img); +} + +static void DrawLives(void) { + float step = G.lifeTex.width * LIFE_ICON_SCALE + LIFE_ICON_PAD; + for (int i = 0; i < MAX_LIVES; i++) { + Texture2D t = (i < G.lives) ? G.lifeTex : G.lifeTexGray; + DrawTextureEx(t, (Vector2){ 4.0f + i * step, 4.0f }, 0.0f, + LIFE_ICON_SCALE, WHITE); + } +} + +static void DrawRacket(Racket *r, Color face, Color edge) { + if (G.racketModel.meshCount == 0) { + // Model failed to load: fall back to the original paddle shape. + DrawCube(r->head, PAD_HX * 2, PAD_HY * 2, PAD_HZ * 2, Fade(face, 0.55f)); + DrawCubeWires(r->head, PAD_HX * 2, PAD_HY * 2, PAD_HZ * 2, edge); + return; + } + if (G.frameMat >= 0) { + G.racketModel.materials[G.frameMat].maps[MATERIAL_MAP_ALBEDO].color = face; + } + + Vector3 dir = Vector3Scale(Vector3Subtract(r->head, r->grip), 1.0f / RACKET_ROD_L); + Vector3 axis = Vector3CrossProduct((Vector3){ 0.0f, 1.0f, 0.0f }, dir); + if (Vector3LengthSqr(axis) < 1e-8f) axis = (Vector3){ 1.0f, 0.0f, 0.0f }; // upright + float angle = RAD2DEG * acosf(Clampf(dir.y, -1.0f, 1.0f)); + // Spin the model about its own handle so the face angle shows. + G.racketModel.transform = MatrixRotateY(r->yaw); + DrawModelEx(G.racketModel, r->grip, axis, angle, + (Vector3){ RACKET_SCALE, RACKET_SCALE, RACKET_SCALE }, WHITE); +} + +static void DrawScene(void) { + // Everything renders into the tiny RES_W x RES_H frame... + BeginTextureMode(G.target); + ClearBackground((Color){ 30, 34, 45, 255 }); + + BeginMode3D(G.camera); + + // Court surface. + DrawCube((Vector3){ 0, -0.01f, 0 }, COURT_W, 0.02f, COURT_L, (Color){ 40, 110, 70, 255 }); + // Court boundary + centre line. + DrawCubeWires((Vector3){ 0, 0.02f, 0 }, COURT_W, 0.02f, COURT_L, RAYWHITE); + DrawLine3D((Vector3){ -COURT_W * 0.5f, 0.02f, 0 }, + (Vector3){ COURT_W * 0.5f, 0.02f, 0 }, Fade(RAYWHITE, 0.7f)); + + // Net. + DrawCube((Vector3){ 0, NET_H * 0.5f, 0 }, COURT_W, NET_H, 0.06f, Fade(SKYBLUE, 0.35f)); + DrawCubeWires((Vector3){ 0, NET_H * 0.5f, 0 }, COURT_W, NET_H, 0.06f, RAYWHITE); + + // Arena shell, faint so it reads as a boundary without stealing the scene. + float wallZc = (BACK_Z + PLAYER_Z) * 0.5f; + float wallZl = PLAYER_Z - BACK_Z; + DrawCube((Vector3){ WALL_X, CEIL_H * 0.5f, wallZc }, + 0.05f, CEIL_H, wallZl, Fade(SKYBLUE, 0.10f)); + DrawCube((Vector3){ -WALL_X, CEIL_H * 0.5f, wallZc }, + 0.05f, CEIL_H, wallZl, Fade(SKYBLUE, 0.10f)); + DrawCube((Vector3){ 0, CEIL_H * 0.5f, BACK_Z }, + WALL_X * 2.0f, CEIL_H, 0.05f, Fade(SKYBLUE, 0.10f)); + DrawCubeWires((Vector3){ 0, CEIL_H * 0.5f, wallZc }, + WALL_X * 2.0f, CEIL_H, wallZl, Fade(RAYWHITE, 0.18f)); + + // Balls + shadows. Evil ones wear red. + for (int i = 0; i < G.ballCount; i++) { + Vector3 bp = B2R(b3Body_GetPosition(G.balls[i].body)); + Color c = G.balls[i].evil ? (Color){ 230, 40, 40, 255 } + : (Color){ 220, 230, 60, 255 }; + DrawSphere(bp, BALL_R, c); + DrawCircle3D((Vector3){ bp.x, 0.02f, bp.z }, BALL_R, (Vector3){ 1, 0, 0 }, + 90.0f, Fade(BLACK, 0.35f)); + } + + // The player's racket goes icy while an evil ball has it slowed. + bool slowed = G.slowTimer > 0.0f; + DrawRacket(&G.player, slowed ? SKYBLUE : LIME, slowed ? DARKBLUE : GREEN); + for (int i = 0; i < G.aiCount; i++) { + DrawRacket(&G.ais[i], RED, MAROON); + } + + EndMode3D(); + + + if (G.state == GS_WAITING) { + const char *msg = "Get ready..."; + DrawText(msg, RES_W / 2 - MeasureText(msg, 20) / 2, + RES_H / 2 - 10, 20, RAYWHITE); + } else if (G.state == GS_PLAYING) { + DrawLives(); + + DrawText(TextFormat("Time: %.1f", G.timeLeft), + RES_W - 64, RES_H - 12, 10, + G.timeLeft < 4.0f ? RED : RAYWHITE); + if (G.slowTimer > 0.0f) { + const char *slow = "SLOWED!"; + DrawText(slow, RES_W / 2 - MeasureText(slow, 20) / 2, + RES_H - 40, 20, SKYBLUE); + } + int cx = (int)G.cursor.x, cy = (int)G.cursor.y; + DrawLine(cx - 3, cy, cx + 4, cy, RAYWHITE); + DrawLine(cx, cy - 3, cx, cy + 4, RAYWHITE); + } else { + DrawLives(); // freeze the row so a loss shows the last face going dark + const char *msg = (G.state == GS_WON) ? "YOU SURVIVED!" : "OUT OF LIVES!"; + Color col = (G.state == GS_WON) ? LIME : RED; + DrawText(msg, RES_W / 2 - MeasureText(msg, 20) / 2, + RES_H / 2 - 10, 20, col); + } + + EndTextureMode(); + + + BeginDrawing(); + ClearBackground(BLACK); + DrawTexturePro(G.target.texture, + (Rectangle){ 0, 0, (float)RES_W, -(float)RES_H }, + (Rectangle){ 0, 0, (float)GetScreenWidth(), (float)GetScreenHeight() }, + (Vector2){ 0, 0 }, 0.0f, WHITE); + EndDrawing(); +} + + +static void UpdateDrawFrame(void) { + if (IsMusicValid(G.music)) UpdateMusicStream(G.music); // feed the stream + + UpdateAimCursor(); + + // stuff to stop the browser from rendering at 144hz and beyond + static float acc = 0.0f; + acc += GetFrameTime(); + if (acc > 0.25f) acc = 0.25f; + + while (acc >= FIXED_DT) { + acc -= FIXED_DT; + switch (G.state) { + case GS_WAITING: { + int d = HarnessPollStart(); + if (d >= 0) StartMatch(d); + break; + } + case GS_PLAYING: + UpdatePlaying(FIXED_DT); + break; + default: + b3World_Step(G.world, FIXED_DT, 4); + break; + } + } + DrawScene(); +} + +int main(void) { + SetConfigFlags(FLAG_WINDOW_RESIZABLE); + InitWindow(1920, 1080, "Clonk Tennis"); + InitAudioDevice(); + + G.music = LoadMusicStream(ASSETS_DIR "/music.mp3"); + G.pongSound = LoadSound(ASSETS_DIR "/mypong.mp3"); + G.pingSound = LoadSound(ASSETS_DIR "/enemyping.mp3"); + + G.target = LoadRenderTexture(RES_W, RES_H); + SetTextureFilter(G.target.texture, TEXTURE_FILTER_POINT); + + //z fighting nonsense + rlSetClipPlanes(0.5, 100.0); + + InitScene(); + LoadRacketModel(); + LoadLifeIcons(); + G.state = GS_WAITING; + + + G.cursor = (Vector2){ RES_W * 0.5f, RES_H * 0.5f }; + DisableCursor(); +#if !defined(PLATFORM_WEB) + + if (glfwRawMouseMotionSupported()) { + glfwSetInputMode(GetWindowHandle(), GLFW_RAW_MOUSE_MOTION, GLFW_FALSE); + } +#endif + + HarnessInit(); + HarnessReady(); + + +#if defined(PLATFORM_WEB) + emscripten_set_main_loop(UpdateDrawFrame, 0, 1); +#else + SetTargetFPS(60); + while (!WindowShouldClose()) { + UpdateDrawFrame(); + } +#endif + + b3DestroyWorld(G.world); + UnloadModel(G.racketModel); + UnloadTexture(G.lifeTex); + UnloadTexture(G.lifeTexGray); + UnloadSound(G.pongSound); + UnloadSound(G.pingSound); + UnloadMusicStream(G.music); + CloseAudioDevice(); + CloseWindow(); + return 0; +} |
