From 46a887cbd9540540b1b63b14efdc0a3efac86b54 Mon Sep 17 00:00:00 2001 From: Jurien Meerlo Date: Tue, 10 Sep 2024 23:46:44 +0100 Subject: [PATCH] started on rendering. --- extraParams.hxml | 1 + hxml/build.hxml | 4 + hxml/test.hxml | 3 +- src/jume/graphics/Pipeline.hx | 179 ++++++++++++++++ src/jume/graphics/Shader.hx | 58 +++++ src/jume/graphics/ShaderType.hx | 9 + src/jume/graphics/gl/BlendMode.hx | 18 ++ src/jume/graphics/gl/BlendOperation.hx | 10 + src/jume/graphics/gl/Context.hx | 205 ++++++++++++++++++ src/jume/graphics/gl/MipmapFilter.hx | 10 + src/jume/graphics/gl/TextureFilter.hx | 10 + src/jume/graphics/gl/TextureWrap.hx | 10 + src/jume/utils/Bitset.hx | 50 +++++ src/jume/utils/Macros.hx | 10 + src/jume/view/ScaleModes.hx | 199 ++++++++++++++++++ src/jume/view/View.hx | 269 ++++++++++++++++++++++++ tests/unit/UnitTests.hx | 7 + tests/unit/jume/utils/BitsetTests.hx | 42 ++++ tests/unit/jume/view/ScaleModesTests.hx | 264 +++++++++++++++++++++++ tests/unit/jume/view/ViewTests.hx | 59 ++++++ 20 files changed, 1416 insertions(+), 1 deletion(-) create mode 100644 extraParams.hxml create mode 100644 src/jume/graphics/Pipeline.hx create mode 100644 src/jume/graphics/Shader.hx create mode 100644 src/jume/graphics/ShaderType.hx create mode 100644 src/jume/graphics/gl/BlendMode.hx create mode 100644 src/jume/graphics/gl/BlendOperation.hx create mode 100644 src/jume/graphics/gl/Context.hx create mode 100644 src/jume/graphics/gl/MipmapFilter.hx create mode 100644 src/jume/graphics/gl/TextureFilter.hx create mode 100644 src/jume/graphics/gl/TextureWrap.hx create mode 100644 src/jume/utils/Bitset.hx create mode 100644 src/jume/view/ScaleModes.hx create mode 100644 src/jume/view/View.hx create mode 100644 tests/unit/jume/utils/BitsetTests.hx create mode 100644 tests/unit/jume/view/ScaleModesTests.hx create mode 100644 tests/unit/jume/view/ViewTests.hx diff --git a/extraParams.hxml b/extraParams.hxml new file mode 100644 index 0000000..77767e5 --- /dev/null +++ b/extraParams.hxml @@ -0,0 +1 @@ +--macro jume.utils.Macros.init() \ No newline at end of file diff --git a/hxml/build.hxml b/hxml/build.hxml index d1e9377..634cfa1 100644 --- a/hxml/build.hxml +++ b/hxml/build.hxml @@ -1,6 +1,10 @@ -cp src -cp build + --js dist/jume.js + --debug +--macro jume.utils.Macros.init() + --main Build \ No newline at end of file diff --git a/hxml/test.hxml b/hxml/test.hxml index 0f1b5f8..39a4a52 100644 --- a/hxml/test.hxml +++ b/hxml/test.hxml @@ -5,9 +5,10 @@ --main UnitTests +--macro jume.utils.Macros.init() --debug --D headless +# -D headless # -D UTEST_PATTERN=TimeStepTests --js tests/out/tests.js diff --git a/src/jume/graphics/Pipeline.hx b/src/jume/graphics/Pipeline.hx new file mode 100644 index 0000000..7cede7f --- /dev/null +++ b/src/jume/graphics/Pipeline.hx @@ -0,0 +1,179 @@ +package jume.graphics; + +import jume.graphics.gl.Context; +import jume.graphics.gl.BlendOperation; +import jume.graphics.gl.BlendMode; + +import js.html.webgl.GL; +import js.html.webgl.Program; +import js.html.webgl.UniformLocation; + +import jume.di.Injectable; + +/** + * The pipeline class to store and use shaders. + */ +class Pipeline implements Injectable { + /** + * Defaults to BlendOne. + */ + public var blendSource: BlendMode; + + /** + * Defaults to InverseSourceAlpha. + */ + public var blendDestination: BlendMode; + + /** + * Defaults to Add. + */ + public var blendOperation: BlendOperation; + + /** + * Defaults to BlendOne. + */ + public var alphaBlendSource: BlendMode; + + /** + * Defaults to InverseSourceAlpha. + */ + public var alphaBlendDestination: BlendMode; + + /** + * Defaults to Add. + */ + public var alphaBlendOperation: BlendOperation; + + /** + * Matrix projection location. + */ + public var projectionLocation: UniformLocation; + + /** + * Texture location for shaders that use a texture. + */ + public var textureLocation: UniformLocation; + + /** + * The vertex position in the shader. + */ + public var vertexPositionLocation: Int; + + /** + * The vertex color in the shader. + */ + public var vertexColorLocation: Int; + + /** + * The vertex uv in the shader. + */ + public var vertexUVLocation: Int; + + // The shaders. + var vertexShader: Shader; + var fragmentShader: Shader; + + // The shader program. + var program: Program; + + @:inject + var context: Context; + + /** + * Create a new shader pipeline. + * @param vertexShader + * @param fragmentShader + * @param useTexture Does this shader use a texture. + */ + public function new(vertexShader: Shader, fragmentShader: Shader, useTexture: Bool) { + this.vertexShader = vertexShader; + this.fragmentShader = fragmentShader; + program = createProgram(useTexture); + + #if !headless + final projection = context.gl.getUniformLocation(program, 'projectionMatrix'); + if (projection == null) { + throw 'projectionMatrix not available in the vertex shader'; + } + projectionLocation = projection; + + textureLocation = null; + if (useTexture) { + final tex = context.gl.getUniformLocation(program, 'tex'); + if (tex == null) { + throw 'tex not available in the fragment shader'; + } + textureLocation = tex; + } + #end + + blendSource = BLEND_ONE; + blendDestination = INVERSE_SOURCE_ALPHA; + blendOperation = ADD; + + alphaBlendSource = BLEND_ONE; + alphaBlendDestination = INVERSE_SOURCE_ALPHA; + alphaBlendOperation = ADD; + } + + /** + * Start rendering using this shader. This gets called by Graphics. + */ + public inline function use() { + #if !headless + context.gl.useProgram(this.program); + #end + } + + /** + * Get a WebGL uniform location for this pipeline. + * @param location The location name + * @returns The uniform location or null if not found. + */ + public function getUniformLocation(location: String): UniformLocation { + #if !headless + return context.gl.getUniformLocation(this.program, location); + #end + + return null; + } + + /** + * Create a new shader program. + * @param useTexture + * @returns The shader program. + */ + function createProgram(useTexture: Bool): Program { + #if !headless + final gl = context.gl; + final program = gl.createProgram(); + if (program != null) { + gl.attachShader(program, vertexShader.glShader); + gl.attachShader(program, fragmentShader.glShader); + gl.linkProgram(program); + + final success: Bool = gl.getProgramParameter(program, GL.LINK_STATUS); + if (!success) { + var error = gl.getProgramInfoLog(program); + if (error == null) { + error = ''; + } + throw 'Error while linking shader program: ${error}'; + } + + gl.bindAttribLocation(program, vertexPositionLocation, 'vertexPosition'); + gl.bindAttribLocation(program, vertexColorLocation, 'vertexColor'); + + if (useTexture) { + gl.bindAttribLocation(program, vertexUVLocation, 'vertexUV'); + } + + return program; + } else { + throw 'Unable to create shader program'; + } + #end + + return null; + } +} diff --git a/src/jume/graphics/Shader.hx b/src/jume/graphics/Shader.hx new file mode 100644 index 0000000..43b67b6 --- /dev/null +++ b/src/jume/graphics/Shader.hx @@ -0,0 +1,58 @@ +package jume.graphics; + +import jume.graphics.gl.Context; + +import haxe.Exception; + +import js.html.webgl.GL; +import js.html.webgl.Shader as WebGLShader; + +import jume.di.Injectable; + +/** + * The shader class is used to create custom shaders. + */ +class Shader implements Injectable { + /** + * The shader id. + */ + public var glShader(default, null): WebGLShader; + + /** + * The rendering context. + */ + @:inject + final context: Context; + + /** + * Create a new Shader. + * @param source The shader source text. + * @param type The shader type (Vertex or Fragment.) + */ + public function new(source: String, type: ShaderType) { + #if !headless + final gl = context.gl; + final shaderType = type == ShaderType.VERTEX ? GL.VERTEX_SHADER : GL.FRAGMENT_SHADER; + final shader = gl.createShader(shaderType); + if (shader == null) { + throw new Exception('Unable to load shader:\n ${source}'); + } + glShader = shader; + + gl.shaderSource(glShader, source); + gl.compileShader(glShader); + if (!gl.getShaderParameter(glShader, GL.COMPILE_STATUS)) { + throw new Exception('Could not compile shader:\n, ${gl.getShaderInfoLog(glShader)}'); + } + #end + } + + /** + * Delete the shader. + */ + public inline function destroy() { + #if !headless + context.gl.deleteShader(glShader); + #end + } +} diff --git a/src/jume/graphics/ShaderType.hx b/src/jume/graphics/ShaderType.hx new file mode 100644 index 0000000..b6db1dd --- /dev/null +++ b/src/jume/graphics/ShaderType.hx @@ -0,0 +1,9 @@ +package jume.graphics; + +/** + * Shader type options. + */ +enum abstract ShaderType(String) { + var VERTEX = 'vertex'; + var FRAGMENT = 'fragment'; +} diff --git a/src/jume/graphics/gl/BlendMode.hx b/src/jume/graphics/gl/BlendMode.hx new file mode 100644 index 0000000..09e77ca --- /dev/null +++ b/src/jume/graphics/gl/BlendMode.hx @@ -0,0 +1,18 @@ +package jume.graphics.gl; + +/** + * Blend mode options. + */ +enum abstract BlendMode(String) { + var UNDEFINED = 'undefined'; + var BLEND_ONE = 'blend_one'; + var BLEND_ZERO = 'blend_zero'; + var SOURCE_ALPHA = 'source_alpha'; + var DESTINATION_ALPHA = 'destination_alpha'; + var INVERSE_SOURCE_ALPHA = 'inverse_source_alpha'; + var INVERSE_DESTINATION_ALPHA = 'inverse_destination_alpha'; + var SOURCE_COLOR = 'source_color'; + var DESTINATION_COLOR = 'destination_color'; + var INVERSE_SOURCE_COLOR = 'inverse_source_color'; + var INVERSE_DESTINATION_COLOR = 'inverse_destination_color'; +} diff --git a/src/jume/graphics/gl/BlendOperation.hx b/src/jume/graphics/gl/BlendOperation.hx new file mode 100644 index 0000000..5bf8a96 --- /dev/null +++ b/src/jume/graphics/gl/BlendOperation.hx @@ -0,0 +1,10 @@ +package jume.graphics.gl; + +/** + * Blend operation options. + */ +enum abstract BlendOperation(String) { + var ADD = 'add'; + var SUBTRACT = 'subtract'; + var REVERSE_SUBTRACT = 'reverse_subtract'; +} diff --git a/src/jume/graphics/gl/Context.hx b/src/jume/graphics/gl/Context.hx new file mode 100644 index 0000000..feaa8d7 --- /dev/null +++ b/src/jume/graphics/gl/Context.hx @@ -0,0 +1,205 @@ +package jume.graphics.gl; + +import js.html.webgl.GL; + +import haxe.Exception; + +import js.Browser; +import js.html.CanvasElement; +import js.html.webgl.ContextAttributes; +import js.html.webgl.GL2; + +import jume.di.Service; + +/** + * WebGL context service. + */ +class Context implements Service { + /** + * Is the game running WebGL 1. + */ + public final isGL1: Bool; + + /** + * The WebGL context. + */ + public var gl(default, null): GL2; + + /** + * Create a new Context instance. + * @param canvasId The id of the canvas element. + * @param forceWebGL1 Should WebGL 1 be forced. + */ + public function new(canvasId: String, forceWebGL1: Bool) { + var gl1 = false; + + #if !headless + final attributes: ContextAttributes = { + alpha: false, + antialias: true + }; + + final canvas: CanvasElement = cast Browser.document.getElementById(canvasId); + var context = forceWebGL1 ? null : canvas.getContextWebGL2(attributes); + + if (context == null) { + context = cast canvas.getContextWebGL(attributes); + if (context == null) { + throw new Exception('Unable to initialize WebGL context.'); + } + gl1 = true; + } + + gl = context; + + gl.pixelStorei(GL.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1); + gl.getExtension('OES_texture_float_linear'); + gl.getExtension('OES_texture_half_float_linear'); + + if (gl1) { + gl.getExtension('OES_texture_float'); + gl.getExtension('EXT_shader_texture_lod'); + gl.getExtension('OES_standard_derivatives'); + } else { + gl.getExtension('EXT_color_buffer_float'); + } + + gl.enable(GL.BLEND); + gl.blendFunc(GL.ONE, GL.ONE_MINUS_SRC_ALPHA); + #end + + isGL1 = gl1; + } + + /** + * Get the WebGL blend mode. + * @param mode The blend mode. + * @return The GL blend mode. + */ + public function getGLBlendMode(mode: BlendMode): Int { + #if !headless + switch (mode) { + case BLEND_ZERO: + return GL.ZERO; + + case UNDEFINED: + return GL.ZERO; + + case BLEND_ONE: + return GL.ONE; + + case SOURCE_ALPHA: + return GL.SRC_ALPHA; + + case DESTINATION_ALPHA: + return GL.DST_ALPHA; + + case INVERSE_SOURCE_ALPHA: + return GL.ONE_MINUS_SRC_ALPHA; + + case INVERSE_DESTINATION_ALPHA: + return GL.ONE_MINUS_DST_ALPHA; + + case SOURCE_COLOR: + return GL.SRC_COLOR; + + case DESTINATION_COLOR: + return GL.DST_COLOR; + + case INVERSE_SOURCE_COLOR: + return GL.ONE_MINUS_SRC_COLOR; + + case INVERSE_DESTINATION_COLOR: + return GL.ONE_MINUS_DST_COLOR; + } + #else + return -1; + #end + } + + /** + * Get the WebGL blend operation. + * @param operation The blend operation. + * @return The GL blend operation. + */ + public function getBlendOperation(operation: BlendOperation): Int { + #if !headless + switch (operation) { + case ADD: + return GL.FUNC_ADD; + + case SUBTRACT: + return GL.FUNC_SUBTRACT; + + case REVERSE_SUBTRACT: + return GL.FUNC_REVERSE_SUBTRACT; + } + #else + return -1; + #end + } + + /** + * Get the WebGL texture wrap mode. + * @param wrap The texture wrap mode. + * @return The GL wrap mode. + */ + public function getTextureWrap(wrap: TextureWrap): Int { + switch (wrap) { + case CLAMP_TO_EDGE: + return GL.CLAMP_TO_EDGE; + + case REPEAT: + return GL.REPEAT; + + case MIRRORED_REPEAT: + return GL.MIRRORED_REPEAT; + } + } + + /** + * Get the WebGL texture filter. + * @param filter The texture filter. + * @param mipmap The mipmap filter. + * @return The GL texture filter. + */ + public function getTextureFilter(filter: TextureFilter, mipmap: MipmapFilter = NONE): Int { + switch (filter) { + case NEAREST: + switch (mipmap) { + case NONE: + return GL.NEAREST; + + case NEAREST: + return GL.NEAREST_MIPMAP_NEAREST; + + case LINEAR: + return GL.NEAREST_MIPMAP_LINEAR; + } + + case LINEAR: + switch (mipmap) { + case NONE: + return GL.LINEAR; + + case NEAREST: + return GL.LINEAR_MIPMAP_NEAREST; + + case LINEAR: + return GL.LINEAR_MIPMAP_LINEAR; + } + + case ANISOTROPIC: + switch (mipmap) { + case NONE: + return GL.LINEAR; + + case NEAREST: + return GL.LINEAR_MIPMAP_NEAREST; + + case LINEAR: + return GL.LINEAR_MIPMAP_LINEAR; + } + } + } +} diff --git a/src/jume/graphics/gl/MipmapFilter.hx b/src/jume/graphics/gl/MipmapFilter.hx new file mode 100644 index 0000000..a500ebd --- /dev/null +++ b/src/jume/graphics/gl/MipmapFilter.hx @@ -0,0 +1,10 @@ +package jume.graphics.gl; + +/** + * Mipmap filtering options. + */ +enum abstract MipmapFilter(String) { + var NONE = 'none'; + var NEAREST = 'nearest'; + var LINEAR = 'linear'; +} diff --git a/src/jume/graphics/gl/TextureFilter.hx b/src/jume/graphics/gl/TextureFilter.hx new file mode 100644 index 0000000..874b2ed --- /dev/null +++ b/src/jume/graphics/gl/TextureFilter.hx @@ -0,0 +1,10 @@ +package jume.graphics.gl; + +/** + * Texture filter options. + */ +enum abstract TextureFilter(String) { + var NEAREST = 'nearest'; + var LINEAR = 'linear'; + var ANISOTROPIC = 'anisotropic'; +} diff --git a/src/jume/graphics/gl/TextureWrap.hx b/src/jume/graphics/gl/TextureWrap.hx new file mode 100644 index 0000000..37682e4 --- /dev/null +++ b/src/jume/graphics/gl/TextureWrap.hx @@ -0,0 +1,10 @@ +package jume.graphics.gl; + +/** + * Texture wrap options. + */ +enum abstract TextureWrap(String) { + var REPEAT = 'repeat'; + var CLAMP_TO_EDGE = 'clamp_to_edge'; + var MIRRORED_REPEAT = 'mirrored_repeat'; +} diff --git a/src/jume/utils/Bitset.hx b/src/jume/utils/Bitset.hx new file mode 100644 index 0000000..d1f471b --- /dev/null +++ b/src/jume/utils/Bitset.hx @@ -0,0 +1,50 @@ +package jume.utils; + +/** + * Bit Sets are used to bit shift. + */ +class Bitset { + /** + * Add a bit to the mask. + * @param bits The bits to add to. + * @param mask The mask to add. + */ + public static inline function add(bits: Int, mask: Int): Int { + return bits | mask; + } + + /** + * Remove a bit from the mask. + * @param bits The bits to remove from. + * @param mask The mas to remove. + */ + public static inline function remove(bits: Int, mask: Int): Int { + return bits & ~mask; + } + + /** + * Check if a bit mask has a bit. + * @param bits The bits to check. + * @param mask The mask to check with. + * @return True if the bits have the mask. + */ + public static inline function has(bits: Int, mask: Int): Bool { + return bits & mask == mask; + } + + /** + * Check if a bit mask has a bit. + * @param bits The bits to check. + * @param masks The masks to check with. + * @return True if the bits have all the masks. + */ + public static function hasAll(bits: Int, masks: Array): Bool { + for (mask in masks) { + if (!has(bits, mask)) { + return false; + } + } + + return true; + } +} diff --git a/src/jume/utils/Macros.hx b/src/jume/utils/Macros.hx index f8e16d0..3084111 100644 --- a/src/jume/utils/Macros.hx +++ b/src/jume/utils/Macros.hx @@ -1,5 +1,6 @@ package jume.utils; +import haxe.macro.Compiler; import haxe.macro.Expr; import haxe.macro.Expr.FunctionArg; #if macro @@ -9,6 +10,15 @@ import haxe.macro.Expr.Function; using haxe.macro.Tools; +function init() { + Compiler.registerCustomMetadata({ + metadata: ':inject', + doc: 'Inject the service of this type.', + targets: [ClassField], + platforms: [Js] + }, 'jume'); +} + function inject(): Array { // Get all the fields in the event class. final fields = Context.getBuildFields(); diff --git a/src/jume/view/ScaleModes.hx b/src/jume/view/ScaleModes.hx new file mode 100644 index 0000000..bcad50a --- /dev/null +++ b/src/jume/view/ScaleModes.hx @@ -0,0 +1,199 @@ +package jume.view; + +/** + * The values returned from a scale mode function. + */ +typedef ScaleModeReturn = { + /** + * The scaled game view width in pixels. + */ + var viewWidth: Int; + + /** + * The scaled game view heigh in pixels. + */ + var viewHeight: Int; + + /** + * The amount scaled on the x axis. + */ + var scaleFactorX: Float; + + /** + * The amount scaled on the y axis. + */ + var scaleFactorY: Float; + + /** + * The horizontal view offset inside the canvas in pixels. + */ + var offsetX: Float; + + /** + * The vertical view offset inside the canvas in pixels. + */ + var offsetY: Float; +} + +/** + * The parameters passed into a scale mode function. + */ +typedef ScaleModeParams = { + /** + * The width in pixels the game is designed for before scaling. + */ + var designWidth: Int; + + /** + * The height in pixels the game is designed for before scaling. + */ + var designHeight: Int; + + /** + * The html canvas width in pixels. + */ + var canvasWidth: Int; + + /** + * The html canvas width in pixels. + */ + var canvasHeight: Int; + + /** + * The horizontal anchor in the screen (0 - 1). + */ + var anchorX: Float; + + /** + * The vertical anchor in the screen (0 - 1). + */ + var anchorY: Float; +} + +/** + * The scale mode function blueprint. + */ +typedef ScaleMode = (params: ScaleModeParams)->ScaleModeReturn; + +/** + * Scale the view to fit the canvas. Will cut off parts of the view to make it fit. Keeps aspect ratio. + * @param params The scale mode parameters. + * @return The scaled values. + */ +function scaleModeFitView(params: ScaleModeParams): ScaleModeReturn { + final designRatio = params.designWidth / params.designHeight; + final canvasRatio = params.canvasWidth / params.canvasHeight; + + var viewWidth = 0; + var viewHeight = 0; + if (canvasRatio < designRatio) { + viewWidth = params.designWidth; + viewHeight = Math.ceil(viewWidth / canvasRatio); + } else { + viewHeight = params.designHeight; + viewWidth = Math.ceil(viewHeight * canvasRatio); + } + + final scaleFactor = params.canvasWidth / viewWidth; + + final offsetX = (params.canvasWidth - params.designWidth * scaleFactor) * params.anchorX; + final offsetY = (params.canvasHeight - params.designHeight * scaleFactor) * params.anchorY; + + return { + viewWidth: viewWidth, + viewHeight: viewHeight, + scaleFactorX: scaleFactor, + scaleFactorY: scaleFactor, + offsetX: offsetX, + offsetY: offsetY + }; +} + +/** + * Scale the view to fit the width of the canvas. Will cut off parts at the top and bottom to fit. Keeps aspect ratio. + * @param params The scale mode parameters. + * @return The scaled values. + */ +function scaleModeFitWidth(params: ScaleModeParams): ScaleModeReturn { + final canvasRatio = params.canvasWidth / params.canvasHeight; + final viewWidth = params.designWidth; + final viewHeight = Math.ceil(viewWidth / canvasRatio); + final scaleFactor = params.canvasWidth / viewWidth; + final offsetX = (params.canvasWidth - params.designWidth * scaleFactor) * params.anchorX; + final offsetY = (params.canvasHeight - params.designHeight * scaleFactor) * params.anchorY; + + return { + viewWidth: viewWidth, + viewHeight: viewHeight, + scaleFactorX: scaleFactor, + scaleFactorY: scaleFactor, + offsetX: offsetX, + offsetY: offsetY + }; +} + +/** + * Scale the view to fit the height of the canvas. Will cut off parts at the left and right to fit. Keeps aspect ratio. + * @param params The scale mode parameters. + * @return The scaled values. + */ +function scaleModeFitHeight(params: ScaleModeParams): ScaleModeReturn { + final canvasRatio = params.canvasWidth / params.canvasHeight; + final viewHeight = params.designHeight; + final viewWidth = Math.ceil(viewHeight * canvasRatio); + + final scaleFactor = params.canvasHeight / viewHeight; + + final offsetX = (params.canvasWidth - params.designWidth * scaleFactor) * params.anchorX; + final offsetY = (params.canvasHeight - params.designHeight * scaleFactor) * params.anchorY; + + return { + viewWidth: viewWidth, + viewHeight: viewHeight, + scaleFactorX: scaleFactor, + scaleFactorY: scaleFactor, + offsetX: offsetX, + offsetY: offsetY + }; +} + +/** + * Don't scale the view. Just offset it inside the canvas if needed. + * @param params The scale mode parameters. + * @return The scaled values. + */ +function scaleModeNoScale(params: ScaleModeParams): ScaleModeReturn { + final offsetX = (params.canvasWidth - params.designWidth) * params.anchorX; + final offsetY = (params.canvasHeight - params.designHeight) * params.anchorY; + + return { + viewWidth: params.designWidth, + viewHeight: params.designHeight, + scaleFactorX: 1.0, + scaleFactorY: 1.0, + offsetX: offsetX, + offsetY: offsetY + }; +} + +/** + * Stretch the view to fit the canvas. Does not keep the aspect ratio and can distort the view. + * @param params The scale mode parameters. + * @return The scaled values. + */ +function scaleModeStretch(params: ScaleModeParams): ScaleModeReturn { + final viewWidth = params.designWidth; + final viewHeight = params.designHeight; + + final scaleFactorX = params.canvasWidth / viewWidth; + final scaleFactorY = params.canvasHeight / viewHeight; + + return { + viewWidth: viewWidth, + viewHeight: viewHeight, + scaleFactorX: scaleFactorX, + scaleFactorY: scaleFactorY, + offsetX: 0, + offsetY: 0 + }; +} diff --git a/src/jume/view/View.hx b/src/jume/view/View.hx new file mode 100644 index 0000000..707dc91 --- /dev/null +++ b/src/jume/view/View.hx @@ -0,0 +1,269 @@ +package jume.view; + +import js.Browser; +import js.html.CanvasElement; + +import jume.di.Service; +import jume.math.Size; +import jume.math.Vec2; +import jume.view.ScaleModes.ScaleMode; +import jume.view.ScaleModes.scaleModeFitView; + +typedef ViewParams = { + /** + * The width the game is designed for in pixels. + */ + var width: Int; + + /** + * The height the game is designed for in pixels. + */ + var height: Int; + + /** + * Use point filtering for better pixel art results. + */ + var pixelFilter: Bool; + + /** + * The ratio between physical pixels and logical pixels. + */ + var pixelRatio: Int; + + /** + * Is the game is full screen. + */ + var isFullScreen: Bool; + + /** + * The id of the canvas object. + */ + var canvasId: String; + + /** + * The frames per second the game will try to run at. + */ + var targetFps: Int; +} + +/** + * The view class has view related information like design, view, and window size. + */ +class View implements Service { + /** + * The ratio between physical pixels and logical pixels. + */ + public final pixelRatio: Int; + + /** + * The top left anchor of the view. + */ + public final viewAnchor = new Vec2(); + + /** + * Use point filtering for better pixel art results. + */ + public var pixelFilter: Bool; + + /** + * The frames per second the game will try to run at. + */ + public var targetFps: Int; + + /** + * Check if the game is full screen. + * TODO: Changing full screen after game start needs to be implemented. + */ + public var isFullScreen: Bool; + + /** + * The game canvas element. + */ + public var canvas(default, null): CanvasElement; + + /** + * The width the game is designed for in pixels. + */ + public var designWidth(get, never): Int; + + /** + * The height the game is designed for in pixels. + */ + public var designHeight(get, never): Int; + + /** + * The canvas element width in pixels. + */ + public var canvasWidth(get, never): Int; + + /** + * The canvas element height in pixels. + */ + public var canvasHeight(get, never): Int; + + /** + * The canvas center x axis position in pixels. + */ + public var canvasCenterX(get, never): Int; + + /** + * The canvas center y axis position in pixels. + */ + public var canvasCenterY(get, never): Int; + + /** + * The scale game view width in pixels. + */ + public var viewWidth(get, never): Int; + + /** + * The scale game view height in pixels. + */ + public var viewHeight(get, never): Int; + + /** + * The view center x axis position in pixels. + */ + public var viewCenterX(get, never): Int; + + /** + * The view center x axis position in pixels. + */ + public var viewCenterY(get, never): Int; + + /** + * The scale factor on the x axis between canvas width and view width. + */ + public var viewScaleX(get, never): Float; + + /** + * The scale factor on the y axis between canvas height and view height. + */ + public var viewScaleY(get, never): Float; + + /** + * The current scale mode. + */ + public var scaleMode(default, null): ScaleMode; + + /** + * Internal design size. + */ + final designSize = new Size(); + + /** + * Internal view size. + */ + final viewSize = new Size(); + + /** + * Internal view scale. + */ + final viewScale = new Vec2(); + + /** + * Internal view offset. + */ + final viewOffset = new Vec2(); + + /** + * Initialize the View. This gets called automatically by the Game class on startup. + */ + public function new(params: ViewParams) { + designSize.set(params.width, params.height); + pixelFilter = params.pixelFilter; + + #if !headless + canvas = cast Browser.document.getElementById(params.canvasId); + #end + + pixelRatio = params.pixelRatio; + isFullScreen = params.isFullScreen; + scaleMode = scaleModeFitView; + targetFps = params.targetFps; + scaleToFit(); + } + + /** + * Scale the design size to fit the canvas. The result will be the view size. + */ + public function scaleToFit() { + final result = scaleMode({ + designWidth: designWidth, + designHeight: designHeight, + canvasWidth: canvasWidth, + canvasHeight: canvasHeight, + anchorX: viewAnchor.x, + anchorY: viewAnchor.y + }); + + viewSize.set(result.viewWidth, result.viewHeight); + viewScale.set(result.scaleFactorX, result.scaleFactorY); + viewOffset.set(result.offsetX, result.offsetY); + } + + /** + * Set a new scale mode. + * @param mode The new scale mode. + */ + public function setScaleMode(mode: ScaleMode) { + scaleMode = mode; + scaleToFit(); + } + + inline function get_designWidth(): Int { + return designSize.widthi; + } + + inline function get_designHeight(): Int { + return designSize.heighti; + } + + function get_canvasWidth(): Int { + #if headless + // Hard code width for headless mode. + return Math.round(800 * pixelRatio); + #end + return Math.round(canvas.clientWidth * pixelRatio); + } + + function get_canvasHeight(): Int { + #if headless + // Hard code height for headless mode. + return Math.round(600 * pixelRatio); + #end + return Math.round(canvas.clientHeight * pixelRatio); + } + + inline function get_canvasCenterX(): Int { + return Math.floor(canvasWidth * 0.5); + } + + inline function get_canvasCenterY(): Int { + return Math.floor(canvasHeight * 0.5); + } + + inline function get_viewWidth(): Int { + return viewSize.widthi; + } + + inline function get_viewHeight(): Int { + return viewSize.heighti; + } + + inline function get_viewCenterX(): Int { + return Math.floor(viewSize.width * 0.5); + } + + inline function get_viewCenterY(): Int { + return Math.floor(viewSize.height * 0.5); + } + + inline function get_viewScaleX(): Float { + return viewScale.x; + } + + inline function get_viewScaleY(): Float { + return viewScale.y; + } +} diff --git a/tests/unit/UnitTests.hx b/tests/unit/UnitTests.hx index c143e74..f705421 100644 --- a/tests/unit/UnitTests.hx +++ b/tests/unit/UnitTests.hx @@ -1,5 +1,8 @@ package; +import jume.view.ViewTests; +import jume.view.ScaleModesTests; +import jume.utils.BitsetTests; import jume.di.InjectableTests; import jume.di.ServicesTests; import jume.events.EventListenerTests; @@ -40,8 +43,12 @@ class UnitTests { runner.addCase(new Vec2Tests()); runner.addCase(new Vec3Tests()); + runner.addCase(new BitsetTests()); runner.addCase(new TimeStepTests()); + runner.addCase(new ScaleModesTests()); + runner.addCase(new ViewTests()); + Report.create(runner); runner.run(); diff --git a/tests/unit/jume/utils/BitsetTests.hx b/tests/unit/jume/utils/BitsetTests.hx new file mode 100644 index 0000000..6c66f6f --- /dev/null +++ b/tests/unit/jume/utils/BitsetTests.hx @@ -0,0 +1,42 @@ +package jume.utils; + +import utest.Assert; +import utest.Test; + +class BitsetTests extends Test { + function testAdd() { + final bits = 0x000001; + final mask = 0x000101; + + Assert.equals(0x000101, Bitset.add(bits, mask)); + } + + function testRemove() { + final bits = 0x001111; + final mask = 0x000101; + + Assert.equals(0x001010, Bitset.remove(bits, mask)); + } + + function testHas() { + final bits = 0x00011010; + var mask = 0x00010010; + + Assert.isTrue(Bitset.has(bits, mask)); + + mask = 0x00100001; + + Assert.isFalse(Bitset.has(bits, mask)); + } + + function testHasAll() { + final bits = 0x00011010; + var list = [0x00010010, 0x00001010]; + + Assert.isTrue(Bitset.hasAll(bits, list)); + + list = [0x00010010, 0x00001011]; + + Assert.isFalse(Bitset.hasAll(bits, list)); + } +} diff --git a/tests/unit/jume/view/ScaleModesTests.hx b/tests/unit/jume/view/ScaleModesTests.hx new file mode 100644 index 0000000..8f596b4 --- /dev/null +++ b/tests/unit/jume/view/ScaleModesTests.hx @@ -0,0 +1,264 @@ +package jume.view; + +import jume.view.ScaleModes.scaleModeStretch; +import jume.view.ScaleModes.scaleModeNoScale; +import jume.view.ScaleModes.scaleModeFitHeight; +import jume.view.ScaleModes.scaleModeFitWidth; + +import utest.Assert; + +import jume.view.ScaleModes.scaleModeFitView; + +import utest.Test; + +class ScaleModesTests extends Test { + function testScaleModeFitView() { + var result = scaleModeFitView({ + designWidth: 200, + designHeight: 400, + canvasWidth: 600, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(300, result.viewWidth); + Assert.equals(400, result.viewHeight); + Assert.floatEquals(2, result.scaleFactorX); + Assert.floatEquals(2, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeFitView({ + designWidth: 100, + designHeight: 300, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(150, result.viewWidth); + Assert.equals(300, result.viewHeight); + Assert.floatEquals(2.66666, result.scaleFactorX); + Assert.floatEquals(2.66666, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeFitView({ + designWidth: 400, + designHeight: 200, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(400, result.viewWidth); + Assert.equals(800, result.viewHeight); + Assert.floatEquals(1, result.scaleFactorX); + Assert.floatEquals(1, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + } + + function testScaleModeFitWidth() { + var result = scaleModeFitWidth({ + designWidth: 200, + designHeight: 400, + canvasWidth: 600, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(200, result.viewWidth); + Assert.equals(267, result.viewHeight); + Assert.floatEquals(3, result.scaleFactorX); + Assert.floatEquals(3, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeFitWidth({ + designWidth: 100, + designHeight: 300, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(100, result.viewWidth); + Assert.equals(200, result.viewHeight); + Assert.floatEquals(4, result.scaleFactorX); + Assert.floatEquals(4, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeFitWidth({ + designWidth: 400, + designHeight: 200, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(400, result.viewWidth); + Assert.equals(800, result.viewHeight); + Assert.floatEquals(1, result.scaleFactorX); + Assert.floatEquals(1, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + } + + function testScaleModeFitHeight() { + var result = scaleModeFitHeight({ + designWidth: 200, + designHeight: 400, + canvasWidth: 600, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(300, result.viewWidth); + Assert.equals(400, result.viewHeight); + Assert.floatEquals(2, result.scaleFactorX); + Assert.floatEquals(2, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeFitHeight({ + designWidth: 100, + designHeight: 300, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(150, result.viewWidth); + Assert.equals(300, result.viewHeight); + Assert.floatEquals(2.66666, result.scaleFactorX); + Assert.floatEquals(2.66666, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeFitHeight({ + designWidth: 400, + designHeight: 200, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(100, result.viewWidth); + Assert.equals(200, result.viewHeight); + Assert.floatEquals(4, result.scaleFactorX); + Assert.floatEquals(4, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + } + + function testScaleModeNoScale() { + var result = scaleModeNoScale({ + designWidth: 200, + designHeight: 400, + canvasWidth: 600, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(200, result.viewWidth); + Assert.equals(400, result.viewHeight); + Assert.floatEquals(1, result.scaleFactorX); + Assert.floatEquals(1, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeNoScale({ + designWidth: 100, + designHeight: 300, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(100, result.viewWidth); + Assert.equals(300, result.viewHeight); + Assert.floatEquals(1, result.scaleFactorX); + Assert.floatEquals(1, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeNoScale({ + designWidth: 400, + designHeight: 200, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(400, result.viewWidth); + Assert.equals(200, result.viewHeight); + Assert.floatEquals(1, result.scaleFactorX); + Assert.floatEquals(1, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + } + + function testScaleModeStretch() { + var result = scaleModeStretch({ + designWidth: 200, + designHeight: 400, + canvasWidth: 600, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(200, result.viewWidth); + Assert.equals(400, result.viewHeight); + Assert.floatEquals(3, result.scaleFactorX); + Assert.floatEquals(2, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeStretch({ + designWidth: 100, + designHeight: 300, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(100, result.viewWidth); + Assert.equals(300, result.viewHeight); + Assert.floatEquals(4, result.scaleFactorX); + Assert.floatEquals(2.66666, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + + result = scaleModeStretch({ + designWidth: 400, + designHeight: 200, + canvasWidth: 400, + canvasHeight: 800, + anchorX: 0, + anchorY: 0 + }); + + Assert.equals(400, result.viewWidth); + Assert.equals(200, result.viewHeight); + Assert.floatEquals(1, result.scaleFactorX); + Assert.floatEquals(4, result.scaleFactorY); + Assert.floatEquals(0, result.offsetX); + Assert.floatEquals(0, result.offsetY); + } +} diff --git a/tests/unit/jume/view/ViewTests.hx b/tests/unit/jume/view/ViewTests.hx new file mode 100644 index 0000000..b5dda28 --- /dev/null +++ b/tests/unit/jume/view/ViewTests.hx @@ -0,0 +1,59 @@ +package jume.view; + +import jume.view.ScaleModes.scaleModeStretch; + +import utest.Assert; +import utest.Test; + +class ViewTests extends Test { + function testNewView() { + final view = new View({ + width: 400, + height: 300, + pixelFilter: true, + pixelRatio: 1, + isFullScreen: true, + canvasId: 'jume', + targetFps: 60 + }); + + Assert.equals(1, view.pixelRatio); + Assert.isTrue(view.pixelFilter); + Assert.isTrue(view.isFullScreen); + + Assert.equals(400, view.designWidth); + Assert.equals(300, view.designHeight); + + Assert.equals(800, view.canvasWidth); + Assert.equals(600, view.canvasHeight); + Assert.equals(400, view.canvasCenterX); + Assert.equals(300, view.canvasCenterY); + + Assert.equals(400, view.viewWidth); + Assert.equals(300, view.viewHeight); + Assert.equals(200, view.viewCenterX); + Assert.equals(150, view.viewCenterY); + + Assert.equals(2, view.viewScaleX); + Assert.equals(2, view.viewScaleY); + } + + function testSetScaleMode() { + final view = new View({ + width: 200, + height: 300, + pixelFilter: true, + pixelRatio: 1, + isFullScreen: true, + canvasId: 'jume', + targetFps: 60 + }); + view.setScaleMode(scaleModeStretch); + + Assert.equals(200, view.viewWidth); + Assert.equals(300, view.viewHeight); + + Assert.equals(4, view.viewScaleX); + Assert.equals(2, view.viewScaleY); + } +}