contrib/tinks: intro 3D client
[nit.git] / contrib / tinks / src / client / client3d.nit
diff --git a/contrib/tinks/src/client/client3d.nit b/contrib/tinks/src/client/client3d.nit
new file mode 100644 (file)
index 0000000..f408ec8
--- /dev/null
@@ -0,0 +1,542 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# 3D client for Tinks!
+module client3d is
+       app_name "Tinks! 3D"
+       app_namespace "org.nitlanguage.tinks3d"
+       app_version(1, 0, git_revision)
+
+       android_api_target 15
+       android_manifest """<uses-permission android:name="android.permission.INTERNET" />"""
+end
+
+import gamnit::depth
+import gamnit::keys
+import app::audio
+
+import base
+
+redef class App
+
+       # ---
+       # Config
+
+       # Maximum distance from the camera to hear events and display explosions
+       private var far_dist2 = 2000.0
+
+       # Approximate maximum distance from the camera to display features
+       private var features_radius = 24
+
+       # ---
+       # Models
+
+       # Models of rocks
+       var models_rock = new Array[Model].with_items(
+               new Model("models/Tall_Rock_1_01.obj"),
+               new Model("models/Tall_Rock_2_01.obj"),
+               new Model("models/Tall_Rock_3_01.obj"),
+               new Model("models/Tall_Rock_4_01.obj"))
+
+       # Models of trees
+       var models_tree = new Array[Model].with_items(
+               new Model("models/Oak_Dark_01.obj"),
+               new Model("models/Oak_Green_01.obj"),
+               new Model("models/Large_Oak_Dark_01.obj"),
+               new Model("models/Large_Oak_Green_01.obj"))
+
+       # Models of the debris left by a destroyed tank
+       var models_debris = new Array[Model].with_items(
+               new Model("models/debris0.obj"),
+               new Model("models/debris1.obj"))
+
+       # Model the health pickup
+       var model_health = new Model("models/health.obj")
+
+       # Model of the tank base (without the turret)
+       var model_tank_base = new Model("models/tank.obj")
+
+       # Model of the tank turret
+       var model_tank_turret = new Model("models/tank-turret.obj")
+
+       # Blast effect on the ground after an explosion
+       private var blast_texture = new Texture("textures/blast.png")
+       private var blast_material: TexturedMaterial do
+               var mat = new TexturedMaterial([1.0]*4, [0.0]*4, [0.0]*4)
+               mat.ambient_texture = blast_texture
+               return mat
+       end
+       private var blast_model = new LeafModel(new Plane, blast_material)
+
+       # ---
+       # Particle effects
+
+       # Explosion image for particle effect
+       private var texture_explosion = new Texture("particles/explosion00.png")
+
+       # Explosion particles
+       var explosion_system = new ParticleSystem(20, explosion_program, texture_explosion)
+
+       # Explosion image for particle effect
+       private var texture_smoke = new Texture("particles/blackSmoke12.png")
+
+       # Explosion particles
+       var smoke_system = new ParticleSystem(200, smoke_program, texture_smoke)
+
+       # ---
+       # Sounds
+
+       # Firing sound
+       var turret_fire = new Sound("sounds/turret_fire.wav")
+
+       # Turret is ready to fire sound
+       var turret_ready = new Sound("sounds/turret_ready.mp3")
+
+       # ---
+       # Scene objects
+
+       # Ground
+       var ground: Actor is noinit
+       private var ground_texture = new Texture("textures/fastgras01.png")
+
+       # All `Feature` with an associated model, to be drawn on screen
+       var features_in_sight = new Set[Feature]
+
+       redef fun on_create
+       do
+               super
+
+               # Show splash screen
+               var logo = new Texture("textures/splash.png")
+               show_splash_screen logo
+
+               # Load everything
+               for model in models do model.load
+               for texture in all_root_textures do texture.load
+
+               # Modify all textures so they have a higher ambient color
+               for model in models do
+                       for leaf in model.leaves do
+                               var mat = leaf.material
+                               if mat isa TexturedMaterial then
+                                       var mod = 0.25
+                                       mat.ambient_color[0] = mat.diffuse_color[0] * mod
+                                       mat.ambient_color[1] = mat.diffuse_color[1] * mod
+                                       mat.ambient_color[2] = mat.diffuse_color[2] * mod
+
+                                       var tex = mat.diffuse_texture
+                                       if tex != null then mat.ambient_texture = tex
+                               end
+                       end
+               end
+
+               # Setup ground
+               # TODO we may need to move this plane if the player goes far from the center
+               var ground_mesh = new Plane
+               ground_mesh.repeat_x = 1000.0
+               ground_mesh.repeat_y = 1000.0
+
+               var ground_material = new TexturedMaterial(
+                       [0.0, 0.1, 0.0, 1.0], [0.4, 0.4, 0.4, 1.0], [0.0]*4)
+               ground_material.diffuse_texture = ground_texture
+
+               var ground_model = new LeafModel(ground_mesh, ground_material)
+               var ground = new Actor(ground_model, new Point3d[Float](0.0, 0.0, 0.0))
+               ground.scale = 5000.0
+
+               self.ground = ground
+               actors.add ground
+
+               # Config the view
+               world_camera.near = 0.1
+
+               # Sky color
+               glClearColor(100.0/256.0, 120.0/256.0, 224.0/256.0, 1.0)
+
+               # Move the sun a bit off right above
+               light.position.x = 1000.0
+               light.position.z = 500.0
+
+               # Register our two systems
+               particle_systems.add explosion_system
+               particle_systems.add smoke_system
+
+               # Connect to server (or launch one) and assign models to rules
+               var context = context
+               context.game.story.assign_models
+       end
+
+       redef fun update(dt)
+       do
+               # Game logic
+               var turn = context.do_turn
+               for event in turn.events do event.client_react
+
+               # Is the player alive?
+               var local_player = context.local_player
+               var local_tank = null
+               if local_player != null then local_tank = local_player.tank
+
+               if local_tank != null then
+                       # Update camera position above the tank
+                       var pos = local_tank.pos
+                       world_camera.position.x = pos.x
+                       world_camera.position.z = pos.y
+                       world_camera.yaw = 1.5 * pi - local_tank.heading
+                       world_camera.position.y = 1.8
+               end
+       end
+
+       redef fun accept_event(event)
+       do
+               # Let `pressed_keys` be populated first
+               var s = super
+
+               var local_player = context.local_player
+               var local_tank = null
+               if local_player != null then local_tank = local_player.tank
+
+               # Quit game?
+               if event isa QuitEvent or (event isa KeyEvent and event.name == "escape") then
+                       exit 0
+               end
+
+               if event isa KeyEvent then
+                       # Move tank?
+                       var direction_change = ["w", "a", "s", "d"].has(event.name)
+                       if direction_change and local_tank != null and local_player != null then
+                               var forward = pressed_keys.has("w")
+                               var backward = pressed_keys.has("s")
+                               var left = pressed_keys.has("a")
+                               var right = pressed_keys.has("d")
+
+                               # Cancel contradictory commands
+                               if forward and backward then
+                                       forward = false
+                                       backward = false
+                               end
+
+                               if left and right then
+                                       left = false
+                                       right = false
+                               end
+
+                               # Set movement and direction
+                               var move = 0.0
+                               if forward then
+                                       move = 0.5
+                               else if backward then move = -0.5
+
+                               var ori = 0.0
+                               if left then
+                                       ori = -local_tank.rule.max_direction/2.0
+                               else if right then ori = local_tank.rule.max_direction/2.0
+
+                               # Activate to invert the orientation on reverse, (for @R4p4Ss)
+                               #if backward then ori = -ori
+
+                               # Bonus when only moving or only turning
+                               if not forward and not backward then ori *= 2.0
+                               if not left and not right then move *= 2.0
+
+                               # Give order
+                               local_player.orders.add new TankDirectionOrder(local_tank, ori, move)
+                               return true
+                       end
+
+                       # Open fire?
+                       if event.name == "space" and local_tank != null and event.is_down then
+                               if local_player == null then return false
+
+                               # Open fire
+                               var heading = local_tank.heading
+                               var dist = 200.0
+                               var target = new Pos(local_tank.pos.x + dist*heading.cos, local_tank.pos.y + dist*heading.sin)
+                               local_player.orders.add new AimAndFireOrder(local_tank, target)
+                       end
+               end
+
+               # Open fire with a target?
+               if event isa PointerEvent and event.pressed and not event.is_move then
+                       if local_player == null then return false
+
+                       var display = display
+                       if display == null then return false
+
+                       if local_tank == null then
+                               local_player.orders.add new SpawnTankOrder(local_player)
+                               return true
+                       end
+
+                       # Compute approximate target
+                       var dx = event.x / display.width.to_f
+                       dx = dx * 2.0 - 1.0 # center of the screen
+                       var fovx = display.aspect_ratio * world_camera.field_of_view_y
+                       fovx *= 0.8
+                       var heading = local_tank.heading + dx * fovx
+
+                       var dy = event.y / display.height.to_f
+                       dy = dy - 0.5
+                       if dy >= 0.0 then
+                               var ty = dy * world_camera.field_of_view_y
+                               var dist = world_camera.position.y / ty.tan / 1.6
+                               if dist > 200.0 then dist = 200.0
+
+                               # Issue fire order
+                               var target = new Pos(local_tank.pos.x + dist*heading.cos, local_tank.pos.y + dist*heading.sin)
+                               local_player.orders.add new AimAndFireOrder(local_tank, target)
+                       end
+               end
+
+               return s
+       end
+end
+
+# ---
+# Story and rules (meta game objects)
+
+redef class FeatureRule
+       # Models of different alternatives
+       var models: Array[Model] is noinit
+end
+
+redef class TankRule
+       # Models of the tank base
+       var base_model: Model is noinit
+
+       # Models of the turret
+       var turret_model: Model is noinit
+end
+
+redef class Story
+
+       # Assign models from `app` to the corresponding rules
+       fun assign_models
+       do
+               tree.models = app.models_tree
+               rock.models = app.models_rock
+               debris.models = app.models_debris
+
+               for tank in tanks do
+                       tank.base_model = app.model_tank_base
+                       tank.turret_model = app.model_tank_turret
+               end
+
+               health.models = [app.model_health]
+       end
+end
+
+# ---
+# Game entities
+
+redef class Feature
+       # Actor representing this feature, if in sight
+       var actor: nullable Actor = null
+
+       # Instantiate `actor` and add it to the 3D world
+       fun add_actor_to_scene
+       do
+               app.features_in_sight.add self
+
+               var actor = actor
+               if actor != null then
+                       # Reuse existing actor
+                       if not app.actors.has(actor) then app.actors.add actor
+                       return
+               end
+
+               # Apply a random model and rotation to new features
+               actor = new Actor(rule.models.rand,
+                       new Point3d[Float](pos.x, 0.0, pos.y))
+               actor.rotation = 2.0*pi.rand
+               actor.scale = 0.75
+
+               self.actor = actor
+               app.actors.add actor
+       end
+
+       # Remove `actor` from the `actors` list as it will net be used anymore
+       fun destroy_actor
+       do
+               var actor = actor
+               if actor != null then
+                       app.actors.remove actor
+                       self.actor = null
+               end
+       end
+end
+
+redef class Tank
+       # Actors representing this tank, both the base and the turret
+       var actors: Array[Actor] is lazy do
+               var actors = new Array[Actor]
+               var actor = new Actor(app.model_tank_base, new Point3d[Float](0.0, 0.0, 0.0))
+               app.actors.add actor
+               actors.add actor
+
+               var tank_turret = new Actor(app.model_tank_turret, new Point3d[Float](0.0, 0.0, 0.0))
+               app.actors.add tank_turret
+               actors.add tank_turret
+               return actors
+       end
+end
+
+# ---
+# Events
+
+redef class TEvent
+       private fun client_react do end
+end
+
+redef class FeatureChangeEvent
+       redef fun client_react
+       do
+               var old_feature = old_feature
+               if old_feature != null then old_feature.destroy_actor
+
+               var feature = feature
+               if feature != null then feature.add_actor_to_scene
+       end
+end
+
+redef class ExplosionEvent
+       redef fun client_react
+       do
+               for feature in destroyed_features do feature.destroy_actor
+
+               # Particles
+               app.explosion_system.add(new Point3d[Float](pos.x, 1.0, pos.y), 4096.0, 0.3)
+               for i in 8.times do
+                       app.explosion_system.add(
+                               new Point3d[Float](pos.x & 1.0, 1.0 & 1.0, pos.y & 1.0),
+                               2048.0 & 1024.0, 0.3 & 0.1)
+               end
+
+               # Blast mark on the ground
+               var blast = new Actor(app.blast_model, new Point3d[Float](pos.x, 0.05 & 0.04, pos.y))
+               blast.scale = 3.0
+               blast.rotation = 2.0*pi.rand
+               app.actors.add blast
+
+               # Smoke
+               for s in 32.times do
+                       var dt = 0.2 * s.to_f + 0.1.rand
+                       app.smoke_system.add(
+                               new Point3d[Float](pos.x & 0.2, 0.0, pos.y & 0.2),
+                               1024.0 & 512.0, 10.0 & 4.0, dt)
+               end
+       end
+end
+
+redef class OpenFireEvent
+       redef fun client_react
+       do
+               if tank.pos.dist2_3d(app.world_camera.position) < app.far_dist2 then
+                       # Within earshot
+                       app.turret_fire.play
+
+                       # Particle
+                       var d = 1.7
+                       var a = tank.turret.heading - 0.025 # Correct to center the art
+                       var pos = new Point3d[Float](tank.pos.x + d*a.cos, 1.25, tank.pos.y + d*a.sin)
+                       app.explosion_system.add(pos, 0.75*256.0, 0.15)
+               end
+       end
+end
+
+redef class TurretReadyEvent
+       redef fun client_react
+       do
+               if tank.pos.dist2_3d(app.world_camera.position) < app.far_dist2 then
+                       # Within earshot
+                       app.turret_ready.play
+               end
+       end
+end
+
+redef class TankMoveEvent
+       redef fun client_react
+       do
+               var pos = tank.pos
+               for actor in tank.actors do
+                       actor.center.x = pos.x
+                       actor.center.z = pos.y
+               end
+
+               tank.actors[0].rotation = tank.heading + pi
+               tank.actors[1].rotation = tank.turret.heading + pi
+
+               # Keep going only for the local tank
+               var local_player = app.context.local_player
+               if local_player != tank.player then return
+
+               var center = tank.pos
+               var d = app.features_radius
+               var l = center.x.to_i - d
+               var r = center.x.to_i + d
+               var t = center.y.to_i - d
+               var b = center.y.to_i + d
+
+               # Remove out of range features
+               for feature in app.features_in_sight.to_a do
+                       var x = feature.pos.x.to_i
+                       var y = feature.pos.y.to_i
+                       if x < l or x > r or y < t or y > b then
+                               var actor = feature.actor
+                               app.actors.remove actor
+                               app.features_in_sight.remove feature
+                       end
+               end
+
+               # Add newly in range features
+               for x in [l..r[ do
+                       for y in [t..b[ do
+                               var feature = app.context.game.world[x, y]
+                               if feature != null then
+                                       feature.add_actor_to_scene
+                               end
+                       end
+               end
+       end
+end
+
+redef class TankDeathEvent
+       redef fun client_react
+       do
+               for actor in tank.actors do app.actors.remove actor
+               tank.actors.clear
+       end
+end
+
+# ---
+# Misc services
+
+redef class Point[N]
+       # Square of the distance to 3D coordinates `other`
+       #
+       # Same as `dist2` but using `other.z` as the Y value
+       # to adapt from the flat plane on X/Y to X/Z.
+       private fun dist2_3d(other: Point3d[Numeric]): N
+       do
+               var dx = other.x.sub(x)
+               var dy = other.z.sub(y)
+               var s = (dx.mul(dx)).add(dy.mul(dy))
+               return x.value_of(s)
+       end
+end
+
+redef class Float
+       # Fuzzy value in `[self-variation..self+variation]`
+       fun &(variation: Float): Float do return self - variation + 2.0*variation.rand
+end